Compare commits
503 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc024c9d99 | |||
| 9f09dd06c9 | |||
| 133e70ea89 | |||
| 3a4146e0c3 | |||
| fd5603629f | |||
| 4c77c595c6 | |||
| 3f825e4375 | |||
| 2d6f6e5cb0 | |||
| 5580824ac9 | |||
| ed0266029a | |||
| 1feed40962 | |||
| b2a7ef5f41 | |||
| 65e5ce0c63 | |||
| 2de2d9ccb6 | |||
| 7231f620c8 | |||
| 6df32b6b34 | |||
| 02e429d99d | |||
| 554964f57c | |||
| 866fa254f6 | |||
| 9d39ec8802 | |||
| e3a4634d3c | |||
| ad35a0b7f9 | |||
| b2352e338b | |||
| 53741632a4 | |||
| e9652daba9 | |||
| ed5b7f669f | |||
| ceeabd1ebc | |||
| 15149ffa0b | |||
| 2f180747b7 | |||
| 25fe90253c | |||
| 86d122aacf | |||
| 9737d5c930 | |||
| c6088d91fa | |||
| 15fdf77ce4 | |||
| f7f001ac2e | |||
| 3524d02047 | |||
| 7948201e18 | |||
| 8d84137a27 | |||
| 2273cffbc2 | |||
| 63ef35a005 | |||
| 417be73b23 | |||
| 18367219c8 | |||
| 2a77cf1a02 | |||
| 41b0f7d742 | |||
| 24c81c234f | |||
| 4833d9f00d | |||
| 37e3497522 | |||
| 8229aa73fe | |||
| e67590d0a1 | |||
| 33f0608d84 | |||
| 3d1a586c73 | |||
| 7d36253668 | |||
| e076e9f4ca | |||
| 44aa4d3a32 | |||
| a1d08b6fa4 | |||
| fc4a71f7e1 | |||
| 9a59b9fea4 | |||
| 19081a2e1f | |||
| 3cb4003bcd | |||
| beb48e500e | |||
| 93c4679c46 | |||
| d6688def9d | |||
| 489d53f55c | |||
| 3deb8fb8d2 | |||
| 1af3a0f092 | |||
| de80ac551b | |||
| 89601efde0 | |||
| 235065322c | |||
| 11b3e120e2 | |||
| 667edf3724 | |||
| 4a10e27ddf | |||
| b4305a1edb | |||
| 7598ee96da | |||
| 6de7c861ed | |||
| 658c57e22c | |||
| 60cf1dab18 | |||
| 4ce9183bd3 | |||
| 7dd002396f | |||
| 1130a830c7 | |||
| 66a9026b8f | |||
| 458f1e517c | |||
| a13f9b481a | |||
| de70a22c42 | |||
| f3d3506e02 | |||
| 1daa14584a | |||
| 08c9214976 | |||
| c110dcd40e | |||
| eaa3e1e6af | |||
| 6562d16ce5 | |||
| 15a5b16b39 | |||
| d9c0c9c68e | |||
| d1ca123104 | |||
| f37647599a | |||
| cb14e84a26 | |||
| 8d7e4d1066 | |||
| c30ed923b6 | |||
| 50713188bf | |||
| e2ccd11f07 | |||
| f3316a017b | |||
| bdf1b63833 | |||
| 3ef26f2918 | |||
| 372360d739 | |||
| dc5877f398 | |||
| 49b2113fe1 | |||
| 556cfa1ee3 | |||
| 1ab4093d54 | |||
| bf27da5c66 | |||
| 1010b9fce7 | |||
| 3e34bd6bff | |||
| 3f892493c0 | |||
| 7d4567efbe | |||
| 9aed707a77 | |||
| 0c373e6b2c | |||
| a501b32a03 | |||
| 8ab6603999 | |||
| 85383f989a | |||
| 0423ac31d9 | |||
| 35f37f3a36 | |||
| 78d7bb9262 | |||
| 3c545be5c5 | |||
| c1983f75e6 | |||
| 7c3de6d77f | |||
| 17dc80f11b | |||
| 846d449aac | |||
| db9b7335f2 | |||
| 6f98473009 | |||
| 357261ec73 | |||
| 5bef901295 | |||
| 59e247c012 | |||
| a87c4796b5 | |||
| 4d289ee14a | |||
| 08feb7c9dd | |||
| 9cb06cb71e | |||
| 5ec607d94a | |||
| ac7ef119e0 | |||
| 03c8127bd3 | |||
| eb001e59b3 | |||
| 3e46d4b280 | |||
| eae07fcad0 | |||
| 41b65a76c1 | |||
| 67c992806f | |||
| 33672315d2 | |||
| 26fc0c67a5 | |||
| 4032ed4e3f | |||
| 2147746ff9 | |||
| 819b3b4de7 | |||
| 3a909c76e0 | |||
| 30c7f6f705 | |||
| b4f1985c99 | |||
| c57e7ebdfe | |||
| fc79251f66 | |||
| 93f6eeac87 | |||
| 138fd1184c | |||
| 0f0e8e9f98 | |||
| 576c0a82f8 | |||
| 57cb349371 | |||
| 791040364b | |||
| defd744ef0 | |||
| 298809686b | |||
| 1b80d008ef | |||
| 0e53e43c77 | |||
| bb76d2edcf | |||
| c9499d839c | |||
| ba1dd3df6b | |||
| 721f93d82d | |||
| 9df663dc9b | |||
| c535126605 | |||
| 6df7b16cdf | |||
| 7cc7919da4 | |||
| e639cee4ee | |||
| da299d8a03 | |||
| b04a8460b0 | |||
| 62b4816498 | |||
| 56c811ad89 | |||
| 71b3989083 | |||
| e753344137 | |||
| bc32a8a10e | |||
| 906f73f303 | |||
| d84c307593 | |||
| de8524b37c | |||
| c67d8761e2 | |||
| 7600ae86d1 | |||
| 97e0998e1d | |||
| 5b85acbd73 | |||
| ca84f4dfea | |||
| 315f0f3858 | |||
| 21d5f74dd8 | |||
| 08c9d5a0e3 | |||
| 4a719b6c9a | |||
| 7043af47c3 | |||
| ebbb3e8ed0 | |||
| 863f0bba2c | |||
| 7df81177de | |||
| 9f6ea0fb58 | |||
| f64514cbe6 | |||
| b651bc6385 | |||
| 6b7351d1b3 | |||
| 878b93889f | |||
| 9edbcc6f01 | |||
| 3f6661e2dc | |||
| f9a8e3149f | |||
| 4ef7c2fb0b | |||
| d6689888b2 | |||
| 1523091637 | |||
| 0b0046986e | |||
| 1b0c0da677 | |||
| 660a619944 | |||
| dcb7957d15 | |||
| dfe07b16ae | |||
| 9bbb40ce8c | |||
| 98c5946efd | |||
| 40fd739b2c | |||
| d2909ab21d | |||
| 4f5445a833 | |||
| 54dad486a4 | |||
| 84fca7ba25 | |||
| 795b4e5095 | |||
| 2e497ccec8 | |||
| d0c5ddc9c7 | |||
| 9871d1c23b | |||
| 50eec23c85 | |||
| 1675c48fc3 | |||
| 86ec6de894 | |||
| ca0a51ec15 | |||
| 244aba88d3 | |||
| 4b7e815c68 | |||
| c946e56a41 | |||
| cbc9ec4634 | |||
| 094e084316 | |||
| 52085cf521 | |||
| a4e9b70b27 | |||
| 2cfd947429 | |||
| 24ed20305f | |||
| 12607cca44 | |||
| 00ee79d33c | |||
| d7d0cb3a06 | |||
| ec8ce1f2f6 | |||
| f7f78da1a1 | |||
| 49b0772076 | |||
| 9b8a6c36db | |||
| f44e06c57e | |||
| b46b904504 | |||
| 004b42436e | |||
| 5e5db62d2a | |||
| fd39b0008e | |||
| af5d5206f8 | |||
| de0e014e50 | |||
| 6bf38d369b | |||
| dbb98ba4fe | |||
| 87c9bc769e | |||
| f014d7ffcd | |||
| 07306ff209 | |||
| ab0b92ad5c | |||
| df96d14643 | |||
| 61ec7509ad | |||
| 0bcedadc5c | |||
| c1c5aa9239 | |||
| ab7c8aa250 | |||
| 0fb9878cff | |||
| acd0092f26 | |||
| 71a4923c56 | |||
| dea59c5285 | |||
| f1d0ab20cb | |||
| fb9dc3fbc5 | |||
| 49acd71ff9 | |||
| 026fdc0d33 | |||
| 08a4c1b732 | |||
| 3eaa8b6efd | |||
| 6c4e157b5c | |||
| b7847ebaad | |||
| 1fe4e19239 | |||
| 4b4f5eba8c | |||
| 8c9d40eefa | |||
| 925d2172ff | |||
| 6b0fb0385d | |||
| 2e9a7265e9 | |||
| d1835c5c06 | |||
| 7babad8262 | |||
| 9282b4231e | |||
| e4201aebff | |||
| 1fb42bffdc | |||
| 9d7d0a0ec9 | |||
| c5730230f6 | |||
| 19867cbcd9 | |||
| 131bd63a5b | |||
| 562529a26b | |||
| 6eae7226be | |||
| 0091a4da92 | |||
| 95ac89c5ec | |||
| 11084f6689 | |||
| 1b5fa5129c | |||
| 00a27fc4a8 | |||
| e1b1dfafac | |||
| 4ffdba8e41 | |||
| 092ae26257 | |||
| 11c3eeb9b8 | |||
| ae656932ba | |||
| 7d4e8ba27c | |||
| fd3d45a21a | |||
| c8042c6a75 | |||
| 421fb76320 | |||
| 6f336970db | |||
| 985285963c | |||
| 43223ad1cc | |||
| f6c8172676 | |||
| 05466f96c0 | |||
| 08b1df79f2 | |||
| 3ec53548a9 | |||
| 93108817de | |||
| 4b19b7199b | |||
| e8d1be6aa6 | |||
| bde3cdd09f | |||
| d05f9aa267 | |||
| d253a88daa | |||
| 6db5a93598 | |||
| d4a4c6878d | |||
| 550e45a5c0 | |||
| 266fcbf58b | |||
| f162de89be | |||
| 2efb9fb2a1 | |||
| 1156373f0c | |||
| 5f9bf37617 | |||
| c0fdd102e3 | |||
| 9f3ee8d963 | |||
| 802865c1a6 | |||
| 02835579c1 | |||
| 81b3be894e | |||
| 3875181d89 | |||
| 0669c64da1 | |||
| 931e9d13f7 | |||
| cd0598c5d1 | |||
| bc4980c6b8 | |||
| f20945be9f | |||
| 099bbb1607 | |||
| ebcfa4f9dc | |||
| f121505c50 | |||
| 793830ab21 | |||
| 735c1ce688 | |||
| 837d4ef7ee | |||
| adfa88aef8 | |||
| fbd0101b45 | |||
| f1cfeb0acc | |||
| 45b8563db1 | |||
| ec3c89ac1b | |||
| b422a565d5 | |||
| eb3212ee2d | |||
| e209251d0b | |||
| e73fa2d7f9 | |||
| 8c0ed525b3 | |||
| 31e66c7213 | |||
| b3e76f5580 | |||
| f217a819fe | |||
| 6a784a14fa | |||
| d809fc021f | |||
| 582df6e3a8 | |||
| d5a4f96482 | |||
| d2fc9cf558 | |||
| b530ac5a5c | |||
| 2659336ee9 | |||
| c32a09bfc1 | |||
| f7ff415374 | |||
| 9cac674d7b | |||
| 2cf7da9c91 | |||
| 0d1d3a1ed5 | |||
| 7e2ebe8013 | |||
| 0606a1278c | |||
| 7c9f24f695 | |||
| 227fb4fa94 | |||
| 77ef154941 | |||
| a00aa3ee02 | |||
| 7b6b5efd8d | |||
| db62e05275 | |||
| 3db66eb55b | |||
| 4709177bc3 | |||
| e25a890946 | |||
| bc4a181182 | |||
| ce7af09f25 | |||
| e3da438225 | |||
| edbb7ccabe | |||
| f4360b34d9 | |||
| 0ed0cf4aa2 | |||
| 61fc2198c9 | |||
| f45f5a899b | |||
| 37f6dce344 | |||
| 10624787fe | |||
| d7a697d68d | |||
| 97954f59c3 | |||
| 3b23e9f420 | |||
| 94712ecbfc | |||
| a2c2b69026 | |||
| 6abc6f9d1a | |||
| db0fbfaaf4 | |||
| a59210c559 | |||
| add11b0037 | |||
| c26034a89c | |||
| 9287464eb8 | |||
| 1c103b2db7 | |||
| ed6a5fc7f1 | |||
| 768efc8cf7 | |||
| 39a9bc9022 | |||
| f90b7b3c9b | |||
| 802a225c3e | |||
| 74370db5de | |||
| db788c3c30 | |||
| eae619e7e8 | |||
| 0f3380e91e | |||
| 7c80486d88 | |||
| 1f65198803 | |||
| 0f18b7bd80 | |||
| b89f4a3b6b | |||
| 658d2d7af4 | |||
| f09c404f65 | |||
| fbe8eca263 | |||
| 923e8b070d | |||
| e6e53e566f | |||
| f144f1d71c | |||
| 3694e1a6e4 | |||
| d45b6dc8dc | |||
| 5194133a78 | |||
| 6e5d634af6 | |||
| 32d5ff3b47 | |||
| 5545554d7e | |||
| 6a0faa958e | |||
| 15c0478710 | |||
| bab9804022 | |||
| c93c417dba | |||
| 2fe91de000 | |||
| 412e3a078c | |||
| b6dda55a82 | |||
| b8bda93e8d | |||
| ac485f9c87 | |||
| 4b33627d19 | |||
| af28624e2d | |||
| 3457aedf5d | |||
| 5acac4ba5b | |||
| 0b151f9054 | |||
| cafc45dbba | |||
| e3cbcab6b2 | |||
| ec7e9a546c | |||
| 7a2aab3204 | |||
| 5dcdd2c36f | |||
| fb0a630bfd | |||
| 47cd0deb03 | |||
| 378941cd5c | |||
| 1da28024cc | |||
| 480e6a62b6 | |||
| 807456de86 | |||
| c8ef42ab21 | |||
| 60d147c71d | |||
| cd36150ddc | |||
| 198a544dbb | |||
| 40616776eb | |||
| 20c9cf8ab3 | |||
| 17558f86aa | |||
| 7408dbb435 | |||
| 9c1fc59af9 | |||
| 31c785b5da | |||
| 5b93135ebb | |||
| 5f4cf0c66e | |||
| ddbf2b0339 | |||
| e62d481405 | |||
| 6c07ac8595 | |||
| 7d74feec0c | |||
| 6b2a4bcb27 | |||
| 6da9ece0d8 | |||
| dc4438dcf9 | |||
| e250e2a130 | |||
| dad51fdf80 | |||
| d60b0301c5 | |||
| db3e09650a | |||
| bcb9911cd0 | |||
| 67e3035c38 | |||
| 94dd256889 | |||
| cc383c84cb | |||
| b20b614f5e | |||
| 31984e104e | |||
| 94f74a194d | |||
| eaa58e6804 | |||
| 351c87ab96 | |||
| 13e2765c3f | |||
| c049840baa | |||
| e1ab74e5a3 | |||
| 02d858f27f | |||
| 1d8e9f352d | |||
| 7b59e3d0ec | |||
| 213da61389 | |||
| 9de55eb186 | |||
| e3bec5ff45 | |||
| 1bfc4735d3 | |||
| 49b742b47b | |||
| 7f79e50f72 | |||
| 39b9d06d21 | |||
| 047881934a | |||
| 689808eca7 | |||
| 70571a3b49 | |||
| 06138ebaff | |||
| 81a3b10a06 | |||
| 8ba88f4626 | |||
| 9d4b66851a | |||
| 3ba0da19f8 | |||
| 6826f854ae | |||
| afa524091c | |||
| f14c7f2b00 |
@@ -1,5 +1,554 @@
|
||||
# Change Log
|
||||
|
||||
# 2602.1 UI Preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Improved Automatic Matching for Surface devices
|
||||
|
||||
To keep inline with HP, Dell, and Lenovo, added support for Surface devices to leverage the SystemSKU values from WMI when doing automatic driver matching during deployment. Check https://github.com/rbalsleyMSFT/FFU/pull/394 for more information. Long story short, there's a new `SurfaceDriverIndex.json` file that is created when getting the models which gathers the WMI information per model as well as the download links for each model. This info is used to generate the DriverMapping.json file for Surface to allow for better matching.
|
||||
|
||||
There'll be deeper documentation on the new [docs site](https://rbalsleymsft.github.io/FFU/)
|
||||
|
||||
### Improved driver injection error handling when deploying drivers via USB
|
||||
|
||||
When drivers failed to be added from the USB drive during deployment, ApplyFFU.ps1 would fail with an error message and the deployment wouldn't complete. ApplyFFU.ps1 will now continue on failure and log the error and capture the setupapi.offline.log to the USB drive for troubleshooting if needed.
|
||||
|
||||
### Fixed an issue with Windows image index for non-English media
|
||||
|
||||
In some cases non-English media would cause the end-user to have to select which Windows SKU to select due to parsing the image name output and assuming the output was in English. BuildFFUVM.ps1 will now parse the edition metadata for each index. This should improve the experience for those that are creating FFUs from non-English media.
|
||||
|
||||
### Run builds in separate pwsh process instead of background jobs
|
||||
|
||||
In https://github.com/rbalsleyMSFT/FFU/pull/393, by changing the deprecated Get-WmiObject calls to Get-CimInstance, this actually broke console output. Still don't fully understand why GWMI was allowing background jobs to output console output to the calling pwsh Window but get-ciminstance wouldn't (WinRM, PowerShell Remoting, etc), but this required changing to running the build in a separate pwsh process. Between this and https://github.com/rbalsleyMSFT/FFU/pull/393, this should fix those that might build their FFUs on Servers and still expect to see console output.
|
||||
|
||||
### Fixed an issue with USB drive selection for same-model USB drives
|
||||
|
||||
When using the UI and selecting specific USB drives to create, the UI would allow you to select multiple of the same name, but would only create one of the drives. You should now be able to multi-select multiple USB drives with the same name and they should build as expected.
|
||||
|
||||
### Created new docs site
|
||||
|
||||
[FFU Builder docs](https://rbalsleymsft.github.io/FFU/) are now available! I'm still working on adding more documentation, but the layout of the site, the prereqs, quick start, and UI overview are done. I still have some stuff to migrate from the old docx file and some deep dive stuff to write up (Drivers, Apps, FAQs, Troubleshooting, etc). It should work well on both mobile and desktop. It also has built-in search capabilities to make it easy to find what you're interested in.
|
||||
|
||||
## New Contributors
|
||||
|
||||
* @JGehl99 made their first contribution in https://github.com/rbalsleyMSFT/FFU/pull/393
|
||||
|
||||
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2601.1Preview...v2602.1Preview
|
||||
|
||||
# 2601.1 UI Preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Improved WinPE driver copy reliability and logging
|
||||
|
||||
Fixed a bug where some drivers weren't being copied into WinPE media when using **Use Drivers Folder as PE Drivers Source** option (`UseDriversAsPEDrivers` parameter)
|
||||
|
||||
### Improved driver injection for long driver folder paths
|
||||
|
||||
In some cases some drivers weren't being copied to the FFU or WinPE deployment media due to long paths. This required some significant refactoring. [See this post](https://github.com/rbalsleyMSFT/FFU/discussions/375) for more details on the changes that were made and the reasoning behind them.
|
||||
|
||||
### Fixed an issue with WingetWin32Apps.Json file corruption during parallel app updates
|
||||
|
||||
A code refactor that was done to consolidate some of the winget application download work that both the UI and BuildFFUVM.ps1 script caused an issue where parallel writes to the WingetWin32Apps.json file was causing the file to corrupt, resulting in apps not installing as expected.
|
||||
|
||||
### Winget App installs now follow Applist.json order
|
||||
|
||||
Winget application installs were installing in an indeterministic way when the WingetWin32Apps.json file was created. The order will now follow the order listed in the AppList.json file.
|
||||
|
||||
### Support added for Winget Win32 app dependency handling
|
||||
|
||||
Some apps (Camtasia) require dependency apps to be installed first. Winget will download said dependency apps. Dependent applications will now install before the calling application. There is also deduplication support added in the event multiple applications have the same dependencies.
|
||||
|
||||
**Full Changelog**: [https://github.com/rbalsleyMSFT/FFU/compare/v2512.1Preview...v2601.1Preview](https://github.com/rbalsleyMSFT/FFU/compare/v2512.1Preview...v2601.1Preview)
|
||||
|
||||
# 2512.1 UI Preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Refactored Cleanup logic into a shared module
|
||||
|
||||
Consolidates duplicated cleanup code by moving logic into a shared function, eliminating redundant implementations across multiple locations.
|
||||
|
||||
Removes standalone cleanup functions (Remove-FFU, Remove-Apps, Remove-Updates) and replaces scattered cleanup calls with a single invocation of Invoke-FFUPostBuildCleanup.
|
||||
|
||||
### Add 30 second delay to allow for Windows Security Platform to install
|
||||
|
||||
There was an issue where the Windows Security Platform would attempt to install in the VM during the build via `Update-Defender.ps1` however the install didn't always happen and on deployment of the FFU, Windows Update would show that the Windows Security Platform needed an update. I suspect this is related to the AppxSVC not being ready during Audit Mode. Adding a 30 second delay appears to work more reliably.
|
||||
|
||||
### Windows and .NET CU's now persist across builds
|
||||
|
||||
Content in the FFUDevelopment\KB folder was always deleted once it was used. Since the Windows CU is so large now, it doesn't make sense to delete it if a user wants it again and may not be using cached VHDX files.
|
||||
|
||||
Deletion of the KB folder is now correctly handled via the **Remove Downloaded Update Files** option on the Build tab.
|
||||
|
||||
### Skip CU downloads if the Windows ESD version is current or newer
|
||||
|
||||
Now that the Windows ESD media is kept up to date, there rarely will be a need to download the latest CU. There will always be a slight gap when the latest CU comes out and the updated media is available, but that's generally just a few days to a week.
|
||||
|
||||
The script will now do some parsing of the windows version of the ESD file and the latest CU and if the ESD is newer, the CU will not be downloaded.
|
||||
|
||||
### Fixes an issue with WingetWin32Apps.json file not being created if applications were pre-downloaded via the UI
|
||||
|
||||
Fixed a bug due to some code consolidation that broke scenarios where applications that were downloaded via the UI, but were not installing in the VM.
|
||||
|
||||
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2511.1preview...v2511.2
|
||||
|
||||
# 2511.1 UI Preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Major changes to drivers
|
||||
|
||||
A few weeks ago I wrote a [lengthy post](https://github.com/rbalsleyMSFT/FFU/discussions/350) asking for some help testing some changes that were added.
|
||||
|
||||
The summary of that post is that there have been significant changes for both Dell and HP driver downloads to leverage the SystemID for each model. This increases the total number of driver models that are exposed in the UI. This also requires the `DriverMapping.json` to be modified to require the SystemID and query the SystemID from WMI when doing automatic matching.
|
||||
|
||||
#### Driver folder structure changes on the USB drive - breaking change
|
||||
|
||||
Driver folder structure on the USB drive has also changed. The new structure is `Drivers\Make\Model` (e.g. `D:\Drivers\Lenovo\Lenovo 300w`). This structure is consistent with how the UI and `BuildFFUVM.ps1` script download and store drivers and automatically copy them. So if you've been following that, then no changes are required.
|
||||
|
||||
Please read [the post](https://github.com/rbalsleyMSFT/FFU/discussions/350) for more details on these changes to drivers.
|
||||
|
||||
### Windows 11 25H2 is now the default option for MCT/ESD downloads
|
||||
|
||||
For MCT/ESD downloads: Adds dynamic products.cab download functionality for Windows 11 using Windows Update service API instead of static MCT links. This is due to a change in how the MCT pulls the products.cab file. In other words, the Windows 11 25H2 ESD media is now updated each month (usually shortly after patch Tuesday)
|
||||
|
||||
### Added 8 new hardware manufactures for automatic driver matching during deployment
|
||||
|
||||
Extends hardware detection and driver mapping capabilities to support Panasonic, Viglen, AZW, Fujitsu, Getac, ByteSpeed, and Intel devices when applying the FFU to a device. This does not mean FFU Builder supports downloading drivers from these manufacturers. You'll still need to download the drivers for them manually. You can now create your own `DriverMapping.json` file to include these manufacturers.
|
||||
|
||||
Thanks to @arwidmark and the [Modern Driver Management](https://msendpointmgr.com/modern-driver-management/) team for the WMI queries.
|
||||
|
||||
### Fixed an issue with long paths when applying drivers from USB
|
||||
|
||||
Implemented SUBST drive mappings to shorten driver file paths within WinPE as some paths were causing dism to error when servicing drivers. You should see a Z:\ drive when applying drivers from the USB drive.
|
||||
|
||||
### Added an option to skip driver selection when multiple driver models are detected during deployment
|
||||
|
||||
Allows users to bypass driver installation by entering 0 at the selection prompt, providing flexibility for deployments that don't require driver updates.
|
||||
|
||||
### Add HTTP fallback for BITS transfer network authentication errors
|
||||
|
||||
Fixes an issue with standard users elevating PowerShell as Admin and getting BITS errors when trying to download content.
|
||||
|
||||
### Add -BitsPriority script parameter
|
||||
|
||||
Introduces a new parameter `-BitsPriority` with options `(Foreground, High, Normal, Low)` to control BITS download priority across the build system and UI, allowing users to optimize transfer speeds when needed.
|
||||
|
||||
The feature adds a priority selector to the UI with four options (Foreground, High, Normal, Low) and propagates the selection through the build script and common modules. Priority can be set via UI or command-line parameter with Normal as the default.
|
||||
|
||||
### BYO Apps: Add MSI path quoting to handle spaces in msiexec arguments
|
||||
|
||||
When specifying Build Your Own Apps msiexec arguments, if there were spaces in the argument list that weren't quoted properly, you'd get an error. This should now automatically add missing spaces in case you forget to add them or there are spaces in your application name.
|
||||
|
||||
### Misc Fixes
|
||||
|
||||
* Fixed some reliability issues when trying to download Lenovo drivers
|
||||
* Fixed an issue with PPKG files with spaces
|
||||
* Replaced SerialNumber with UniqueID for USB drive identification when building USB drives. USB drive manufacturers may use the same serial number for different drives, potentially causing data loss if the wrong drive is chosen.
|
||||
* `-Threads` parameter has been added to `BuildFFUVM.ps1` which defaults to 5, matching the UI behavior. This value can be 1-64.
|
||||
* ESD media downloads now use BITS by default
|
||||
* Fixed an issue with multi-disk devices. Prior, if multiple disks were detected, ApplyFFU.ps1 would fail. Now a menu pops up asking the end user to select the disk they want to deploy the FFU to
|
||||
|
||||
## New Contributors
|
||||
|
||||
* @arwidmark made their first contribution in https://github.com/rbalsleyMSFT/FFU/pull/325
|
||||
|
||||
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2509.1preview...v2511.1preview
|
||||
|
||||
# 2509.1 UI Preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
### [Refactor: Enhance artifact cleanup for disabled features](https://github.com/rbalsleyMSFT/FFU/commit/1ab4093d54b7d9bda9f47d7819694e66ae8de357)
|
||||
|
||||
Renames `Remove-DisabledUpdates` to `Remove-DisabledArtifacts` to better reflect its expanded scope.
|
||||
|
||||
This function now also removes Office installation scripts and downloaded content if the Office installation is disabled via the `$InstallOffice` flag.
|
||||
|
||||
The function call is moved to run before app installations to ensure artifacts are removed prior to the installation phase.
|
||||
|
||||
### [Removes the VM workaround for MCT ESD builds](https://github.com/rbalsleyMSFT/FFU/commit/dc5877f398316969299ee03800f3d07c7d98a9ab)
|
||||
|
||||
Comments out the logic that forces app installation when building from a downloaded ESD file. This workaround was implemented to prevent an OOBE reboot loop but is no longer required. This should speed up scenarios where you want to download the ESD media, install the latest CU and .NET CU, and capture the FFU.
|
||||
|
||||
### [Update default disk size to 50GB in FFU scripts and UI](https://github.com/rbalsleyMSFT/FFU/commit/372360d7392ad945be0db889a68e1fff0ed3b5d6)
|
||||
|
||||
Changed the default disk size parameter from 30GB to 50GB in BuildFFUVM.ps1 and FFUUI.Core.psm1 to accommodate larger virtual machines.
|
||||
Updated tooltip and default value in the UI XAML file to reflect the new disk size.
|
||||
|
||||
### [Adds auto-loading of previous configuration on startup](https://github.com/rbalsleyMSFT/FFU/commit/3ef26f2918977906ebe14e328f015ce4f1941dc3)
|
||||
|
||||
Implements a new feature to automatically load the previously saved environment when the UI is launched.
|
||||
|
||||
This improves user experience by restoring the last saved configuration, including selected applications and drivers, eliminating the need to manually reload them on each run.
|
||||
|
||||
The process loads the main `FFUConfig.json` and then proceeds to load associated Winget, BYO App, and Driver lists if they are defined. UI elements and checkboxes are updated accordingly to reflect the loaded state.
|
||||
|
||||
### [Improves UI state after environment autoload](https://github.com/rbalsleyMSFT/FFU/commit/bdf1b63833c83171aed63e8fc16702078ccd577b)
|
||||
|
||||
Updates the visibility of UI panels for Winget and drivers when a previous environment is automatically loaded.
|
||||
|
||||
This ensures that if Winget apps or driver models are present, their corresponding UI sections are made visible. Additionally, it updates the "select all" checkbox state for Winget results and attempts to pre-select the hardware make for loaded drivers.
|
||||
|
||||
### [Add restore defaults and centralize cleanup logic](https://github.com/rbalsleyMSFT/FFU/commit/f3316a017b73bf12cf1a66e3d03a63e29c437cb1)
|
||||
|
||||
Introduces a "Restore Defaults" feature in the UI to reset the environment. This action removes generated configuration files, ISOs, downloaded apps, updates, drivers, and FFUs.
|
||||
|
||||
The post-build cleanup logic is refactored from the main build script into a new common function. This new function is used by both the standard build process and the new restore defaults feature, promoting code reuse and simplifying maintenance.
|
||||
|
||||
### [Add option to dynamically build PE drivers](https://github.com/rbalsleyMSFT/FFU/commit/e2ccd11f07217b389f1622a69794224412e046e1)
|
||||
|
||||
Thanks to @JonasKloseBW for the original code for this in https://github.com/rbalsleyMSFT/FFU/pull/115
|
||||
|
||||
Introduces a new parameter, `UseDriversAsPEDrivers`, that allows WinPE drivers to be sourced directly from the main driver repository.
|
||||
|
||||
When enabled, the script scans all available drivers, parses their INF files, and copies only the essential driver types (e.g., storage, mouse, keyboard, touchpad, system devices) needed for WinPE. This eliminates the need to maintain a separate, manually curated `PEDrivers` folder.
|
||||
|
||||
The UI is updated with a new checkbox that becomes visible when "Copy PE Drivers" is selected, making this a sub-option. Parameter validation is also adjusted to support this new workflow.
|
||||
|
||||
### [Improve model name normalization for driver mapping](https://github.com/rbalsleyMSFT/FFU/commit/50713188bffcb64f1b0c1f9eb89e02a300e3de98)
|
||||
|
||||
Enhances the model name normalization function to better handle variations in hardware model strings. This change introduces specific rules to canonicalize "All-in-One" and screen size variants (e.g., "-in" or "inch") for more reliable matching against driver mapping rules.
|
||||
|
||||
Additionally, optimizes performance by normalizing the system model once before the comparison loop. Logging is also added to show the original and normalized model strings for easier debugging.
|
||||
|
||||
### [Defer cleanup of compressed driver source folders](https://github.com/rbalsleyMSFT/FFU/commit/c30ed923b68b933f719b9a2941043b813bf4fd3f)
|
||||
|
||||
Implements a deferred cleanup mechanism for driver source folders when they are compressed to a WIM and also used for WinPE.
|
||||
|
||||
When drivers are compressed, the original source folders are now preserved if they are also needed for WinPE driver injection. A marker file is created in these preserved folders.
|
||||
|
||||
A new cleanup step is added after the WinPE media creation to remove these preserved folders, ensuring they are available when needed but not left behind permanently.
|
||||
|
||||
### [Refactor config loading and improve error handling](https://github.com/rbalsleyMSFT/FFU/commit/8d7e4d106620761d0ae1a5133f6d6ba301131471)
|
||||
|
||||
Extracts the logic for importing supplemental assets (Winget, BYO, Drivers) into a new reusable function. This function is now called by both the manual and automatic configuration loaders, reducing code duplication.
|
||||
|
||||
Enhances the manual configuration loading process with more robust error handling. It now provides specific user-facing error messages for file read failures, empty files, and invalid JSON, improving the user experience when loading a malformed configuration.
|
||||
|
||||
When loading a configuration, if optional supplemental files like AppList.json are referenced but not found, an informational message is now displayed to the user instead of failing silently.
|
||||
|
||||
### [Add robust sanitization for names used in paths](https://github.com/rbalsleyMSFT/FFU/commit/cb14e84a26acaf5863aa3bb094dbf18424798875)
|
||||
|
||||
Introduces a new common function, `ConvertTo-SafeName`, to sanitize strings by removing characters that are invalid in Windows file paths.
|
||||
|
||||
This function is now used consistently when creating directory and file names for drivers (Dell, HP, Lenovo, Microsoft) and applications to prevent path-related errors. It replaces several ad-hoc sanitization methods with a single, more robust implementation.
|
||||
|
||||
### [Includes exit code fields when using Copy Apps button](https://github.com/rbalsleyMSFT/FFU/commit/f37647599a318da29b62154bebff8c8a857d3002)
|
||||
|
||||
Adds persistence of AdditionalExitCodes and IgnoreNonZeroExitCodes when exporting the UI list to prevent losing custom exit handling settings and maintain parity with the primary save routine.
|
||||
|
||||
### [Sanitizes app names for storage and paths](https://github.com/rbalsleyMSFT/FFU/commit/d1ca1231045e38316733495e1fdb8590a225be67)
|
||||
|
||||
Applies name sanitization when persisting the app list and when building/checking Win32 and Store download directories.
|
||||
Prevents invalid characters in folder names, aligns persisted names with on-disk structure, and improves detection of existing content to avoid redundant downloads and errors.
|
||||
|
||||
### [Adds exit-code overrides and UI for winget apps](https://github.com/rbalsleyMSFT/FFU/commit/d9c0c9c68ee1769230c9789b5c7cb84bcff4d642)
|
||||
|
||||
Adds per-app control for additional accepted exit codes and ignoring non‑zero exit codes to improve handling of installers with nonstandard returns.
|
||||
|
||||
Exposes editable fields in the app list UI, persists them across search defaults, import/export, and pre-download save, and applies overrides during app resolution to honor configured behavior.
|
||||
|
||||
### [Adds UI/CLI to copy additional FFUs to USB build](https://github.com/rbalsleyMSFT/FFU/commit/15a5b16b39887b71ae545c638d57183c97bdf629)
|
||||
|
||||
- Enables selecting multiple existing FFU images to include on the deployment USB for easier distribution and testing.
|
||||
- Adds a UI option with selectable, sortable list from the capture folder, refresh support, and persisted selections.
|
||||
- Validates that selections exist when the option is enabled to prevent empty runs.
|
||||
- Supports unattended/CLI flows by prompting early or accepting a preselected list for USB creation; deduplicates and logs chosen files.
|
||||
- Always includes the just-built (or latest available) FFU as a base.
|
||||
- Improves no-FFU handling and streamlines multi-FFU selection workflow.
|
||||
|
||||
### [Standardizes JSON output: depth, UTF-8, key order](https://github.com/rbalsleyMSFT/FFU/commit/6562d16ce500197b428b51915332c6649df302df)
|
||||
|
||||
- Sorts top-level config keys before serialization for deterministic files and cleaner diffs.
|
||||
- Increases JSON depth to 10 to retain nested settings.
|
||||
- Writes JSON as UTF-8 via Set-Content for consistent encoding.
|
||||
- Applies across config export and UI save flows.
|
||||
|
||||
### [Adds Windows 11 25H2 mapping](https://github.com/rbalsleyMSFT/FFU/commit/eaa3e1e6af5c25e0f8b185f8107e017782b0f00f)
|
||||
|
||||
Extends supported Windows 11 releases to include 25H2. Default is still 24H2.
|
||||
|
||||
* Update USBImagingToolCreator.ps1 by @jrollmann in https://github.com/rbalsleyMSFT/FFU/pull/262
|
||||
|
||||
## New Contributors
|
||||
|
||||
* @jrollmann made their first contribution in https://github.com/rbalsleyMSFT/FFU/pull/262
|
||||
|
||||
# 2507.1 UI Preview
|
||||
|
||||
Waaay too many to list. Just watch the Youtube video in the Readme :)
|
||||
|
||||
# 2505.1
|
||||
|
||||
Highly recommended that you upgrade to this release. Fixes the issue with the May 2025 cumulative update and some SKU naming issues for SKUs other than Pro.
|
||||
|
||||
# Support for Windows LTSB/LTSC
|
||||
|
||||
Thanks to @zehadialam for the code to allow support for LTSB and LTSC. This has been a requested feature from a number of customers and some might be opting for LTSC when Windows 10 support ends in October. We support LTSB 2016, LTSC 2019, 2021, 2024 including the N and IoT variants. Extensive testing has gone into validating CU and .net support. File an issue if you see any weird behavior.
|
||||
|
||||
# Support for automating computer naming via CSV
|
||||
|
||||
Thanks to @JonasKloseBW for PR #150
|
||||
|
||||
- Allows setting the computer name with a predefined list (SerialComputerNames.csv) of serial numbers and matching computer names
|
||||
- Defaults to FFU-{Random} if no matching serial number is found in list so FFU deployment can continue without user input
|
||||
|
||||
# Fixes
|
||||
|
||||
- Thanks to @JonasKloseBW for PR #129 for adding the -AppListPath parameter
|
||||
- Fixed an issue where if AppsScriptVariables was configured in a config file, the hashtable wasn't being created by the script when setting the variable.
|
||||
- Fixed a crash where shortening the Windows SKU was creating duplicate shortened names for certain SKUs (EDU mainly, but others too)
|
||||
- Fix an issue with checkpoint CUs and May 2025-05B CU. Should future proof new checkpoint CUs in the future.
|
||||
|
||||
# Additional Fixes
|
||||
|
||||
BuildFFUVM.ps1
|
||||
|
||||
- Added parameter definitions that were missing:
|
||||
- AppListPath - Path to a JSON file containing a list of applications to install using WinGet. Default is $FFUDevelopmentPath\Apps\AppList.json.
|
||||
- PEDriversFolder - Path to the folder containing drivers to be injected into the WinPE deployment media. Default is $FFUDevelopmentPath\PEDrivers.
|
||||
- Added two new parameters:
|
||||
- UpdateLatestMicrocode - This is used for Windows 10/Server. 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.
|
||||
- UpdateADK - Added for airgapped scenarios where you've manually updated the ADK and don't need it to continually check. 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.
|
||||
- Reorganized the WindowsSKU validateset to make it easier to read and added in 2016 LTSB releases
|
||||
- Changed version to 2505.1
|
||||
- Reorganized the releasetoMapping SKUs to make it easier to read
|
||||
- Omitted Defender/Edge from reporting KB ID since neither includes it
|
||||
- Updated Save-KB with some enhancements from the UI branch which will handle KBs that don't have an architecture defined in their file name that will leverage a new function Get-PEArchitecture that can interrogate the file name and determine the correct architecture
|
||||
- Updated Get-ShortenedWindowsSKU with LTSB/LTSC SKUs
|
||||
- Updated New-FFUFileName to use $winverinfo.Name for $WindowsRelease for client OSes to which will set $WindowsRelease to using Win10 or Win11. This fixes a bug where you might see 10 or 11 instead of Win10 or Win11 for FFU builds that use only the VHDX (e.g. `-InstallApps $false`. This keeps the naming consistent with FFUs built via VM.
|
||||
- Updated Get-WindowsVersionInfo to fix an issue with naming LTSC 2019
|
||||
- Added Get-PEArchitecture function
|
||||
- Commented out the Windows Security Platform Update code since the URL is dead for the content. This is fixed in the UI branch and will be reintroduced in Dev and Main at a later date when the UI work is complete.
|
||||
- Created a new variable `$isLTSC`
|
||||
- Modified and reorganized the search strings for the various .net framework components. LTSC introduced some complexity with handling the various .net releases.
|
||||
- VHDXCaching will now recurse the KBPath folder when finding downloaded KBs to include in its config file
|
||||
|
||||
Sample_default.json
|
||||
|
||||
- Added new/missing parameters
|
||||
- ApplistPath
|
||||
- UpdateADK
|
||||
- UpdateLatestMicrocode
|
||||
|
||||
CaptureFFU.ps1
|
||||
|
||||
- `$WindowsVersion` 2016 and 2019 for LTSC releases
|
||||
- Changed some SKU spacing to make things more consistent and included Enterprise N LTSC
|
||||
|
||||
ApplyFFU.ps1
|
||||
|
||||
- Updated version to 2505.1
|
||||
|
||||
# 2412.1
|
||||
|
||||
This is a major release with a number of quality-of-life improvements that will reduce the time it takes to create FFUs. I highly recommend you update to this release.
|
||||
|
||||
## Windows Server Support
|
||||
|
||||
Thanks to [JonasKloseBW](https://github.com/JonasKloseBW) we have added support for Windows Server! This includes support for Windows Server 2016 through 2025 and supports both core and desktop experience. It will require you to provide your own Server ISO
|
||||
using the `-ISOPath` parameter since we can't automatically download it like we can with client. You also will want to set the `-WindowsSKU` parameter to either `'Standard', 'Datacenter', 'Standard (Desktop Experience)', or 'Datacenter (Desktop Experience)'` depending on your needs.
|
||||
|
||||
Cumulative Updates for Windows and .NET should work as expected. Defender updates should work too. If you notice anything that doesn't work, open an issue.
|
||||
|
||||
## VHDX Caching Support
|
||||
|
||||
Thanks again to Jonas for adding VHDX caching support #89. For those of you that might be making many FFUs for different configurations, instead of building the VHDX every time, you can cache the VHDX and re-use it for your next build. In testing, this seems to save about 10 minutes, depending on how you're installing Windows (via MCT download, or your own ISO and how old your media is).
|
||||
|
||||
The way this works is a VHDXCache folder is created in the FFUDevelopment folder. If `-AllowVHDXCaching $true`, we store the VHDX file and a config file that keeps track of the following info
|
||||
|
||||
```
|
||||
{
|
||||
"VhdxFileName": "_FFU-808829869.vhdx",
|
||||
"LogicalSectorSizeBytes": 512,
|
||||
"WindowsSKU": "Pro",
|
||||
"WindowsRelease": "11",
|
||||
"WindowsVersion": "24H2",
|
||||
"OptionalFeatures": "",
|
||||
"IncludedUpdates": [
|
||||
{
|
||||
"Name": "windows11.0-kb5043080-x64_953449672073f8fb99badb4cc6d5d7849b9c83e8.msu"
|
||||
},
|
||||
{
|
||||
"Name": "windows11.0-kb5045934-x64-ndp481_fa9c3adfb0532eb8f4e521f4fb92a179380184c5.msu"
|
||||
},
|
||||
{
|
||||
"Name": "windows11.0-kb5048667-x64_d4ad0ca69de9a02bc356757581e0e0d6960c9f93.msu"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The VHDX files are cached before boot, so they've never been sysprepped. On subsequent runs, if `-AllowVHDXCaching $true` is set, we search the VHDXCache folder, loop through any config files, and look to see if we find one that matches the build information you've passed to the script. If a match is found, robocopy copies in the VHDX and uses the cached VHDX to build the FFU VM.
|
||||
|
||||
## Configuration File Support
|
||||
|
||||
A configuration file can now be used to configure the parameters in lieu of, or in conjunction with, parameters specified on the command line. Configuration files are especially helpful for those making FFUs for different models, Windows releases, application sets, and more.
|
||||
|
||||
To use, run:
|
||||
`.\BuildFFUVM.ps1 -ConfigFile 'C:\FFUDevelopment\config\Sample_default.json' -verbose`
|
||||
|
||||
### Creating your own Configuration Json file
|
||||
|
||||
If you have a command line that you’ve been using for awhile and would like to convert it to a json file automatically, run your command line like normal, adding
|
||||
`-exportConfigFile 'C:\FFUDevelopment\config\YourConfigFile.json'`
|
||||
to the end of the command. Doing this will generate a well-formatted json file with your configuration settings.
|
||||
|
||||
You can also temporarily overwrite parameters while using a config file. Using the following sample command:
|
||||
|
||||
`.\BuildFFUVM.ps1 -ConfigFile 'C:\FFUDevelopment\config\Sample_default.json' -verbose`
|
||||
|
||||
If you’d like to not include Office (the Sample_default.json file installs Office), you’d add `-InstallOffice $False` to the command line
|
||||
|
||||
`.\BuildFFUVM.ps1 -ConfigFile 'C:\FFUDevelopment\config\Sample_default.json' -verbose -InstallOffice $False`
|
||||
|
||||
Doing this will temporarily overwrite whatever is in the json for the `InstallOffice` parameter. It will not modify the json file. If you would like to change the json file, you can add `-exportConfigFile 'C:\FFUDevelopment\config\Sample_default.json'` and that will overwrite the json file with the new parameter.
|
||||
|
||||
`.\BuildFFUVM.ps1 -ConfigFile 'C:\FFUDevelopment\config\Sample_default.json' -verbose -InstallOffice $False -exportConfigFile 'C:\FFUDevelopment\config\Sample_default.json'`
|
||||
|
||||
## Custom FFU Naming Support
|
||||
|
||||
Thanks to Jonas, we now have custom FFU naming support. A new parameter -CustomFFUNameTemplate has been added.
|
||||
|
||||
This parameter sets a custom FFU output name with placeholders. Allowed placeholders are:
|
||||
|
||||
`{WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}`
|
||||
|
||||
And below is a description of what to expect when you use each placeholder.
|
||||
|
||||
```
|
||||
{WindowsRelease} = 10, 11, 2016, 2019, 2022, 2025
|
||||
{WindowsVersion} = 1607, 1809, 21h2, 22h2, 23h2, 24h2, etc
|
||||
{SKU} = 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, Standard, Standard (Desktop Experience), Datacenter, Datacenter (Desktop Experience)
|
||||
{BuildDate} = e.g. Dec2024
|
||||
{yyyy} = e.g. 2024
|
||||
{MM} = 2 digit month format (e.g. 12 for December)
|
||||
{dd} = Day of the month in 2 digit format (19)
|
||||
{HH} = Current hour in 24-hour format (e.g., 14 for 2 PM)
|
||||
{hh} = Current hour in 12-hour format (e.g., 02 for 2 PM)
|
||||
{mm} = Current minute in 2-digit format (e.g., 09)
|
||||
{tt} = Current AM/PM designator (e.g., AM or PM)
|
||||
```
|
||||
|
||||
An example for Windows 11 24h2 Pro built today would be:
|
||||
|
||||
`-CustomFFUNameTemplate '{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}'`
|
||||
|
||||
Would result in a FFU file name of:
|
||||
|
||||
`Win11_24h2_Pro_2024-12-20_1225.ffu`
|
||||
|
||||
You can also mix in static text in the name
|
||||
|
||||
`-CustomFFUNameTemplate '{WindowsRelease}_{WindowsVersion}_{SKU}_Office_{yyyy}-{MM}-{dd}_{HH}{mm}'`
|
||||
|
||||
Would result in:
|
||||
|
||||
`Win11_24h2_Pro_Office_2024-12-20_1225.ffu`
|
||||
|
||||
## Additional PRs added
|
||||
|
||||
#79 Includes the latest Microsoft Software Removal Tool from `@zehadialam` - use `-UpdateLatestMSRT $true`
|
||||
|
||||
#72 Includes some Unattend Sample files from @HedgeComp in the `$FFUDevelopment\Unattend` folder
|
||||
|
||||
#74 Includes some improvements to the USBImagingToolCreator.ps1 file from @w0
|
||||
|
||||
#103 Includes some additional improvements to the USBImagingToolCreator.ps1 file from @MKellyCBSD
|
||||
|
||||
## Misc Fixes
|
||||
|
||||
- Added server skus to validateset for $WindowsSKU
|
||||
- Added new variable $installationType which uses $WindowsRelease to determine Server or Client. If $installationType is Server, $WindowsRelease version is used to set $WindowsVersion to the appropriate version (1607, 1809, 21H2)
|
||||
- Fixed an issue where the recovery partition wouldn't be created on server OSes due to winre.wim being hidden. Never saw this on client OSes even though it also was hidden IIRC.
|
||||
- Removed verbosity for Optimize-Volume as it was outputting when -verbose was not specified.
|
||||
- Modified some search strings for .NET CUs when installing on Server OS
|
||||
- Included SSU for Windows Server 2016 as it's mandatory
|
||||
- Added some error checking for Server 2019 and 2022 CU installations when CU fails due to lack of SSU. If you run into this error, you're using old media and should use the latest. Always use the latest ISO if you can.
|
||||
- $WindowsVersion set to 24h2, can override by using -WindowsVersion 23H2 if you want the old behavior
|
||||
- Removed the "Downloading information GUID" messages when downloading content from the Microsoft Update Catalog while -verbose was specified in the command line
|
||||
- In Get-KBLink, made a change to just grab the first result returned instead of the entire results page. This removes the need to use a break statement in Save-KB when downloading updates. This fixed an issue with the new 24H2 Checkpoint Cumulative Updates in Win11 and Server 2025.
|
||||
- Changed Windows MSRT search string for x64 and x86. This was mainly to get x64 to the top of the search results. x86 won't actually download since the code isn't in place for content from the MU Catalog to download x86 content (no idea if anyone actually builds x86 FFUs for Win10 - I hope not)
|
||||
- If not passing an ISO, hardcoded WindowsVersion of 22H2 for Windows 10 or 24H2 for Windows 11 since the ESD media only provides those two versions. Not doing this allowed for unnecessary VHDX creation since it checks the WindowsVersion via the json file. This also fixes an issue where CUs could be searched for that didn’t exist, but the media would still download
|
||||
- Added some additional logging entries
|
||||
- Removed verbose output of the Optimize-Volume command
|
||||
- Fixed an issue where not passing an ISO caused the script to fail
|
||||
- Cleaned up the KBPath folder at the end of the run
|
||||
- Changed some minor formatting items
|
||||
- Added Get-Childprocesses function to return child processes of parent process
|
||||
- Added a new -Wait boolean parameter to Invoke-Process function. This is to control whether Invoke-Process should wait in order to track stdout and stderr output. This is needed for processes that may hang, waiting for user input and there isn't a way to bypass (some Intel drivers provided by Dell leave dialog windows even when running silently)
|
||||
-Invoke-Process now returns process information (returns $cmd). This allows for process tracking when calling the function.
|
||||
- Since Invoke-Process now returns the process information, also needed to add Out-Null to the majority of the Invoke-Process references to prevent Invoke-Process from writing to the terminal
|
||||
- Refactored a lot of the Get-DellDrivers function due to inconsistencies with how driver extraction behaves between client and server devices. For client, /s /e seemed to work fine, but for server it would only extract the driver installer content and other dell related files, rather than the driver files themselves. We have since switched to using /s /drivers= which will extract the driver content. Not all drivers support /drivers= and may output some information to the terminal that looks like help documentation. If a driver doesn't support /drivers, the script falls back to using /s /e to do the extraction. If this doesn't work for you, you can always provide your own drivers that you manually download from Dell's website.
|
||||
- Updated Malicious Software Removal Tool (MSRT) code to handle Windows Server
|
||||
- Refactored the Get-ODTURL function to fix recent download issues. Also added some better error handling
|
||||
- Moved the odtsetup.exe download to the FFUDevelopment folder and will clean it up after office has downloaded
|
||||
Updated parameter definition block to be alphabetized (not to be confused by the param block, which is not alphabetized)
|
||||
- Added $PEDriversFolder script variable to the param block (for some reason it was missing)
|
||||
- Added ConfigFile and ExportConfigFile parameters to support json config files
|
||||
- Changed Version to 2412.1
|
||||
- Modified vhdxCacheItem class to include $LogicalSectorSizeBytes
|
||||
- Added new function Get-Parameters to help with new config and export config file functionality
|
||||
- Fixed Get-MicrosoftDrivers function to not require the HTMLFILE COM object, which isn't available in Windows 11. It seems to be installed with Office, which is what was allowing downloads to work and masked the issue.
|
||||
- Added long path support to prevent issues with oscdimg creating the Apps.iso.
|
||||
- Fixed an issue where the $PEDriversFolder variable wasn't being used (instead $FFUDevelopment\PEDrivers was used)
|
||||
- Created a new function New-FFUFileName - this works in conjunction with the new $CustomFFUNameTemplate. The function was needed to support both scenarios where $InstallApps is either $true or $false.
|
||||
- Added new function Export-ConfigFile. When passing -ExportConfigFile 'Path\To\ConfigFile.json' the script will generate a parameter dump of all of the configured parameters
|
||||
- Added driver folder validation to throw an error if spaces are detected in the folder name of the drivers folder (e.g. C:\FFUDevelopment\Drivers\Dell 3190). This is due to an issue with Dell drivers and their inability to handle paths with spaces consistently.
|
||||
- Added back the Windows Security Platform update which grabs it from the web instead of the Microsoft update catalog
|
||||
- Fixed an issue where the Drivers folder was being completely deleted instead of its sub-folders
|
||||
- Removed the Requires -PSEdition Desktop. The script works with both Desktop and Core, so pwsh 7 is fine.
|
||||
- Created a new config folder to hold config files. A new sample_default.json file is provided to show what the format looks like.
|
||||
- You can now set the computername in the unattend.xml file where ever you want. Prior it required that the computername was the first component element.
|
||||
|
||||
## **2409.1**
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix an issue with removal of Defender/OneDrive/Edge after FFU is complete
|
||||
- Migrate Winget downloads to use [Export-WingetPackage cmdlet](https://github.com/microsoft/winget-cli/blob/master/doc/specs/%23658%20-%20WinGet%20Download.md#winget-powershell-cmdlet) as per issue #50
|
||||
- Add support for preview updates https://github.com/rbalsleyMSFT/FFU/pull/51 - thanks to @HedgeComp
|
||||
- Refactor validation of Unattend/prefixes, PPKG, Autopilot to check for these files early in the process, similar to how we check for drivers
|
||||
- Add better logging when unable to find HDD when applying FFU. Will inform to add WinPE drivers to Deployment Media if HDD not found.
|
||||
- Remove ValidateScript on InstallDrivers and break it out in a validation block so -Make and -Model can be specified anywhere in the command line
|
||||
- Add validation for VMHostIPAddress and VMSwtichName and inform the user if these don't match. Should prevent issues where the FFU isn't getting created.
|
||||
- Removed installation of the Windows Security Platform Update as it has been removed from the MU Catalog. See issue #58
|
||||
- Thanks to w0 for PR #54 to change the validation set for WindowsSKU
|
||||
- Thanks to @zehadialam for PR #60 to fix an issue with Windows boot loader for certain devices where Windows Boot Manager is not the first boot entry after the FFU is applied.
|
||||
- Thanks to @HedgeComp for PR #64 and PR #65
|
||||
|
||||
## **2408.1**
|
||||
|
||||
### External Drive Support
|
||||
|
||||
Up until now, the USB build process has supported using drives identified by Windows as removable drives. Most USB sticks will identify as removable, however faster drives may show up as external hard disk media. You may also have a smaller, portable SSD drive that you'd like to use for imaging since these are typically much faster than regular USB 3.x thumb drives.
|
||||
|
||||
In adding this support, I do realize that there is potential for data loss for those that might have external hard drives attached to their machines.
|
||||
|
||||
To handle this, with help from [HedgeComp](https://github.com/HedgeComp), we've refactored the `Get-USBDrives` function. Two new variables have been created:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| AllowExternalHardDiskMedia | Bool | If `$true`, will allow the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is not defined. |
|
||||
| PromptExternalHardDiskMedia | Bool | If `$true` and AllowExternalHardDiskMedia is `$true`, the script will prompt to select which drive to use. When set to `$true`, only a single drive will be created. If `$false`, the script won't prompt for which external hard disk to use and can use multiple external hard disks, similar to how removable USB drives function. |
|
||||
|
||||
By default, this functionality won't effect previous USB drive creation behavior. However if you want to take advantage of the new functionality, set `-AllowExternalHardDiskMedia $true`
|
||||
|
||||
Fixes/misc
|
||||
|
||||
- Fixed a display issue where if multiple FFU files were in the FFU folder, the script wouldn't display which FFUs to choose from when running the script without -verbose. This will now display a table with the last modified date whether you run with the -verbose switch or not.
|
||||
- Added start/end/duration time (thanks [HedgeComp](https://github.com/HedgeComp))
|
||||
- Fixed an issue where deployment media wasn't prompting for a key to be pressed as expected
|
||||
- Fixed an issue when creating the USB drive and the drive had a RAW partition style that clear-disk would generate an error
|
||||
- Cleaned up some commented code
|
||||
- Added Create-PEMedia.ps1 as a helper script to quickly generate Deploy or Capture media
|
||||
- Fixed an issue with clean up of Defender/OneDrive/Edge
|
||||
- Fixed an issue with the formatting of InstallAppsandSysprep.cmd file
|
||||
- Updated parameter documentation in the script to include newly added parameters
|
||||
|
||||
## **2407.1**
|
||||
|
||||
This is another major release that includes:
|
||||
@@ -16,7 +565,7 @@ To support the newly released Copilot+ PCs, we now support the creation and depl
|
||||
* The host machine you're building the FFU from must be ARM64
|
||||
* Office/M365 apps don't currently support installing the ARM64 native bits from an offline system. If you pass `-InstallOffice $true` the script will change the value to false. You can install office after the fact when connected to the internet. I'm investigating this behavior and will issue a fix if/when this gets resolved. I still don't recommend building the FFU VM on the internet.
|
||||
* The [Defender Updates Site](https://www.microsoft.com/en-us/wdsi/defenderupdates) provides download links for Defender definitions. The ARM link doesn't work for ARM64 and mpam-fe.exe fails to install. However there might be an undocumented ARM64 URL that may work. I've included it, but haven't tested it as I'm writing these notes. So we'll see if that works out.
|
||||
* Drivers - I know Surface Laptop 7 and Pro
|
||||
* Drivers - Surface Laptop 7 and Pro 11 don't have ARM64 drivers available yet (there are entries, but they just point to a .txt file). Other OEMs may have drivers available.
|
||||
|
||||
In all, testing has gone very well.
|
||||
|
||||
@@ -43,6 +592,8 @@ In order to get apps to help build your AppList.json file, just run `winget sear
|
||||
|
||||
In this example we see that Firefox is published to both the msstore and winget sources. It's up to you which one you'd like to pick (I assume the msstore and the 128 version from the winget source are both the same version, but that may not be the case). You'll want to use the Name, ID, and Source values to help create your AppList.json file.
|
||||
|
||||
When downloading msstore apps, it does require an Entra ID. If you're building your FFUs from a machine that is not signed in with an Entra ID, you will be prompted for credentials for each app you download AND for the license file for each app (2 prompts per app). If downloading many store apps is something you plan on doing, I highly recommend signing in with an Entra ID to prevent the authentication prompts.
|
||||
|
||||
Other improvements
|
||||
|
||||
* [mhaley](https://github.com/mhaley) made their first contribution to [assign the drive letter to the recovery partition when copying in a custom WinRE.wim](https://github.com/rbalsleyMSFT/FFU/pull/35)
|
||||
|
||||
@@ -1,178 +1,23 @@
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"name": "Windows Terminal",
|
||||
"id": "9N0DX20HK701",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Cross Device Experience Host",
|
||||
"id": "9NTXGKQ8P7N0",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Movies & TV",
|
||||
"id": "9WZDNCRFJ3P2",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft Photos",
|
||||
"id": "9WZDNCRFJBH4",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Mail and Calendar",
|
||||
"id": "9WZDNCRFHVQM",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft Sticky Notes",
|
||||
"id": "9NBLGGH4QGHW",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Power Automate",
|
||||
"id": "9NFTCH6J7FHV",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Snipping Tool",
|
||||
"id": "9MZ95KL8MR0L",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Phone Link",
|
||||
"id": "9NMPJ99VJBWV",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft 365 (Office Hub)",
|
||||
"id": "9WZDNCRFJBH4",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "App Installer",
|
||||
"id": "9NBLGGH4NNS1",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft Clipchamp",
|
||||
"id": "9P1J8S7CCWWT",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Webp Image Extensions",
|
||||
"id": "9PG2DK419DRG",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Web Experience Pack",
|
||||
"id": "9MSSGKG348SP",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Xbox",
|
||||
"id": "9MV0B5HZVK9Z",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Paint",
|
||||
"id": "9PCFS5B6T72H",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Camera",
|
||||
"id": "9WZDNCRFJBBG",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Notepad",
|
||||
"id": "9MSMLRH6LZF3",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Sound Recorder",
|
||||
"id": "9WZDNCRFHWKN",
|
||||
"name": "Windows Client Web Experience",
|
||||
"id": "9MSSGKG348SP",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Calculator",
|
||||
"id": "9WZDNCRFHVN5",
|
||||
"name": "Screen Sketch",
|
||||
"id": "9MZ95KL8MR0L",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Feedback Hub",
|
||||
"id": "9NBLGGH4R32N",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Xbox Identity Provider",
|
||||
"id": "9WZDNCRD1HKW",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Media Player",
|
||||
"id": "9WZDNCRFJ3PT",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "MSN Weather",
|
||||
"id": "9WZDNCRFJ3Q2",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Game Bar",
|
||||
"id": "9NZKPSTSNW4P",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Web Media Extensions",
|
||||
"id": "9N5TDP8VCMHS",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Get Help",
|
||||
"id": "9PKDZBMV1H3T",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Raw Image Extension",
|
||||
"id": "9NCTDW2W1BH8",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Store Experience Host",
|
||||
"id": "9NBLGGH4LS1F",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Maps",
|
||||
"id": "9WZDNCRDTBVB",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Clock",
|
||||
"id": "9WZDNCRFJ3PR",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft To Do",
|
||||
"id": "9NBLGGH5R558",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Cortana",
|
||||
"id": "9NFFX4SZZ23L",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Quick Assist",
|
||||
"id": "9P7BP5VNWKX5",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "HEIF Image Extensions",
|
||||
"id": "9PMMSR1CGPWG",
|
||||
"name": "Windows Terminal",
|
||||
"id": "9N0DX20HK701",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
@@ -181,28 +26,123 @@
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Xbox Live in-game experience",
|
||||
"name": "MPEG2 Video Extension",
|
||||
"id": "9N95Q1ZZPMH4",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Store Purchase App",
|
||||
"id": "9NBLGGH4LS1F",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Feedback Hub",
|
||||
"id": "9NBLGGH4R32N",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Todos",
|
||||
"id": "9NBLGGH5R558",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Raw Image Extension",
|
||||
"id": "9NCTDW2W1BH8",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Power Automate Desktop",
|
||||
"id": "9NFTCH6J7FHV",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Xbox TCUI",
|
||||
"id": "9NKNC0LD5NN6",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Xbox Game Speech Window",
|
||||
"id": "9P086NHDNB9W",
|
||||
"name": "Your Phone",
|
||||
"id": "9NMPJ99VJBWV",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft News",
|
||||
"name": "Xbox Gaming Overlay",
|
||||
"id": "9NZKPSTSNW4P",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Clipchamp",
|
||||
"id": "9P1J8S7CCWWT",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Paint",
|
||||
"id": "9PCFS5B6T72H",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Webp Image Extension",
|
||||
"id": "9PG2DK419DRG",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Get Help",
|
||||
"id": "9PKDZBMV1H3T",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "HEIF Image Extension",
|
||||
"id": "9PMMSR1CGPWG",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Xbox Identity Provider",
|
||||
"id": "9WZDNCRD1HKW",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft Office Hub",
|
||||
"id": "9WZDNCRD29V9",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Bing News",
|
||||
"id": "9WZDNCRFHVFW",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft Store",
|
||||
"id": "9WZDNCRFJBMP",
|
||||
"name": "Windows Sound Recorder",
|
||||
"id": "9WZDNCRFHWKN",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft Tips",
|
||||
"id": "9WZDNCRDTBJJ",
|
||||
"name": "Windows Alarms",
|
||||
"id": "9WZDNCRFJ3PR",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Zune Music",
|
||||
"id": "9WZDNCRFJ3PT",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Bing Weather",
|
||||
"id": "9WZDNCRFJ3Q2",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Camera",
|
||||
"id": "9WZDNCRFJBBG",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Photos",
|
||||
"id": "9WZDNCRFJBH4",
|
||||
"source": "msstore"
|
||||
},
|
||||
{
|
||||
"name": "Windows Store",
|
||||
"id": "9WZDNCRFJBMP",
|
||||
"source": "msstore"
|
||||
}
|
||||
]
|
||||
@@ -1,87 +0,0 @@
|
||||
setlocal enabledelayedexpansion
|
||||
REM Put each app install on a separate line
|
||||
REM M365 Apps/Office ProPlus
|
||||
REM d:\Office\setup.exe /configure d:\office\DeployFFU.xml
|
||||
REM Install Defender Platform Update
|
||||
REM Install Defender Definitions
|
||||
REM Install Windows Security Platform Update
|
||||
REM Install OneDrive Per Machine
|
||||
REM Install Edge Stable
|
||||
REM Winget Win32 Apps
|
||||
REM Add additional apps below here
|
||||
REM Contoso App (Example)
|
||||
REM msiexec /i d:\Contoso\setup.msi /qn /norestart
|
||||
set "INSTALL_STOREAPPS=false"
|
||||
if /i "%INSTALL_STOREAPPS%"=="false" (
|
||||
echo Skipping MS Store installation due to INSTALL_STOREAPPS flag.
|
||||
goto :remaining
|
||||
)
|
||||
set "basepath=D:\MSStore"
|
||||
for /d %%D in ("%basepath%\*") do (
|
||||
set "appfolder=%%D"
|
||||
set "mainpackage="
|
||||
set "dependenciesfolder=!appfolder!\Dependencies"
|
||||
for %%F in ("!appfolder!\*") do (
|
||||
if not "%%~dpF"=="!dependenciesfolder!\" (
|
||||
if /i not "%%~xF"==".xml" (
|
||||
if /i not "%%~xF"==".yaml" (
|
||||
set "mainpackage=%%F"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
@REM for %%F in ("!appfolder!\*.xml") do (
|
||||
@REM set "licensefile=%%F"
|
||||
@REM )
|
||||
if defined mainpackage (
|
||||
set "dism_command=DISM /Online /Add-ProvisionedAppxPackage /PackagePath:"!mainpackage!""
|
||||
if exist "!dependenciesfolder!" (
|
||||
for %%G in ("!dependenciesfolder!\*") do (
|
||||
set "dism_command=!dism_command! /DependencyPackagePath:"%%G""
|
||||
)
|
||||
)
|
||||
for %%F in ("!appfolder!\*.xml") do (
|
||||
set "licensefile=%%F"
|
||||
)
|
||||
if defined licensefile (
|
||||
set "dism_command=!dism_command! /LicensePath:"!licensefile!""
|
||||
) else (
|
||||
set "dism_command=!dism_command! /SkipLicense"
|
||||
)
|
||||
set "dism_command=!dism_command! /Region:All"
|
||||
echo !dism_command!
|
||||
!dism_command!
|
||||
)
|
||||
)
|
||||
:remaining
|
||||
endlocal
|
||||
for /r "D:\" %%G in (.) do (
|
||||
if exist "%%G\Notepad++" (
|
||||
powershell -Command "Remove-AppxPackage -Package NotepadPlusPlus_1.0.0.0_neutral__7njy0v32s6xk6"
|
||||
)
|
||||
)
|
||||
REM The below lines will remove the unattend.xml that gets the machine into audit mode. If not removed, the OS will get stuck booting to audit mode each time.
|
||||
REM Also kills the sysprep process in order to automate sysprep generalize
|
||||
del c:\windows\panther\unattend\unattend.xml /F /Q
|
||||
del c:\windows\panther\unattend.xml /F /Q
|
||||
taskkill /IM sysprep.exe
|
||||
timeout /t 10
|
||||
REM Run disk cleanup (cleanmgr.exe) with all options enabled: https://learn.microsoft.com/en-us/troubleshoot/windows-server/backup-and-storage/automating-disk-cleanup-tool
|
||||
set rootkey=HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches
|
||||
REM Per above doc, the Offline Pages Files subkey does not have stateflags value
|
||||
for /f "tokens=*" %%K in ('reg query "%rootkey%"') do (
|
||||
echo %%K | findstr /i /c:"Offline Pages Files"
|
||||
if errorlevel 1 (
|
||||
reg add "%%K" /v StateFlags0000 /t REG_DWORD /d 2 /f
|
||||
)
|
||||
)
|
||||
cleanmgr.exe /sagerun:0
|
||||
REM Remove the StateFlags0000 registry value
|
||||
for /f "tokens=*" %%K in ('reg query "%rootkey%"') do (
|
||||
echo %%K | findstr /i /c:"Offline Pages Files"
|
||||
if errorlevel 1 (
|
||||
reg delete "%%K" /v StateFlags0000 /f
|
||||
)
|
||||
)
|
||||
REM Sysprep/Generalize
|
||||
c:\windows\system32\sysprep\sysprep.exe /quiet /generalize /oobe
|
||||
@@ -2,16 +2,6 @@
|
||||
<Add SourcePath="C:\FFUDevelopment\Apps\Office" OfficeClientEdition="64" Channel="Current">
|
||||
<Product ID="O365ProPlusRetail">
|
||||
<Language ID="MatchOS" />
|
||||
<ExcludeApp ID="Access" />
|
||||
<ExcludeApp ID="Lync" />
|
||||
<ExcludeApp ID="Publisher" />
|
||||
<ExcludeApp ID="Bing" />
|
||||
</Product>
|
||||
</Add>
|
||||
<Property Name="SharedComputerLicensing" Value="0" />
|
||||
<Property Name="FORCEAPPSHUTDOWN" Value="FALSE" />
|
||||
<Property Name="DeviceBasedLicensing" Value="0" />
|
||||
<Property Name="SCLCacheOverride" Value="0" />
|
||||
<Updates Enabled="TRUE" />
|
||||
<Display Level="None" AcceptEULA="TRUE" />
|
||||
</Configuration>
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"VMWareTools": true,
|
||||
"foo": "bar"
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
# Base path where application folders are located. Each subfolder represents one application.
|
||||
$basePath = "D:\MSStore"
|
||||
# Path for temporary files (e.g., for extracting archives). This will be created and cleaned up automatically.
|
||||
$tempBasePath = Join-Path -Path $env:TEMP -ChildPath "StoreAppInstall"
|
||||
|
||||
# --- SCRIPT ---
|
||||
|
||||
# Helper function to clean up temporary files on exit or error
|
||||
function Remove-TemporaryFiles {
|
||||
if (Test-Path -Path $tempBasePath) {
|
||||
Write-Host "Cleaning up temporary directory: $tempBasePath"
|
||||
Remove-Item -Path $tempBasePath -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure temp directory is clean before starting
|
||||
Remove-TemporaryFiles
|
||||
New-Item -Path $tempBasePath -ItemType Directory -Force | Out-Null
|
||||
|
||||
# 1. Determine applicable dependency architectures based on the OS architecture
|
||||
$osArchitecture = $env:PROCESSOR_ARCHITECTURE
|
||||
$applicableArchitectures = switch ($osArchitecture) {
|
||||
"AMD64" { 'x64', 'x86' }
|
||||
"x86" { 'x86' }
|
||||
"ARM64" { 'arm64', 'arm' }
|
||||
default { $osArchitecture.ToLower() }
|
||||
}
|
||||
Write-Host "Installing Store Apps: Detected OS Architecture: $osArchitecture."
|
||||
Write-Host "Applicable dependency architectures: $($applicableArchitectures -join ', ')"
|
||||
|
||||
# Check if the base path exists
|
||||
if (-not (Test-Path -Path $basePath)) {
|
||||
Write-Host "Installing Store Apps: Base path '$basePath' does not exist. Exiting."
|
||||
exit
|
||||
}
|
||||
Write-Host "Installing Store Apps: Base path '$basePath' exists."
|
||||
|
||||
# 2. Process and install each main application
|
||||
Write-Host "Starting main application installation process..."
|
||||
foreach ($appFolder in Get-ChildItem -Path $basePath -Directory) {
|
||||
Write-Host "--- Processing application in folder: $($appFolder.Name) ---"
|
||||
|
||||
# Find the main application package (.appx/.msix/.appxbundle) in the app's root folder
|
||||
$mainPackage = Get-ChildItem -Path $appFolder.FullName -File |
|
||||
Where-Object { $_.Extension -in '.appx', '.msix', '.appxbundle', '.msixbundle' } |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $mainPackage) {
|
||||
Write-Warning "No main application package found in '$($appFolder.Name)'. Skipping."
|
||||
Write-Output ""
|
||||
continue
|
||||
}
|
||||
Write-Host "Found main package: $($mainPackage.Name)"
|
||||
|
||||
# Extract and parse AppxManifest.xml from the main package
|
||||
$manifestTempPath = Join-Path -Path $tempBasePath -ChildPath "AppxManifest.xml"
|
||||
if (Test-Path $manifestTempPath) { Remove-Item $manifestTempPath -Force }
|
||||
|
||||
$requiredDependencies = $null
|
||||
try {
|
||||
[System.Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") | Out-Null
|
||||
|
||||
# Logic for handling bundles vs. single packages
|
||||
if ($mainPackage.Extension -in '.appxbundle', '.msixbundle') {
|
||||
Write-Host "Processing bundle. Searching for architecture-specific package..."
|
||||
$bundleArchive = [System.IO.Compression.ZipFile]::OpenRead($mainPackage.FullName)
|
||||
try {
|
||||
# Find the best matching .appx/.msix package inside the bundle
|
||||
$primaryArch = if ($osArchitecture -eq 'AMD64') { 'x64' } else { $osArchitecture.ToLower() }
|
||||
$packageEntries = $bundleArchive.Entries | Where-Object { ($_.Name.EndsWith('.appx') -or $_.Name.EndsWith('.msix')) -and $_.Name -notlike "*_language-*" }
|
||||
|
||||
# Prioritize the primary architecture, then x86 (on x64), then neutral
|
||||
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]${primaryArch}\.(appx|msix)$" } | Select-Object -First 1
|
||||
if (-not $bestPackageEntry -and $primaryArch -eq 'x64') {
|
||||
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]x86\.(appx|msix)$" } | Select-Object -First 1
|
||||
}
|
||||
if (-not $bestPackageEntry) {
|
||||
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]neutral\.(appx|msix)$" } | Select-Object -First 1
|
||||
}
|
||||
|
||||
if ($bestPackageEntry) {
|
||||
Write-Host "Found inner package: $($bestPackageEntry.Name). Extracting to read its manifest."
|
||||
$innerPackageTempPath = Join-Path -Path $tempBasePath -ChildPath $bestPackageEntry.Name
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($bestPackageEntry, $innerPackageTempPath, $true)
|
||||
|
||||
$innerPackageArchive = [System.IO.Compression.ZipFile]::OpenRead($innerPackageTempPath)
|
||||
try {
|
||||
$manifestEntry = $innerPackageArchive.Entries | Where-Object { $_.Name -eq 'AppxManifest.xml' } | Select-Object -First 1
|
||||
if ($manifestEntry) {
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($manifestEntry, $manifestTempPath, $true)
|
||||
}
|
||||
} finally {
|
||||
$innerPackageArchive.Dispose()
|
||||
}
|
||||
} else {
|
||||
Write-Error "Could not find a suitable architecture-specific package inside '$($mainPackage.Name)'."
|
||||
}
|
||||
} finally {
|
||||
$bundleArchive.Dispose()
|
||||
}
|
||||
} else { # It's a regular .appx or .msix
|
||||
$zipArchive = [System.IO.Compression.ZipFile]::OpenRead($mainPackage.FullName)
|
||||
try {
|
||||
$manifestEntry = $zipArchive.Entries | Where-Object { $_.Name -eq 'AppxManifest.xml' } | Select-Object -First 1
|
||||
if ($manifestEntry) {
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($manifestEntry, $manifestTempPath, $true)
|
||||
}
|
||||
} finally {
|
||||
$zipArchive.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Common manifest parsing logic
|
||||
if (Test-Path $manifestTempPath) {
|
||||
[xml]$manifest = Get-Content -Path $manifestTempPath
|
||||
$nsm = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable)
|
||||
$nsm.AddNamespace("def", "http://schemas.microsoft.com/appx/manifest/foundation/windows10")
|
||||
|
||||
$dependenciesNode = $manifest.SelectSingleNode("//def:Dependencies", $nsm)
|
||||
if ($dependenciesNode) {
|
||||
$requiredDependencies = $dependenciesNode.SelectNodes("def:PackageDependency", $nsm)
|
||||
}
|
||||
} else {
|
||||
Write-Error "Could not find or extract AppxManifest.xml from '$($mainPackage.FullName)'."
|
||||
}
|
||||
} catch {
|
||||
Write-Error "Failed to read or parse manifest from '$($mainPackage.FullName)'. Error: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Scan for and resolve dependencies only if the manifest lists actual package dependencies.
|
||||
$resolvedDependencyPaths = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
if ($null -ne $requiredDependencies -and $requiredDependencies.Count -gt 0) {
|
||||
$appDependenciesPath = Join-Path -Path $appFolder.FullName -ChildPath "Dependencies"
|
||||
|
||||
if (Test-Path -Path $appDependenciesPath) {
|
||||
Write-Host "Scanning for dependencies in '$appDependenciesPath'..."
|
||||
$appSpecificDependencies = @{}
|
||||
$dependencyFoldersToScan = [System.Collections.Generic.List[string]]::new()
|
||||
$dependencyFoldersToScan.Add($appDependenciesPath)
|
||||
|
||||
# Handle zipped dependencies by extracting them to a temp location
|
||||
Get-ChildItem -Path $appDependenciesPath -Filter "*.zip" -File | ForEach-Object {
|
||||
$zipFile = $_
|
||||
# Ensure unique extract path per app to avoid conflicts
|
||||
$extractPath = Join-Path -Path $tempBasePath -ChildPath "$($appFolder.Name)_$($zipFile.BaseName)"
|
||||
Write-Host "Extracting zipped dependencies from '$($zipFile.FullName)' to '$extractPath'..."
|
||||
try {
|
||||
Expand-Archive -Path $zipFile.FullName -DestinationPath $extractPath -Force
|
||||
$dependencyFoldersToScan.Add($extractPath)
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to extract '$($zipFile.FullName)'. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Regex to parse package filenames
|
||||
$packageFileRegex = '^(?<Name>.+?)_(?<Version>(?:\d+\.){2,3}\d+)_(?:[^_]+_)*(?<Arch>x64|x86|arm|arm64|neutral)(?:__.*)?$'
|
||||
|
||||
# Catalog all package files found in the dependency folders for this app
|
||||
foreach ($folder in $dependencyFoldersToScan.ToArray() | Select-Object -Unique) {
|
||||
Get-ChildItem -Path $folder -Recurse -File | Where-Object { $_.Extension -in '.appx', '.msix', '.appxbundle' } | ForEach-Object {
|
||||
$file = $_
|
||||
$match = $file.BaseName -imatch $packageFileRegex
|
||||
if ($match) {
|
||||
$dependencyName = $matches.Name
|
||||
try {
|
||||
$dependencyVersion = [System.Version]$matches.Version
|
||||
$dependencyArch = $matches.Arch
|
||||
|
||||
if (-not $appSpecificDependencies.ContainsKey($dependencyName)) {
|
||||
$appSpecificDependencies[$dependencyName] = [System.Collections.Generic.List[object]]::new()
|
||||
}
|
||||
$appSpecificDependencies[$dependencyName].Add([pscustomobject]@{
|
||||
Name = $dependencyName
|
||||
Version = $dependencyVersion
|
||||
Arch = $dependencyArch
|
||||
Path = $file.FullName
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Could not parse version for file '$($file.Name)'. Skipping."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host "Dependency scan for '$($appFolder.Name)' complete."
|
||||
|
||||
# Resolve all required dependencies using the app-specific catalog
|
||||
foreach ($req in $requiredDependencies) {
|
||||
$reqName = $req.Name
|
||||
$reqMinVersion = [System.Version]$req.MinVersion
|
||||
Write-Host "Resolving dependency: $reqName (MinVersion: $reqMinVersion)"
|
||||
|
||||
if ($appSpecificDependencies.ContainsKey($reqName)) {
|
||||
# Find all available packages that meet the minimum version and architecture requirements
|
||||
$candidates = $appSpecificDependencies[$reqName] | Where-Object {
|
||||
$_.Version -ge $reqMinVersion -and
|
||||
$_.Arch -in ($applicableArchitectures + 'neutral')
|
||||
}
|
||||
|
||||
if ($candidates) {
|
||||
# Group by architecture and find the single latest version for each applicable arch
|
||||
$bestCandidates = $candidates | Group-Object -Property Arch | ForEach-Object {
|
||||
$_.Group | Sort-Object -Property Version -Descending | Select-Object -First 1
|
||||
}
|
||||
|
||||
foreach($best in $bestCandidates) {
|
||||
Write-Host " - Found best match: $($best.Path.Replace($basePath, '...'))"
|
||||
$resolvedDependencyPaths.Add($best.Path)
|
||||
}
|
||||
} else {
|
||||
Write-Warning " - No suitable package found for dependency '$reqName' with MinVersion '$reqMinVersion' for applicable architectures."
|
||||
}
|
||||
} else {
|
||||
Write-Warning " - Dependency '$reqName' not found in this app's dependency folder."
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning "Dependencies are required by manifest, but no 'Dependencies' folder found for '$($appFolder.Name)'."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "No actual package dependencies listed in manifest for '$($appFolder.Name)'. Proceeding without dependency resolution."
|
||||
}
|
||||
|
||||
# Build the DISM command
|
||||
$dismParams = @(
|
||||
"/Online"
|
||||
"/Add-ProvisionedAppxPackage"
|
||||
"/PackagePath:`"$($mainPackage.FullName)`""
|
||||
"/Region:all"
|
||||
# "/StubPackageOption:installfull"
|
||||
)
|
||||
|
||||
# Add resolved dependencies, ensuring no duplicates
|
||||
$resolvedDependencyPaths.ToArray() | Select-Object -Unique | ForEach-Object {
|
||||
$dismParams += "/DependencyPackagePath:`"$_`""
|
||||
}
|
||||
|
||||
# Find and add the license file, or skip if not found
|
||||
$licenseFile = Get-ChildItem -Path $appFolder.FullName -Filter "*.xml" -File | Select-Object -First 1
|
||||
if ($licenseFile) {
|
||||
$dismParams += "/LicensePath:`"$($licenseFile.FullName)`""
|
||||
} else {
|
||||
$dismParams += "/SkipLicense"
|
||||
}
|
||||
|
||||
# Execute the DISM command
|
||||
$dismCommand = "DISM.exe " + ($dismParams -join " ")
|
||||
Write-Host "Constructed DISM command:"
|
||||
Write-Output $dismCommand
|
||||
|
||||
try {
|
||||
Invoke-Expression -Command $dismCommand -ErrorAction Stop
|
||||
Write-Host "Successfully installed $($mainPackage.Name)."
|
||||
} catch {
|
||||
Write-Error "DISM command failed for $($mainPackage.Name). Error: $($_.Exception.Message)"
|
||||
}
|
||||
Write-Output ""
|
||||
}
|
||||
|
||||
# Final cleanup
|
||||
Write-Host "Installation process finished."
|
||||
Remove-TemporaryFiles
|
||||
@@ -0,0 +1,325 @@
|
||||
function Invoke-Process {
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param
|
||||
(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter()]
|
||||
[string[]]$ArgumentList,
|
||||
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[bool]$Wait = $true,
|
||||
|
||||
[Parameter()]
|
||||
[string[]]$AdditionalSuccessCodes,
|
||||
|
||||
[Parameter()]
|
||||
[bool]$IgnoreNonZeroExitCodes = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
try {
|
||||
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
|
||||
# Use .NET Process class for proper stream handling
|
||||
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$pinfo.FileName = $FilePath
|
||||
if ($ArgumentList) {
|
||||
$pinfo.Arguments = $ArgumentList -join ' '
|
||||
}
|
||||
$pinfo.RedirectStandardOutput = $true
|
||||
$pinfo.RedirectStandardError = $true
|
||||
$pinfo.UseShellExecute = $false
|
||||
$pinfo.CreateNoWindow = $true
|
||||
|
||||
$p = New-Object System.Diagnostics.Process
|
||||
$p.StartInfo = $pinfo
|
||||
|
||||
# Start the process
|
||||
$p.Start() | Out-Null
|
||||
|
||||
# Read output and error streams
|
||||
$cmdOutput = $p.StandardOutput.ReadToEnd()
|
||||
$cmdError = $p.StandardError.ReadToEnd()
|
||||
|
||||
if ($Wait) {
|
||||
$p.WaitForExit()
|
||||
}
|
||||
|
||||
$exitCode = $p.ExitCode
|
||||
# An exit code of 0 is always a success
|
||||
if ($exitCode -ne 0) {
|
||||
# If IgnoreNonZeroExitCodes is true, treat any non-zero exit code as a success
|
||||
if ($IgnoreNonZeroExitCodes) {
|
||||
Write-Host "Ignoring non-zero exit code $exitCode because IgnoreNonZeroExitCodes is set to true."
|
||||
}
|
||||
# Check if the non-zero exit code is in the list of additional success codes
|
||||
elseif ($null -eq $AdditionalSuccessCodes -or $exitCode -notin $AdditionalSuccessCodes) {
|
||||
if ($cmdError) {
|
||||
throw $cmdError.Trim()
|
||||
}
|
||||
if ($cmdOutput) {
|
||||
throw $cmdOutput.Trim()
|
||||
}
|
||||
# If there's no output, throw a generic error with the exit code
|
||||
if (-not $cmdError -and -not $cmdOutput) {
|
||||
throw "Process exited with non-zero code: $exitCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
|
||||
# WriteLog $cmdOutput
|
||||
}
|
||||
}
|
||||
|
||||
# Create a simple object with exit code for compatibility
|
||||
$result = [PSCustomObject]@{
|
||||
ExitCode = $exitCode
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
}
|
||||
catch {
|
||||
#$PSCmdlet.ThrowTerminatingError($_)
|
||||
# WriteLog $_
|
||||
# Write-Host "Script failed - $Logfile for more info"
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
|
||||
function Format-MsiArguments {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Ensures MSI file paths in msiexec arguments are properly quoted.
|
||||
.DESCRIPTION
|
||||
Detects /i arguments followed by an unquoted path ending in .msi
|
||||
and wraps the path in double quotes to handle paths with spaces.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$CommandLine,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Arguments
|
||||
)
|
||||
|
||||
# Only process if the command is msiexec
|
||||
if ($CommandLine -notmatch '^msiexec(\.exe)?$') {
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
# Regex pattern explanation:
|
||||
# (?i) - Case-insensitive matching
|
||||
# (/i)\s+ - Match /i followed by whitespace
|
||||
# (?!") - Negative lookahead: not already quoted
|
||||
# (.+?\.msi) - Capture path ending in .msi (lazy match to stop at first .msi)
|
||||
# (?=\s+/|\s*$) - Followed by another switch or end of string
|
||||
|
||||
# Pattern to match /i followed by an unquoted MSI path
|
||||
$pattern = '(?i)(/i)\s+(?!")(.+?\.msi)(?=\s+/|\s*$)'
|
||||
|
||||
if ($Arguments -match $pattern) {
|
||||
$originalArgs = $Arguments
|
||||
# Replace with quoted path
|
||||
$Arguments = $Arguments -replace $pattern, '$1 "$2"'
|
||||
Write-Host "Detected unquoted MSI path in msiexec arguments. Adjusted arguments:"
|
||||
Write-Host "Original: $originalArgs"
|
||||
Write-Host "Modified: $Arguments"
|
||||
}
|
||||
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
function Install-Applications {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[array]$apps
|
||||
)
|
||||
|
||||
if ($apps.Count -eq 0) {
|
||||
Write-Host "No applications to install from this source."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Total apps to install from this source: $($apps.Count)"
|
||||
|
||||
# Sort all apps by priority
|
||||
$sortedApps = $apps | Sort-Object -Property Priority
|
||||
|
||||
# Install each app
|
||||
foreach ($app in $sortedApps) {
|
||||
# Check if required properties exist
|
||||
if (-not $app.PSObject.Properties['Name'] -or -not $app.PSObject.Properties['CommandLine'] -or -not $app.PSObject.Properties['Arguments']) {
|
||||
Write-Warning "Skipping app due to missing required properties (Name, CommandLine, Arguments): $($app | ConvertTo-Json -Depth 1 -Compress)"
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Host "Installing $($app.Name)..."
|
||||
|
||||
# Wait until no MSIExec installation is running
|
||||
while ($true) {
|
||||
try {
|
||||
# Try to open the MSIExec global mutex
|
||||
$Mutex = [System.Threading.Mutex]::OpenExisting("Global\_MSIExecute")
|
||||
# Dispose releases the handle from our script only.
|
||||
$Mutex.Dispose()
|
||||
Write-Host "Another MSIExec installer is running. Waiting for 5 seconds before rechecking..."
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
catch [System.Threading.WaitHandleCannotBeOpenedException] {
|
||||
# If we can't open the mutex, it means no MSIExec installation is running
|
||||
break
|
||||
}
|
||||
catch {
|
||||
# Handle other potential errors when checking the mutex
|
||||
Write-Warning "Error checking MSIExec mutex: $_. Proceeding with caution."
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Check for 'PAUSE' command
|
||||
if ($app.CommandLine -eq 'PAUSE') {
|
||||
Write-Host "Pausing script as requested by '$($app.Name)'. Press Enter to continue..."
|
||||
$null = Read-Host
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
# Normalize arguments: treat null/empty/whitespace as no arguments
|
||||
$argumentsToPass = $null
|
||||
if ($null -ne $app.Arguments) {
|
||||
if ($app.Arguments -is [array]) {
|
||||
$trimmed = $app.Arguments | ForEach-Object { ($_ | ForEach-Object { if ($_ -ne $null) { $_.ToString().Trim() } else { $_ } }) } | Where-Object { $_ -and (-not [string]::IsNullOrWhiteSpace($_)) }
|
||||
if ($trimmed.Count -gt 0) {
|
||||
$argumentsToPass = $trimmed
|
||||
}
|
||||
}
|
||||
else {
|
||||
$single = $app.Arguments.ToString().Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($single)) {
|
||||
$argumentsToPass = @($single)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Check for and parse AdditionalExitCodes
|
||||
$additionalSuccessCodes = @()
|
||||
if ($app.PSObject.Properties['AdditionalExitCodes'] -and -not [string]::IsNullOrWhiteSpace($app.AdditionalExitCodes)) {
|
||||
$additionalSuccessCodes = $app.AdditionalExitCodes -split ',' | ForEach-Object { $_.Trim() }
|
||||
Write-Host "Additional success exit codes for $($app.Name): $($additionalSuccessCodes -join ', ')"
|
||||
}
|
||||
|
||||
# Check for IgnoreNonZeroExitCodes
|
||||
$ignoreNonZeroExitCodes = $false
|
||||
if ($app.PSObject.Properties['IgnoreNonZeroExitCodes'] -and $app.IgnoreNonZeroExitCodes -is [bool]) {
|
||||
$ignoreNonZeroExitCodes = $app.IgnoreNonZeroExitCodes
|
||||
}
|
||||
|
||||
# Auto-quote MSI paths if using msiexec and path contains spaces but no quotes
|
||||
if ($null -ne $argumentsToPass -and $argumentsToPass.Count -gt 0) {
|
||||
$joinedArgs = $argumentsToPass -join ' '
|
||||
$formattedArgs = Format-MsiArguments -CommandLine $app.CommandLine -Arguments $joinedArgs
|
||||
if ($formattedArgs -ne $joinedArgs) {
|
||||
$argumentsToPass = @($formattedArgs)
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $argumentsToPass -or $argumentsToPass.Count -eq 0) {
|
||||
Write-Host "Running command: $($app.CommandLine) (no arguments)"
|
||||
$result = Invoke-Process -FilePath $app.CommandLine -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
|
||||
}
|
||||
else {
|
||||
Write-Host "Running command: $($app.CommandLine) $($argumentsToPass -join ' ')"
|
||||
$result = Invoke-Process -FilePath $app.CommandLine -ArgumentList $argumentsToPass -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
|
||||
}
|
||||
Write-Host "$($app.Name) exited with exit code: $($result.ExitCode)`r`n"
|
||||
}
|
||||
catch {
|
||||
Write-Error "Error occurred while installing $($app.Name): $_"
|
||||
Read-Host "An error occurred, and the script cannot continue. Press Enter to exit."
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Define paths for the JSON files
|
||||
$wingetAppsJsonFile = "$PSScriptRoot\WinGetWin32Apps.json"
|
||||
# Look for UserAppList.json one directory level up from the script's location. This keeps the user specific json files (AppList.json and UserAppList.json in the Apps dir)
|
||||
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json"
|
||||
|
||||
# Initialize empty arrays for apps from each source
|
||||
$wingetApps = @()
|
||||
$userApps = @()
|
||||
|
||||
# Read the WinGetWin32Apps.json file if it exists
|
||||
if (Test-Path -Path $wingetAppsJsonFile) {
|
||||
Write-Host "Processing WinGetWin32Apps.json..."
|
||||
try {
|
||||
$wingetContent = Get-Content -Path $wingetAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
|
||||
if ($wingetContent -is [array]) {
|
||||
$wingetApps = $wingetContent
|
||||
Write-Host "Found $(($wingetApps | Measure-Object).Count) WinGet Win32 apps."
|
||||
}
|
||||
elseif ($wingetContent) {
|
||||
$wingetApps = @($wingetContent) # Ensure it's an array
|
||||
Write-Host "Found 1 WinGet Win32 app."
|
||||
}
|
||||
else {
|
||||
Write-Host "WinGetWin32Apps.json is empty or invalid."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to read or parse WinGetWin32Apps.json file: $_"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "WinGetWin32Apps.json file not found. Skipping."
|
||||
}
|
||||
|
||||
# Install WinGet apps if any were found
|
||||
if ($wingetApps.Count -gt 0) {
|
||||
Install-Applications -apps $wingetApps
|
||||
}
|
||||
|
||||
# Read the UserAppList.json file if it exists
|
||||
if (Test-Path -Path $userAppsJsonFile) {
|
||||
Write-Host "Processing UserAppList.json..."
|
||||
try {
|
||||
$userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
|
||||
if ($userContent -is [array]) {
|
||||
$userApps = $userContent
|
||||
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
|
||||
}
|
||||
elseif ($userContent) {
|
||||
$userApps = @($userContent) # Ensure it's an array
|
||||
Write-Host "Found 1 user-defined app."
|
||||
}
|
||||
else {
|
||||
Write-Host "UserAppList.json is empty or invalid."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to read or parse UserAppList.json file: $_"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "UserAppList.json file not found. Skipping."
|
||||
}
|
||||
|
||||
# Install User apps if any were found
|
||||
if ($userApps.Count -gt 0) {
|
||||
Install-Applications -apps $userApps
|
||||
}
|
||||
|
||||
# Check if any apps were installed at all
|
||||
if ($wingetApps.Count -eq 0 -and $userApps.Count -eq 0) {
|
||||
Write-Host "No Win32 apps found in either WinGetWin32Apps.json or UserAppList.json. Exiting."
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "All Win32 app installations attempted."
|
||||
@@ -0,0 +1,60 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
This script uses the variables from the AppsScriptVariables hashtable passed to BuildFFUVM.ps1 to run application deployment tasks.
|
||||
|
||||
.DESCRIPTION
|
||||
By defining the variables in the AppsScriptVariables hashtable, you can customize the application deployment tasks that are run by this script.
|
||||
The BuildFFUVM.ps1 script will export the AppsScriptVariables hashtable to a JSON file in the Orchestration folder.
|
||||
Include your own custom script here if you want to run it as part of the application deployment tasks.
|
||||
Alternatively, you can pass the AppsScriptVariables hashtable directly to this script.
|
||||
#>
|
||||
|
||||
param (
|
||||
[hashtable]$AppsScriptVariables
|
||||
)
|
||||
|
||||
# Try to read from the JSON file if it exists and AppsScriptVariables is not provided
|
||||
$appsScriptVarsJsonPath = Join-Path -Path $PSScriptRoot -ChildPath "AppsScriptVariables.json"
|
||||
if ((-not $AppsScriptVariables -or $AppsScriptVariables.Count -eq 0) -and (Test-Path -Path $appsScriptVarsJsonPath)) {
|
||||
try {
|
||||
$jsonContent = Get-Content -Path $appsScriptVarsJsonPath -Raw -ErrorAction Stop
|
||||
$jsonObject = $jsonContent | ConvertFrom-Json -ErrorAction Stop
|
||||
|
||||
# Convert PSCustomObject to hashtable
|
||||
$AppsScriptVariables = @{}
|
||||
foreach ($prop in $jsonObject.PSObject.Properties) {
|
||||
$AppsScriptVariables[$prop.Name] = $prop.Value
|
||||
}
|
||||
|
||||
Write-Host "Successfully loaded AppsScriptVariables from $appsScriptVarsJsonPath"
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to load AppsScriptVariables from JSON file: $_"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "AppsScriptVariables provided directly, skipping JSON file load."
|
||||
}
|
||||
|
||||
# Example of how to use the AppsScriptVariables hashtable to control script execution
|
||||
|
||||
# Note: The UI saves the values as strings, so if you type true for a value, it'll save to the config file as a string, not boolean
|
||||
|
||||
# Example: Check if a variable named 'foo' is set to string 'bar' and run a script accordingly
|
||||
# if ($AppsScriptVariables['foo'] -eq 'bar') {
|
||||
# Write-Host "Foo would have installed"
|
||||
# }
|
||||
# else {
|
||||
# Write-Host "Foo would not have installed"
|
||||
# }
|
||||
|
||||
# Example: Check if a variable named 'Teams' is set to string 'true' and run a script accordingly
|
||||
# if ($AppsScriptVariables['Teams'] -eq 'true') {
|
||||
# Write-Host "Teams would have been installed"
|
||||
# }
|
||||
# else {
|
||||
# Write-Host "Teams would not have been installed"
|
||||
# }
|
||||
|
||||
# Your code below here
|
||||
Write-Host 'Invoke-AppsScript.ps1 finished'
|
||||
@@ -0,0 +1,116 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Orchestration script for FFU VM deployment tasks
|
||||
|
||||
.DESCRIPTION
|
||||
This script orchestrates the following deployment tasks:
|
||||
- Install-Office.ps1
|
||||
- Update-Defender.ps1
|
||||
- Update-MSRT.ps1
|
||||
- Update-OneDrive.ps1
|
||||
- Update-Edge.ps1
|
||||
- Install-Win32Apps.ps1
|
||||
- Invoke-AppsScript.ps1
|
||||
- Install-UserApps.ps1
|
||||
- Install-StoreApps.ps1
|
||||
- Run-DiskCleanup.ps1
|
||||
- Run-Sysprep.ps1
|
||||
|
||||
The script will check for the presence of each of these files and if they exist, will run the script
|
||||
#>
|
||||
|
||||
# Header
|
||||
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
Write-Host " FFU Builder Orchestrator " -ForegroundColor Yellow
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
|
||||
# Define the path to the scripts
|
||||
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
# Define the list of scripts to run, order doesn't matter - if you have a custom script, add it here
|
||||
$scriptList = @(
|
||||
"Update-Defender.ps1",
|
||||
"Install-Office.ps1",
|
||||
"Update-MSRT.ps1",
|
||||
"Update-OneDrive.ps1",
|
||||
"Update-Edge.ps1",
|
||||
"Install-Win32Apps.ps1",
|
||||
"Install-StoreApps.ps1",
|
||||
"Install-UserApps.ps1"
|
||||
)
|
||||
# Check if each script exists and has content to process, then run it
|
||||
foreach ($script in $scriptList) {
|
||||
$scriptFile = Join-Path -Path $scriptPath -ChildPath $script
|
||||
if (-not (Test-Path -Path $scriptFile)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$shouldRun = $true # Default to run if script exists
|
||||
switch ($script) {
|
||||
"Install-Win32Apps.ps1" {
|
||||
$wingetAppsJsonFile = Join-Path -Path $scriptPath -ChildPath "WinGetWin32Apps.json"
|
||||
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $scriptPath) -ChildPath "UserAppList.json"
|
||||
if (-not (Test-Path -Path $wingetAppsJsonFile) -and -not (Test-Path -Path $userAppsJsonFile)) {
|
||||
$shouldRun = $false
|
||||
}
|
||||
}
|
||||
"Install-StoreApps.ps1" {
|
||||
$msStorePath = "D:\MSStore"
|
||||
if (-not (Test-Path -Path $msStorePath) -or -not (Get-ChildItem -Path $msStorePath)) {
|
||||
$shouldRun = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldRun) {
|
||||
Write-Host "`n" # Add a newline for spacing
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
Write-Host " Running script: $script " -ForegroundColor Yellow
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
# Run script and wait for it to finish
|
||||
& $scriptFile
|
||||
}
|
||||
}
|
||||
|
||||
# Invoke-AppsScript.ps1 if it exists and AppsScriptVariables.json is present
|
||||
$appsScriptFile = Join-Path -Path $scriptPath -ChildPath "Invoke-AppsScript.ps1"
|
||||
$appsScriptVarsJsonPath = Join-Path -Path $PSScriptRoot -ChildPath "AppsScriptVariables.json"
|
||||
if ((Test-Path -Path $appsScriptFile) -and (Test-Path -Path $appsScriptVarsJsonPath)) {
|
||||
Write-Host "`n" # Add a newline for spacing
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
Write-Host " Running script: Invoke-AppsScript.ps1 " -ForegroundColor Yellow
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
|
||||
Write-Host "Using AppsScriptVariables from JSON file: $appsScriptVarsJsonPath"
|
||||
& $appsScriptFile
|
||||
}
|
||||
|
||||
# Run-DiskCleanup.ps1 must run before Run-Sysprep.ps1
|
||||
$diskCleanupScript = Join-Path -Path $scriptPath -ChildPath "Run-DiskCleanup.ps1"
|
||||
if (Test-Path -Path $diskCleanupScript) {
|
||||
Write-Host "`n" # Add a newline for spacing
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
Write-Host " Running script: Run-DiskCleanup.ps1 " -ForegroundColor Yellow
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
# Run script and wait for it to finish
|
||||
& $diskCleanupScript
|
||||
|
||||
} else {
|
||||
Write-Host "Run-DiskCleanup.ps1 not found!"
|
||||
}
|
||||
|
||||
# Run-Sysprep.ps1 must run last
|
||||
$sysprepScript = Join-Path -Path $scriptPath -ChildPath "Run-Sysprep.ps1"
|
||||
if (Test-Path -Path $sysprepScript) {
|
||||
Write-Host "`n" # Add a newline for spacing
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
Write-Host " Running script: Run-Sysprep.ps1 " -ForegroundColor Yellow
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
# Run script and wait for it to finish
|
||||
& $sysprepScript
|
||||
} else {
|
||||
Write-Host "Run-Sysprep.ps1 not found!"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Run disk cleanup (cleanmgr.exe) with all options enabled
|
||||
# Reference: https://learn.microsoft.com/en-us/troubleshoot/windows-server/backup-and-storage/automating-disk-cleanup-tool
|
||||
|
||||
$rootKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches"
|
||||
|
||||
# Set StateFlags0000 to 2 for all subkeys except "Offline Pages Files"
|
||||
Get-ChildItem -Path $rootKey | ForEach-Object {
|
||||
if ($_.PSChildName -ne "Offline Pages Files") {
|
||||
Set-ItemProperty -Path $_.PSPath -Name "StateFlags0000" -Type DWord -Value 2 -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Run the disk cleanup tool with the specified flags
|
||||
Start-Process -FilePath "cleanmgr.exe" -ArgumentList "/sagerun:0" -Wait
|
||||
|
||||
# Remove the StateFlags0000 registry values that were added
|
||||
Get-ChildItem -Path $rootKey | ForEach-Object {
|
||||
if ($_.PSChildName -ne "Offline Pages Files") {
|
||||
Remove-ItemProperty -Path $_.PSPath -Name "StateFlags0000" -Force
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#The below lines will remove the unattend.xml that gets the machine into audit mode. If not removed, the OS will get stuck booting to audit mode each time.
|
||||
#Also kills the sysprep process in order to automate sysprep generalize
|
||||
Write-Host "Removing existing unattend.xml files and stopping sysprep process if running..."
|
||||
Remove-Item -Path "C:\windows\panther\unattend\unattend.xml" -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "C:\windows\panther\unattend.xml" -Force -ErrorAction SilentlyContinue
|
||||
Stop-Process -Name "sysprep" -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 10
|
||||
|
||||
# Detect and remediate per-user, non-provisioned Appx packages that would block Sysprep.
|
||||
Write-Host "Checking for per-user Appx packages not provisioned for all users (potential Sysprep blockers)..."
|
||||
|
||||
# Build hash set of provisioned package families (DisplayName_PublisherId).
|
||||
$provFamilies = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||
Get-AppxProvisionedPackage -Online | ForEach-Object {
|
||||
$family = '{0}_{1}' -f $_.DisplayName, $_.PublisherId
|
||||
[void]$provFamilies.Add($family)
|
||||
}
|
||||
|
||||
# Collect current user Appx packages excluding frameworks, resource packs, and non-removable packages.
|
||||
$userApps = Get-AppxPackage -User $env:USERNAME | Where-Object {
|
||||
$_.Status -eq 'Ok' -and
|
||||
-not $_.IsFramework -and
|
||||
-not $_.IsResourcePackage -and
|
||||
-not $_.NonRemovable
|
||||
}
|
||||
|
||||
# Identify packages not provisioned (per-user only).
|
||||
$notProvisioned = foreach ($pkg in $userApps) {
|
||||
if (-not $provFamilies.Contains($pkg.PackageFamilyName)) {
|
||||
[PSCustomObject]@{
|
||||
Name = $pkg.Name
|
||||
PackageFamilyName = $pkg.PackageFamilyName
|
||||
Version = $pkg.Version
|
||||
SignatureKind = $pkg.SignatureKind
|
||||
PackageFullName = $pkg.PackageFullName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($notProvisioned) {
|
||||
Write-Host "Found $($notProvisioned.Count) per-user Appx package(s) not provisioned for all users:"
|
||||
$notProvisioned | Sort-Object PackageFamilyName | Format-Table -AutoSize -Property Name,PackageFamilyName,Version
|
||||
Write-Host "Attempting removal of per-user, non-provisioned Appx packages..."
|
||||
foreach ($pkg in $notProvisioned) {
|
||||
try {
|
||||
Write-Host "Removing $($pkg.PackageFullName)..."
|
||||
Remove-AppxPackage -Package $pkg.PackageFullName -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to remove $($pkg.PackageFullName): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Re-check after attempted removals.
|
||||
$remaining = @()
|
||||
$currentUserApps = Get-AppxPackage -User $env:USERNAME | Where-Object {
|
||||
$_.Status -eq 'Ok' -and
|
||||
-not $_.IsFramework -and
|
||||
-not $_.IsResourcePackage -and
|
||||
-not $_.NonRemovable
|
||||
}
|
||||
foreach ($pkg in $currentUserApps) {
|
||||
if (-not $provFamilies.Contains($pkg.PackageFamilyName)) {
|
||||
$remaining += $pkg
|
||||
}
|
||||
}
|
||||
|
||||
if ($remaining.Count -gt 0) {
|
||||
Write-Error "Unable to remove all per-user, non-provisioned Appx packages. Sysprep cannot continue."
|
||||
$remaining | Sort-Object PackageFamilyName | Format-Table -AutoSize -Property Name,PackageFamilyName,Version
|
||||
throw "Sysprep aborted due to unresolved per-user Appx packages. Resolve manually and re-run."
|
||||
}
|
||||
else {
|
||||
Write-Host "All per-user, non-provisioned Appx packages were successfully removed."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "No per-user, non-provisioned Appx packages detected."
|
||||
}
|
||||
|
||||
# If an Unattend.xml has been provided on the mounted Apps ISO (D:\Unattend\Unattend.xml),
|
||||
# pass it to sysprep; otherwise, run without /unattend.
|
||||
$unattendOnAppsIso = "D:\Unattend\Unattend.xml"
|
||||
if (Test-Path -Path $unattendOnAppsIso) {
|
||||
Write-Host "Using $unattendOnAppsIso from Apps ISO..."
|
||||
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe /unattend:$unattendOnAppsIso
|
||||
}
|
||||
else {
|
||||
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<RunAsynchronous>
|
||||
<RunAsynchronousCommand wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<Path>d:\InstallAppsandSysprep.cmd</Path>
|
||||
<Path>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File "d:\orchestration\orchestrator.ps1"</Path>
|
||||
</RunAsynchronousCommand>
|
||||
</RunAsynchronous>
|
||||
</component>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<RunAsynchronous>
|
||||
<RunAsynchronousCommand wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<Path>d:\InstallAppsandSysprep.cmd</Path>
|
||||
<Path>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File "d:\orchestration\orchestrator.ps1"</Path>
|
||||
</RunAsynchronousCommand>
|
||||
</RunAsynchronous>
|
||||
</component>
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Launches the FFU Development UI, a WPF application for configuring and running the FFU build process.
|
||||
.DESCRIPTION
|
||||
The BuildFFUVM_UI.ps1 script is the main entry point for the FFU Development user interface. It initializes and displays a WPF-based graphical interface defined in BuildFFUVM_UI.xaml.
|
||||
|
||||
The script is responsible for:
|
||||
- Initializing a global state object to manage UI controls, data, and application flags.
|
||||
- Importing the required FFU.Common and FFUUI.Core modules which contain the business logic.
|
||||
- Ensuring system prerequisites, such as PowerShell 7 and Long Path Support, are met.
|
||||
- Loading the XAML window, initializing UI controls with default values, and registering all event handlers.
|
||||
- Launching the core build script (BuildFFUVM.ps1) in a background job when the user initiates a build.
|
||||
- Providing real-time feedback by monitoring the build log file and updating the UI's progress bar and log viewer.
|
||||
- Handling cleanup operations, such as reverting system settings, when the application is closed.
|
||||
|
||||
This script acts as the primary host for the UI, connecting the user interface with the underlying build and logic modules.
|
||||
#>
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
[CmdletBinding()]
|
||||
[System.STAThread()]
|
||||
param()
|
||||
|
||||
# Check PowerShell Version
|
||||
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||
Write-Error "PowerShell 7 or later is required to run this script."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Creating custom state object to hold UI state and data
|
||||
$FFUDevelopmentPath = $PSScriptRoot
|
||||
|
||||
$script:uiState = [PSCustomObject]@{
|
||||
FFUDevelopmentPath = $FFUDevelopmentPath;
|
||||
Window = $null;
|
||||
Controls = @{
|
||||
featureCheckBoxes = @{};
|
||||
UpdateInstallAppsBasedOnUpdates = $null
|
||||
};
|
||||
Data = @{
|
||||
allDriverModels = [System.Collections.Generic.List[PSCustomObject]]::new();
|
||||
appsScriptVariablesDataList = [System.Collections.Generic.List[PSCustomObject]]::new();
|
||||
versionData = $null;
|
||||
vmSwitchMap = @{};
|
||||
logData = $null;
|
||||
logStreamReader = $null;
|
||||
pollTimer = $null;
|
||||
currentBuildProcess = $null;
|
||||
lastConfigFilePath = $null
|
||||
};
|
||||
Flags = @{
|
||||
installAppsForcedByUpdates = $false;
|
||||
prevInstallAppsStateBeforeUpdates = $null;
|
||||
installAppsCheckedByOffice = $false;
|
||||
lastSortProperty = $null;
|
||||
lastSortAscending = $true;
|
||||
isBuilding = $false;
|
||||
isCleanupRunning = $false
|
||||
};
|
||||
Defaults = @{};
|
||||
LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log"
|
||||
}
|
||||
|
||||
# Remove any existing modules to avoid conflicts
|
||||
if (Get-Module -Name 'FFU.Common' -ErrorAction SilentlyContinue) {
|
||||
Remove-Module -Name 'FFU.Common' -Force
|
||||
}
|
||||
if (Get-Module -Name 'FFUUI.Core' -ErrorAction SilentlyContinue) {
|
||||
Remove-Module -Name 'FFUUI.Core' -Force
|
||||
}
|
||||
# Import Modules
|
||||
Import-Module "$PSScriptRoot\FFU.Common" -Force
|
||||
Import-Module "$PSScriptRoot\FFUUI.Core" -Force
|
||||
|
||||
# Set the log path
|
||||
Set-CommonCoreLogPath -Path $script:uiState.LogFilePath
|
||||
|
||||
# Setting long path support - this prevents issues where some applications have deep directory structures
|
||||
# and driver extraction fails due to long paths.
|
||||
$script:uiState.Flags.originalLongPathsValue = $null # Store original value
|
||||
try {
|
||||
$script:uiState.Flags.originalLongPathsValue = Get-ItemPropertyValue -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch {
|
||||
# Key or value might not exist, which is fine.
|
||||
WriteLog "Could not read initial LongPathsEnabled value (may not exist)."
|
||||
}
|
||||
|
||||
# Enable long paths if not already enabled
|
||||
if ($script:uiState.Flags.originalLongPathsValue -ne 1) {
|
||||
try {
|
||||
WriteLog 'LongPathsEnabled is not set to 1. Setting it to 1 for the duration of this script.'
|
||||
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1 -Force
|
||||
WriteLog 'LongPathsEnabled set to 1.'
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error setting LongPathsEnabled registry key: $($_.Exception.Message). Long path issues might persist."
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "LongPathsEnabled is already set to 1."
|
||||
}
|
||||
|
||||
if (Test-Path -Path $script:uiState.LogFilePath) {
|
||||
Remove-item -Path $script:uiState.LogFilePath -Force
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName WindowsBase
|
||||
Add-Type -AssemblyName PresentationCore, PresentationFramework
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
# Load XAML
|
||||
$xamlPath = Join-Path $PSScriptRoot "BuildFFUVM_UI.xaml"
|
||||
if (-not (Test-Path $xamlPath)) {
|
||||
Write-Error "XAML file not found: $xamlPath"
|
||||
return
|
||||
}
|
||||
$xamlString = Get-Content $xamlPath -Raw
|
||||
$reader = New-Object System.IO.StringReader($xamlString)
|
||||
$xmlReader = [System.Xml.XmlReader]::Create($reader)
|
||||
$window = [Windows.Markup.XamlReader]::Load($xmlReader)
|
||||
|
||||
$window.Add_Loaded({
|
||||
# Pass the state object to all initialization functions
|
||||
$script:uiState.Window = $window
|
||||
$window.Tag = $script:uiState
|
||||
Initialize-UIControls -State $script:uiState
|
||||
Initialize-UIDefaults -State $script:uiState
|
||||
Initialize-DynamicUIElements -State $script:uiState
|
||||
Register-EventHandlers -State $script:uiState
|
||||
|
||||
# Attempt automatic load of previous environment (silent)
|
||||
try {
|
||||
Invoke-AutoLoadPreviousEnvironment -State $script:uiState
|
||||
}
|
||||
catch {
|
||||
WriteLog "Auto-load previous environment failed: $($_.Exception.Message)"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# Button: Build FFU
|
||||
$script:uiState.Controls.btnRun = $window.FindName('btnRun')
|
||||
$script:uiState.Controls.btnRun.Add_Click({
|
||||
# Get a local reference to the button for convenience in this handler
|
||||
$btnRun = $script:uiState.Controls.btnRun
|
||||
try {
|
||||
# If a build is running and cleanup is not already running, treat this click as Cancel
|
||||
if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) {
|
||||
$btnRun.IsEnabled = $false
|
||||
$script:uiState.Controls.txtStatus.Text = "Cancel requested. Stopping build..."
|
||||
WriteLog "Cancel requested by user. Stopping background build process."
|
||||
|
||||
# Stop the timer
|
||||
if ($null -ne $script:uiState.Data.pollTimer) {
|
||||
$script:uiState.Data.pollTimer.Stop()
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
}
|
||||
|
||||
# Close the log stream
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
$script:uiState.Data.logStreamReader.Close()
|
||||
$script:uiState.Data.logStreamReader.Dispose()
|
||||
$script:uiState.Data.logStreamReader = $null
|
||||
}
|
||||
|
||||
# Stop the running build process
|
||||
$processToStop = $script:uiState.Data.currentBuildProcess
|
||||
$script:uiState.Data.currentBuildProcess = $null
|
||||
|
||||
if ($null -ne $processToStop) {
|
||||
# Recursively terminate the build process and any children (DISM, setup tools, etc.)
|
||||
function Stop-ProcessTree {
|
||||
param([int]$parentPid)
|
||||
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
|
||||
foreach ($child in $children) {
|
||||
Stop-ProcessTree -parentPid $child.ProcessId
|
||||
}
|
||||
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
Stop-ProcessTree -parentPid $processToStop.Id
|
||||
WriteLog "Background build process stopped (PID: $($processToStop.Id))."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error terminating build process tree: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Safety net: kill any active DISM capture still running
|
||||
try {
|
||||
$dismCaptures = Get-CimInstance Win32_Process -Filter "Name='DISM.EXE'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '/Capture-FFU' }
|
||||
foreach ($p in $dismCaptures) {
|
||||
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error stopping DISM capture processes: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Also stop Office ODT setup.exe if running (to avoid recreating files after cleanup)
|
||||
try {
|
||||
$officePathForKill = $null
|
||||
|
||||
# Prefer explicit UI path
|
||||
$uiOfficePath = $script:uiState.Controls.txtOfficePath.Text
|
||||
if (-not [string]::IsNullOrWhiteSpace($uiOfficePath)) {
|
||||
$officePathForKill = $uiOfficePath
|
||||
}
|
||||
else {
|
||||
# Fall back to the last config path only if known
|
||||
$lastConfigPathLocal = $script:uiState.Data.lastConfigFilePath
|
||||
if (-not [string]::IsNullOrWhiteSpace($lastConfigPathLocal)) {
|
||||
$ffuDevRoot = Split-Path (Split-Path $lastConfigPathLocal -Parent) -Parent
|
||||
if (-not [string]::IsNullOrWhiteSpace($ffuDevRoot)) {
|
||||
$officePathForKill = Join-Path $ffuDevRoot 'Apps\Office'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Only proceed when a valid Office folder exists
|
||||
if ($officePathForKill -and (Test-Path -LiteralPath $officePathForKill -PathType Container)) {
|
||||
$setupProcs = Get-CimInstance Win32_Process -Filter "Name='setup.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -like "$officePathForKill*" }
|
||||
foreach ($p in $setupProcs) {
|
||||
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error stopping Office setup.exe processes: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Start cleanup using the same BuildFFUVM.ps1 via -Cleanup short-circuit
|
||||
$lastConfigPath = $script:uiState.Data.lastConfigFilePath
|
||||
if ([string]::IsNullOrWhiteSpace($lastConfigPath)) {
|
||||
WriteLog "No stored config file path found. Cleanup cannot proceed."
|
||||
$script:uiState.Controls.txtStatus.Text = "Build canceled. No config found for cleanup."
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
$btnRun.Content = "Build FFU"
|
||||
$btnRun.IsEnabled = $true
|
||||
return
|
||||
}
|
||||
|
||||
$ffuDevPath = Split-Path (Split-Path $lastConfigPath -Parent) -Parent
|
||||
$mainLogPath = Join-Path $ffuDevPath "FFUDevelopment.log"
|
||||
|
||||
WriteLog "Starting cleanup without deleting FFUDevelopment.log (will append new entries)."
|
||||
|
||||
$script:uiState.Controls.txtStatus.Text = "Cancel in progress... Cleaning environment..."
|
||||
WriteLog "Starting cleanup job (BuildFFUVM.ps1 -Cleanup)."
|
||||
|
||||
# Prepare parameters for cleanup
|
||||
# Inform user: in-progress items will be removed; ask whether to also remove other items downloaded during this run
|
||||
$removeCurrentRunToo = $false
|
||||
$promptText = "Cancel requested.`n`nWe'll remove the download currently in progress to avoid partial/corrupt content.`n`nDo you also want to remove other items downloaded during this run? Previously downloaded items will be kept."
|
||||
$result = [System.Windows.MessageBox]::Show($promptText, "Cancel cleanup options", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
|
||||
if ($result -eq [System.Windows.MessageBoxResult]::Yes) { $removeCurrentRunToo = $true }
|
||||
|
||||
$cleanupParams = @{
|
||||
ConfigFile = $lastConfigPath
|
||||
Cleanup = $true
|
||||
# Avoid wiping all user content on cancel
|
||||
RemoveApps = $false
|
||||
RemoveUpdates = $false
|
||||
CleanupDrivers = $false
|
||||
# Scoped removal to current run only (optional per user choice)
|
||||
CleanupCurrentRunDownloads = $removeCurrentRunToo
|
||||
}
|
||||
|
||||
# Start cleanup in a separate pwsh process so the UI stays responsive
|
||||
$pwshPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'
|
||||
if (-not (Test-Path -Path $pwshPath)) {
|
||||
$pwshPath = 'pwsh'
|
||||
}
|
||||
|
||||
$cleanupScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'BuildFFUVM.ps1'
|
||||
|
||||
# Build argument list for cleanup.
|
||||
# -Cleanup is a [switch] in BuildFFUVM.ps1, so do not pass a value after it.
|
||||
# Use -Param:$true/$false syntax for boolean parameters to avoid argument transformation errors.
|
||||
$cleanupArgs = @(
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-File', $cleanupScriptPath,
|
||||
'-ConfigFile', $cleanupParams.ConfigFile,
|
||||
'-Cleanup',
|
||||
"-RemoveApps:$($cleanupParams.RemoveApps)",
|
||||
"-RemoveUpdates:$($cleanupParams.RemoveUpdates)",
|
||||
"-CleanupDrivers:$($cleanupParams.CleanupDrivers)",
|
||||
"-CleanupCurrentRunDownloads:$($cleanupParams.CleanupCurrentRunDownloads)"
|
||||
)
|
||||
|
||||
$startCleanupParams = @{
|
||||
FilePath = $pwshPath
|
||||
ArgumentList = $cleanupArgs
|
||||
PassThru = $true
|
||||
}
|
||||
if ($Host.Name -eq 'ConsoleHost') {
|
||||
$startCleanupParams['NoNewWindow'] = $true
|
||||
}
|
||||
|
||||
$script:uiState.Data.currentBuildProcess = Start-Process @startCleanupParams
|
||||
|
||||
# Wait for log file to appear (or open immediately if it exists)
|
||||
$logWaitTimeout = 60
|
||||
$watch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
$watch.Stop()
|
||||
|
||||
# Open log stream for cleanup (tail to end to avoid re-reading the whole file)
|
||||
if (Test-Path $mainLogPath) {
|
||||
$fileStream = [System.IO.File]::Open($mainLogPath, 'Open', 'Read', 'ReadWrite')
|
||||
[void]$fileStream.Seek(0, [System.IO.SeekOrigin]::End)
|
||||
$script:uiState.Data.logStreamReader = [System.IO.StreamReader]::new($fileStream)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update during cleanup."
|
||||
}
|
||||
|
||||
# Create a timer to poll the cleanup process
|
||||
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
|
||||
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
|
||||
$script:uiState.Flags.isCleanupRunning = $true
|
||||
|
||||
$script:uiState.Data.pollTimer.Add_Tick({
|
||||
param($sender, $e)
|
||||
$currentProcess = $script:uiState.Data.currentBuildProcess
|
||||
|
||||
# Read new lines from log
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
|
||||
$script:uiState.Data.logData.Add($line)
|
||||
if ($script:uiState.Flags.autoScrollLog) {
|
||||
$script:uiState.Controls.lstLogOutput.ScrollIntoView($line)
|
||||
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
|
||||
if ($null -ne $sender) { $sender.Stop() }
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
return
|
||||
}
|
||||
|
||||
if ($currentProcess.HasExited) {
|
||||
if ($null -ne $sender) { $sender.Stop() }
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
$lastLine = $null
|
||||
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
|
||||
$script:uiState.Data.logData.Add($line)
|
||||
$lastLine = $line
|
||||
}
|
||||
if ($script:uiState.Flags.autoScrollLog -and $null -ne $lastLine) {
|
||||
$script:uiState.Controls.lstLogOutput.ScrollIntoView($lastLine)
|
||||
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
|
||||
}
|
||||
$script:uiState.Data.logStreamReader.Close()
|
||||
$script:uiState.Data.logStreamReader.Dispose()
|
||||
$script:uiState.Data.logStreamReader = $null
|
||||
}
|
||||
|
||||
$script:uiState.Controls.txtStatus.Text = "Build canceled. Environment cleaned."
|
||||
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||
$script:uiState.Controls.pbOverallProgress.Value = 0
|
||||
|
||||
# Clear cleanup process state
|
||||
$script:uiState.Data.currentBuildProcess = $null
|
||||
|
||||
# Reset flags and button
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$btn = $script:uiState.Controls.btnRun
|
||||
$btn.Content = "Build FFU"
|
||||
$btn.IsEnabled = $true
|
||||
}
|
||||
})
|
||||
|
||||
$script:uiState.Data.pollTimer.Start()
|
||||
return
|
||||
}
|
||||
|
||||
# Not currently building: start a new build
|
||||
$btnRun.IsEnabled = $false
|
||||
|
||||
# Switch to Monitor Tab
|
||||
$script:uiState.Controls.MainTabControl.SelectedItem = $script:uiState.Controls.MonitorTab
|
||||
|
||||
# Clear previous log data and reset autoscroll
|
||||
if ($null -ne $script:uiState.Data.logData) {
|
||||
$script:uiState.Data.logData.Clear()
|
||||
$script:uiState.Flags.autoScrollLog = $true
|
||||
}
|
||||
|
||||
$progressBar = $script:uiState.Controls.pbOverallProgress
|
||||
$txtStatus = $script:uiState.Controls.txtStatus
|
||||
$progressBar.Visibility = 'Visible'
|
||||
$txtStatus.Text = "Starting FFU build..."
|
||||
|
||||
# Gather config on the UI thread before starting the job
|
||||
$config = Get-UIConfig -State $script:uiState
|
||||
|
||||
# Validate Additional FFU selection if enabled
|
||||
if ($config.BuildUSBDrive -and $config.CopyAdditionalFFUFiles -and (($null -eq $config.AdditionalFFUFiles) -or ($config.AdditionalFFUFiles.Count -eq 0))) {
|
||||
[System.Windows.MessageBox]::Show("Please select at least one additional FFU file to copy, or uncheck 'Copy Additional FFU Files'.", "Selection Required", "OK", "Warning") | Out-Null
|
||||
$btnRun.IsEnabled = $true
|
||||
$script:uiState.Controls.txtStatus.Text = "Build canceled: Additional FFU selection required."
|
||||
return
|
||||
}
|
||||
|
||||
$configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json"
|
||||
# Sort top-level keys alphabetically for consistent output
|
||||
$sortedConfig = [ordered]@{}
|
||||
foreach ($k in ($config.Keys | Sort-Object)) { $sortedConfig[$k] = $config[$k] }
|
||||
$sortedConfig | ConvertTo-Json -Depth 10 | Set-Content -Path $configFilePath -Encoding UTF8
|
||||
$script:uiState.Data.lastConfigFilePath = $configFilePath
|
||||
|
||||
if ($config.InstallOffice -and $config.OfficeConfigXMLFile) {
|
||||
Copy-Item -Path $config.OfficeConfigXMLFile -Destination $config.OfficePath -Force
|
||||
WriteLog "Office Configuration XML file copied successfully."
|
||||
}
|
||||
|
||||
$txtStatus.Text = "Executing BuildFFUVM.ps1 in the background..."
|
||||
WriteLog "Executing BuildFFUVM.ps1 in the background..."
|
||||
|
||||
# Start BuildFFUVM.ps1 in a separate pwsh process.
|
||||
# This keeps the UI responsive and restores console interaction (Write-Host / Read-Host) when available.
|
||||
$pwshPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'
|
||||
if (-not (Test-Path -Path $pwshPath)) {
|
||||
$pwshPath = 'pwsh'
|
||||
}
|
||||
|
||||
$buildScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'BuildFFUVM.ps1'
|
||||
$pwshArgs = @(
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-File', $buildScriptPath,
|
||||
'-ConfigFile', $configFilePath
|
||||
)
|
||||
if ($config.Verbose) {
|
||||
$pwshArgs += '-Verbose'
|
||||
}
|
||||
|
||||
# Delete the old log file before starting the build process to ensure we don't read stale content.
|
||||
$mainLogPath = Join-Path $config.FFUDevelopmentPath "FFUDevelopment.log"
|
||||
if (Test-Path $mainLogPath) {
|
||||
WriteLog "Removing old FFUDevelopment.log file."
|
||||
Remove-Item -Path $mainLogPath -Force
|
||||
}
|
||||
|
||||
$startBuildParams = @{
|
||||
FilePath = $pwshPath
|
||||
ArgumentList = $pwshArgs
|
||||
PassThru = $true
|
||||
}
|
||||
if ($Host.Name -eq 'ConsoleHost') {
|
||||
$startBuildParams['NoNewWindow'] = $true
|
||||
}
|
||||
|
||||
# Start the build process and store it in the shared state object
|
||||
$script:uiState.Data.currentBuildProcess = Start-Process @startBuildParams
|
||||
|
||||
# Wait for the new log file to be created by the background process.
|
||||
$logWaitTimeout = 15 # seconds
|
||||
$watch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
$watch.Stop()
|
||||
|
||||
# Open a stream reader to the main log file
|
||||
if (Test-Path $mainLogPath) {
|
||||
$fileStream = [System.IO.File]::Open($mainLogPath, 'Open', 'Read', 'ReadWrite')
|
||||
$script:uiState.Data.logStreamReader = [System.IO.StreamReader]::new($fileStream)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update."
|
||||
}
|
||||
|
||||
# Create a timer to poll the job status from the UI thread
|
||||
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
|
||||
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
|
||||
|
||||
# Add the Tick event handler
|
||||
$script:uiState.Data.pollTimer.Add_Tick({
|
||||
param($sender, $e)
|
||||
# This scriptblock runs on the UI thread, so it can safely access script-scoped variables
|
||||
$currentProcess = $script:uiState.Data.currentBuildProcess
|
||||
|
||||
# Read from log stream
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
|
||||
# Add the full line to the log view first to maintain consistency
|
||||
$script:uiState.Data.logData.Add($line)
|
||||
if ($script:uiState.Flags.autoScrollLog) {
|
||||
$script:uiState.Controls.lstLogOutput.ScrollIntoView($line)
|
||||
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
|
||||
}
|
||||
|
||||
# Now, check if it's a progress line and update the UI accordingly
|
||||
if ($line -match '\[PROGRESS\] (\d{1,3}) \| (.*)') {
|
||||
$percentage = [double]$matches[1]
|
||||
$message = $matches[2]
|
||||
|
||||
# Update progress bar and status text
|
||||
$script:uiState.Controls.pbOverallProgress.Value = $percentage
|
||||
$script:uiState.Controls.txtStatus.Text = $message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# If process is somehow null or the timer has been nulled out, stop the timer
|
||||
if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
|
||||
if ($null -ne $sender) {
|
||||
$sender.Stop()
|
||||
}
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
return
|
||||
}
|
||||
|
||||
# Check if the build process has exited
|
||||
if ($currentProcess.HasExited) {
|
||||
# Stop the timer, we're done polling
|
||||
if ($null -ne $sender) {
|
||||
$sender.Stop()
|
||||
}
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
|
||||
# Final read of the log stream
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
$lastLine = $null
|
||||
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
|
||||
# Add the full line to the log view first
|
||||
$script:uiState.Data.logData.Add($line)
|
||||
$lastLine = $line
|
||||
|
||||
# Now, check if it's a progress line and update the UI accordingly
|
||||
if ($line -match '\[PROGRESS\] (\d{1,3}) \| (.*)') {
|
||||
$percentage = [double]$matches[1]
|
||||
$message = $matches[2]
|
||||
|
||||
$script:uiState.Controls.pbOverallProgress.Value = $percentage
|
||||
$script:uiState.Controls.txtStatus.Text = $message
|
||||
}
|
||||
}
|
||||
|
||||
# After the final read, scroll to the last line if autoscroll is enabled
|
||||
if ($script:uiState.Flags.autoScrollLog -and $null -ne $lastLine) {
|
||||
$script:uiState.Controls.lstLogOutput.ScrollIntoView($lastLine)
|
||||
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
|
||||
}
|
||||
|
||||
$script:uiState.Data.logStreamReader.Close()
|
||||
$script:uiState.Data.logStreamReader.Dispose()
|
||||
$script:uiState.Data.logStreamReader = $null
|
||||
}
|
||||
|
||||
$exitCode = $currentProcess.ExitCode
|
||||
|
||||
# Determine final status based on process exit code
|
||||
$finalStatusText = "FFU build completed successfully."
|
||||
if ($exitCode -ne 0) {
|
||||
$finalStatusText = "FFU build failed. Check FFUDevelopment.log for details."
|
||||
WriteLog "BuildFFUVM.ps1 process failed with exit code: $exitCode"
|
||||
[System.Windows.MessageBox]::Show("The build process failed. Please check the $FFUDevelopmentPath\FFUDevelopment.log file for details.`n`nExit code: $exitCode", "Build Error", "OK", "Error") | Out-Null
|
||||
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||
}
|
||||
else {
|
||||
WriteLog "BuildFFUVM.ps1 process completed successfully."
|
||||
$script:uiState.Controls.pbOverallProgress.Value = 100
|
||||
}
|
||||
|
||||
# Update UI elements
|
||||
$script:uiState.Controls.txtStatus.Text = $finalStatusText
|
||||
|
||||
# Clear process state
|
||||
$script:uiState.Data.currentBuildProcess = $null
|
||||
|
||||
# Reset button and flags for next run
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
$script:uiState.Controls.btnRun.Content = "Build FFU"
|
||||
$script:uiState.Controls.btnRun.IsEnabled = $true
|
||||
}
|
||||
})
|
||||
|
||||
# Start the timer
|
||||
$script:uiState.Data.pollTimer.Start()
|
||||
|
||||
# Mark building and toggle button to Cancel
|
||||
$script:uiState.Flags.isBuilding = $true
|
||||
$btnRun.Content = "Cancel"
|
||||
$btnRun.IsEnabled = $true
|
||||
}
|
||||
catch {
|
||||
# This catch block handles errors during the setup of the job (e.g., Get-UIConfig fails)
|
||||
$errorMessage = "An error occurred before starting the build job: $_"
|
||||
WriteLog $errorMessage
|
||||
[System.Windows.MessageBox]::Show($errorMessage, "Error", "OK", "Error")
|
||||
|
||||
# Clean up stream reader if it was opened
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
$script:uiState.Data.logStreamReader.Close()
|
||||
$script:uiState.Data.logStreamReader.Dispose()
|
||||
$script:uiState.Data.logStreamReader = $null
|
||||
}
|
||||
|
||||
# Re-enable UI elements
|
||||
$script:uiState.Controls.txtStatus.Text = "FFU build failed to start."
|
||||
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||
if ($null -ne $script:uiState.Controls.btnRun) {
|
||||
$script:uiState.Controls.btnRun.IsEnabled = $true
|
||||
$script:uiState.Controls.btnRun.Content = "Build FFU"
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Add handler for Remove button clicks
|
||||
$window.Add_SourceInitialized({
|
||||
$listView = $window.FindName('lstApplications')
|
||||
$listView.AddHandler(
|
||||
[System.Windows.Controls.Button]::ClickEvent,
|
||||
[System.Windows.RoutedEventHandler] {
|
||||
param($buttonSender, $clickEventArgs)
|
||||
if ($clickEventArgs.OriginalSource -is [System.Windows.Controls.Button] -and $clickEventArgs.OriginalSource.Content -eq "Remove") {
|
||||
Remove-Application -priority $clickEventArgs.OriginalSource.Tag -State $script:uiState
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
# Register cleanup to reclaim memory and revert LongPathsEnabled setting when the UI window closes
|
||||
$window.Add_Closed({
|
||||
# Stop any running build process if the window is closed
|
||||
if ($null -ne $script:uiState.Data.currentBuildProcess) {
|
||||
WriteLog "UI closing, stopping background build process."
|
||||
|
||||
# Stop the timer
|
||||
if ($null -ne $script:uiState.Data.pollTimer) {
|
||||
$script:uiState.Data.pollTimer.Stop()
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
}
|
||||
|
||||
# Close the log stream
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
$script:uiState.Data.logStreamReader.Close()
|
||||
$script:uiState.Data.logStreamReader.Dispose()
|
||||
$script:uiState.Data.logStreamReader = $null
|
||||
}
|
||||
|
||||
$processToStop = $script:uiState.Data.currentBuildProcess
|
||||
$script:uiState.Data.currentBuildProcess = $null
|
||||
|
||||
try {
|
||||
# Terminate the build process and any children
|
||||
function Stop-ProcessTree {
|
||||
param([int]$parentPid)
|
||||
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
|
||||
foreach ($child in $children) {
|
||||
Stop-ProcessTree -parentPid $child.ProcessId
|
||||
}
|
||||
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
|
||||
if ($null -ne $processToStop -and -not $processToStop.HasExited) {
|
||||
Stop-ProcessTree -parentPid $processToStop.Id
|
||||
}
|
||||
|
||||
WriteLog "Background process stopped."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error stopping background build process: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Revert LongPathsEnabled registry setting if it was changed by this script
|
||||
if ($script:uiState.Flags.originalLongPathsValue -ne 1) {
|
||||
# Only revert if we changed it from something other than 1
|
||||
try {
|
||||
$currentValue = Get-ItemPropertyValue -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -ErrorAction SilentlyContinue
|
||||
if ($currentValue -eq 1) {
|
||||
# Double-check it's still 1 before reverting
|
||||
$revertValue = if ($null -eq $script:uiState.Flags.originalLongPathsValue) { 0 } else { $script:uiState.Flags.originalLongPathsValue } # Revert to original or 0 if it didn't exist
|
||||
WriteLog "Reverting LongPathsEnabled registry key back to original value ($revertValue)."
|
||||
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value $revertValue -Force
|
||||
WriteLog "LongPathsEnabled reverted."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error reverting LongPathsEnabled registry key: $($_.Exception.Message)."
|
||||
}
|
||||
}
|
||||
|
||||
# # Garbage collection
|
||||
# [System.GC]::Collect()
|
||||
# [System.GC]::WaitForPendingFinalizers()
|
||||
})
|
||||
|
||||
[void]$window.ShowDialog()
|
||||
@@ -0,0 +1,865 @@
|
||||
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sys="clr-namespace:System;assembly=mscorlib" Title="FFU Builder UI">
|
||||
<Window.Resources>
|
||||
<Style x:Key="MinimalExpanderNoHighlightStyle" TargetType="Expander">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Expander">
|
||||
<StackPanel>
|
||||
<!-- Header Toggle -->
|
||||
<ToggleButton x:Name="HeaderToggle" IsChecked="{Binding IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" Background="Transparent" BorderThickness="0" Padding="0" HorizontalAlignment="Left" HorizontalContentAlignment="Left" VerticalContentAlignment="Center">
|
||||
<ToggleButton.Style>
|
||||
<Style TargetType="ToggleButton">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ToggleButton.Style>
|
||||
<!-- Text + Arrow side by side -->
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Optional Features" Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<!-- Default arrow = “▼” -->
|
||||
<TextBlock x:Name="ArrowText" Text="▼" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</ToggleButton>
|
||||
<!-- Expanded content -->
|
||||
<ContentPresenter x:Name="ExpandSite" Visibility="Collapsed" Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
<!-- Trigger: Show content, swap arrow to “▲” when expanded -->
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsExpanded" Value="True">
|
||||
<Setter TargetName="ExpandSite" Property="Visibility" Value="Visible"/>
|
||||
<Setter TargetName="ArrowText" Property="Text" Value="▲"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<!-- Added global tooltip styles for consistent tooltips -->
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="ToolTip" Value="{Binding Tag, RelativeSource={RelativeSource Self}}"/>
|
||||
</Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="ToolTip" Value="{Binding Tag, RelativeSource={RelativeSource Self}}"/>
|
||||
</Style>
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="ToolTip" Value="{Binding Tag, RelativeSource={RelativeSource Self}}"/>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- TabControl with multiple tabs -->
|
||||
<TabControl x:Name="MainTabControl" TabStripPlacement="Left" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" FontSize="14" Padding="10" Grid.Row="0">
|
||||
<!-- TAB: Home -->
|
||||
<TabItem Header="Home" Padding="20">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<Grid Margin="10">
|
||||
<TextBlock Text="Welcome to FFU Builder" FontSize="20" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- TAB: Hyper-V Settings -->
|
||||
<TabItem Header="Hyper-V Settings" Padding="20">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- Row 0: VM Switch Name -->
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
|
||||
<TextBlock Text="VM Switch Name" ToolTip="Name of the Hyper-V virtual switch. If $InstallApps is set to $true, this must be set. This is required to capture the FFU from the VM. The default is '*external*', but you will likely need to change this."/>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="cmbVMSwitchName" Grid.Row="0" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Name of the Hyper-V virtual switch. If $InstallApps is set to $true, this must be set. This is required to capture the FFU from the VM. The default is '*external*', but you will likely need to change this."/>
|
||||
<!-- Row 1: Custom VM Switch Name -->
|
||||
<TextBox x:Name="txtCustomVMSwitchName" Grid.Row="1" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Visibility="Collapsed" ToolTip="Enter your custom VM Switch Name if 'Other' is selected."/>
|
||||
<!-- Row 2: VM Host IP Address -->
|
||||
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
|
||||
<TextBlock Text="VM Host IP Address" ToolTip="IP address of the Hyper-V host for FFU capture. If $InstallApps is set to $true, this parameter must be configured. You must manually configure this. The script will not auto-detect your IP (depending on your network adapters, it may not find the correct IP)."/>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="txtVMHostIPAddress" Grid.Row="2" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch"/>
|
||||
<!-- Row 3: Disk Size (GB) -->
|
||||
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
|
||||
<TextBlock Text="Disk Size (GB)" ToolTip="Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk."/>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="txtDiskSize" Grid.Row="3" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Text="50" ToolTip="Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk."/>
|
||||
<!-- Row 4: Memory (GB) -->
|
||||
<StackPanel Grid.Row="4" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
|
||||
<TextBlock Text="Memory (GB)" ToolTip="Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Default is 4GB."/>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="txtMemory" Grid.Row="4" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Text="4" ToolTip="Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Default is 4GB."/>
|
||||
<!-- Row 5: Processors -->
|
||||
<StackPanel Grid.Row="5" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
|
||||
<TextBlock Text="Processors" ToolTip="Number of virtual processors for the virtual machine. Recommended to use at least 4."/>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="txtProcessors" Grid.Row="5" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Text="4" ToolTip="Number of virtual processors for the virtual machine. Recommended to use at least 4."/>
|
||||
<!-- Row 6: VM Location -->
|
||||
<StackPanel Grid.Row="6" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
|
||||
<TextBlock Text="VM Location" ToolTip="Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to."/>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="txtVMLocation" Grid.Row="6" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Text="{x:Static sys:Environment.CurrentDirectory}" ToolTip="Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to."/>
|
||||
<!-- Row 7: VM Name Prefix -->
|
||||
<StackPanel Grid.Row="7" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
|
||||
<TextBlock Text="VM Name Prefix" ToolTip="Prefix for the VM Name. The default is _FFU."/>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="txtVMNamePrefix" Grid.Row="7" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Prefix for the VM Name. The default is _FFU."/>
|
||||
<!-- Row 8: Logical Sector Size -->
|
||||
<StackPanel Grid.Row="8" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
|
||||
<TextBlock Text="Logical Sector Size" ToolTip="Unit32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512."/>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="cmbLogicalSectorSize" Grid.Row="8" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Left" ToolTip="Unit32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512.">
|
||||
<ComboBoxItem Content="512" IsSelected="True"/>
|
||||
<ComboBoxItem Content="4096"/>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- TAB: Windows Settings -->
|
||||
<TabItem Header="Windows Settings" Padding="20">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- (0) ISO Path -->
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
|
||||
<TextBlock Text="Windows ISO Path" VerticalAlignment="Center" ToolTip="Path to the Windows 10/11 ISO file."/>
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="0" Grid.Column="1" Margin="5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="txtISOPath" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch"/>
|
||||
<Button x:Name="btnBrowseISO" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
<!-- (1) Windows Release -->
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
|
||||
<TextBlock Text="Windows Release" VerticalAlignment="Center" ToolTip="Integer value of 10 or 11. This is used to identify which release of Windows to download. Default is 11."/>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="cmbWindowsRelease" Grid.Row="1" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Integer value of 10 or 11. This is used to identify which release of Windows to download. Default is 11."/>
|
||||
<!-- (2) Windows Version -->
|
||||
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
|
||||
<TextBlock Text="Windows Version" VerticalAlignment="Center" ToolTip="String value of the Windows version to download. This is used to identify which version of Windows to download. Default is '24h2'."/>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="cmbWindowsVersion" Grid.Row="2" Grid.Column="1" Margin="5" Width="120" VerticalAlignment="Center" HorizontalAlignment="Left" IsEnabled="False" ToolTip="String value of the Windows version to download. This is used to identify which version of Windows to download. Default is '24h2'."/>
|
||||
<!-- (3) Windows Arch -->
|
||||
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
|
||||
<TextBlock Text="Windows Architecture" VerticalAlignment="Center" ToolTip="String value of 'x86' or 'x64'. This is used to identify which architecture of Windows to download. Default is 'x64'."/>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="cmbWindowsArch" Grid.Row="3" Grid.Column="1" Margin="5" Width="120" VerticalAlignment="Center" HorizontalAlignment="Left" ToolTip="String value of 'x86' or 'x64'. This is used to identify which architecture of Windows to download. Default is 'x64'."/>
|
||||
<!-- (4) Windows Lang -->
|
||||
<StackPanel x:Name="WindowsLangStackPanel" Grid.Row="4" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
|
||||
<TextBlock Text="Windows Language" VerticalAlignment="Center" ToolTip="String value in language-region format (e.g., 'en-us'). This is used to identify which language of media to download. Default is 'en-us'."/>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="cmbWindowsLang" Grid.Row="4" Grid.Column="1" Margin="5" Width="120" VerticalAlignment="Center" HorizontalAlignment="Left" ToolTip="String value in language-region format (e.g., 'en-us'). This is used to identify which language of media to download. Default is 'en-us'."/>
|
||||
<!-- (5) Windows SKU -->
|
||||
<StackPanel Grid.Row="5" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
|
||||
<TextBlock Text="Windows SKU" VerticalAlignment="Center" ToolTip="Edition of Windows 10/11 to be installed. Accepted values are: 'Home', 'Home N', 'Home Single Language', 'Education', 'Education N', 'Pro', 'Pro N', 'Pro Education', 'Pro Education N', 'Pro for Workstations', 'Pro N for Workstations', 'Enterprise', 'Enterprise N'."/>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="cmbWindowsSKU" Grid.Row="5" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Edition of Windows 10/11 to be installed. Accepted values are: 'Home', 'Home N', 'Home Single Language', 'Education', 'Education N', 'Pro', 'Pro N', 'Pro Education', 'Pro Education N', 'Pro for Workstations', 'Pro N for Workstations', 'Enterprise', 'Enterprise N'."/>
|
||||
<!-- (6) Media Type -->
|
||||
<StackPanel x:Name="MediaTypeStackPanel" Grid.Row="6" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
|
||||
<TextBlock Text="Media Type" VerticalAlignment="Center" ToolTip="String value of either 'business' or 'consumer'. This is used to identify which media type to download. Default is 'consumer'."/>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="cmbMediaType" Grid.Row="6" Grid.Column="1" Margin="5" Width="120" VerticalAlignment="Center" HorizontalAlignment="Left" ToolTip="String value of either 'business' or 'consumer'. This is used to identify which media type to download. Default is 'consumer'."/>
|
||||
<!-- (7) Product Key -->
|
||||
<StackPanel Grid.Row="7" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
|
||||
<TextBlock Text="Product Key" VerticalAlignment="Center" ToolTip="Product key for the Windows edition specified in WindowsSKU. This will overwrite whatever SKU is entered for WindowsSKU. Recommended to use if you want to use a MAK or KMS key to activate Enterprise or Education. If using VL media instead of consumer media, you'll want to enter a MAK or KMS key here."/>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="txtProductKey" Grid.Row="7" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch"/>
|
||||
<!-- (8) Expander for Optional Features -->
|
||||
<Expander x:Name="expOptionalFeatures" Style="{StaticResource MinimalExpanderNoHighlightStyle}" Grid.Row="8" Grid.Column="0" Grid.ColumnSpan="2" IsExpanded="False" Margin="0,5,0,0" ExpandDirection="Down">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" Margin="0,5,0,0">
|
||||
<StackPanel x:Name="stackFeaturesContainer" Margin="15,5">
|
||||
<TextBlock Text="Selected features (semicolon):" Margin="0,10,0,5" FontStyle="Italic"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Expander>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- TAB: Updates -->
|
||||
<TabItem Header="Updates" Padding="20">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="5">
|
||||
<CheckBox x:Name="chkUpdateLatestCU" Content="Update Latest Cumulative Update" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest cumulative update for Windows 10/11. Default is $false."/>
|
||||
<CheckBox x:Name="chkUpdateLatestNet" Content="Update .NET" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest .NET Framework for Windows 10/11. Default is $false."/>
|
||||
<CheckBox x:Name="chkUpdateLatestDefender" Content="Update Defender" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest Windows Defender definitions and Defender platform update. Default is $false."/>
|
||||
<CheckBox x:Name="chkUpdateEdge" Content="Update Edge" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest Microsoft Edge for Windows 10/11. Default is $false."/>
|
||||
<CheckBox x:Name="chkUpdateOneDrive" Content="Update OneDrive (Per-Machine)" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest OneDrive for Windows 10/11 and install it as a per-machine installation instead of per-user. Default is $false."/>
|
||||
<CheckBox x:Name="chkUpdateLatestMSRT" Content="Update Microsoft Software Removal Tool (MSRT)" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest Windows Malicious Software Removal Tool. Default is $false."/>
|
||||
<CheckBox x:Name="chkUpdateLatestMicrocode" Content="Update Latest Microcode (for LTSC/Server 2016/2019)" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will download and install the latest microcode updates for applicable Windows releases (e.g., Windows Server 2016/2019, Windows 10 LTSC 2016/2019) into the FFU."/>
|
||||
<CheckBox x:Name="chkUpdatePreviewCU" Content="Update Preview Cumulative Update" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest Preview cumulative update for Windows 10/11. Default is $false."/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- TAB: Applications -->
|
||||
<TabItem Header="Applications" Padding="20">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Regular Applications Section -->
|
||||
<StackPanel Grid.Row="0" Margin="0,0,0,10">
|
||||
<CheckBox x:Name="chkInstallApps" Content="Install Applications" Margin="5" ToolTip="Enable to install regular applications during the build process"/>
|
||||
|
||||
<!-- Application Path - Shows only when Install Applications is checked -->
|
||||
<StackPanel x:Name="applicationPathPanel" Visibility="Collapsed" Margin="25,5,5,10">
|
||||
<TextBlock Text="Application Path:" Margin="0,0,0,5" ToolTip="Path where applications will be downloaded and stored"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="txtApplicationPath" Grid.Column="0" VerticalAlignment="Center" ToolTip="Path where applications will be downloaded and stored"/>
|
||||
<Button x:Name="btnBrowseApplicationPath" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- AppList.json Path - Shows only when Install Applications is checked -->
|
||||
<StackPanel x:Name="appListJsonPathPanel" Visibility="Collapsed" Margin="25,5,5,10">
|
||||
<TextBlock Text="AppList.json Path:" Margin="0,0,0,5" ToolTip="Path to the AppList.json file"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="txtAppListJsonPath" Grid.Column="0" VerticalAlignment="Center" ToolTip="Path to the AppList.json file"/>
|
||||
<Button x:Name="btnBrowseAppListJsonPath" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Winget Applications Section - Indented under Install Applications -->
|
||||
<CheckBox x:Name="chkInstallWingetApps" Content="Install Winget Applications" Margin="5" ToolTip="Enable to install applications using Windows Package Manager (winget)"/>
|
||||
|
||||
<!-- Winget Status Panel -->
|
||||
<StackPanel x:Name="wingetPanel" Visibility="Collapsed" Margin="25,0,5,5">
|
||||
|
||||
<TextBlock Text="Winget Status" FontWeight="Bold" Margin="0,0,0,5"/>
|
||||
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="150"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Winget CLI Version Display -->
|
||||
<TextBlock Text="Winget Version:" Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" ToolTip="Current version of the Winget CLI installed on the system"/>
|
||||
<TextBlock x:Name="txtWingetVersion" Text="Not checked" Grid.Row="0" Grid.Column="1" VerticalAlignment="Center"/>
|
||||
|
||||
<!-- Winget PowerShell Module Version Display -->
|
||||
<TextBlock Text="Module Version:" Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" ToolTip="Current version of the Microsoft.WinGet.Client PowerShell module"/>
|
||||
<TextBlock x:Name="txtWingetModuleVersion" Text="Not checked" Grid.Row="1" Grid.Column="1" VerticalAlignment="Center"/>
|
||||
|
||||
<!-- Check/Install Button -->
|
||||
<Button x:Name="btnCheckWingetModule" Content="Check Winget Status" Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" Margin="0,10,0,0" Padding="10,5" HorizontalAlignment="Left" ToolTip="Check installation status and version of Winget CLI and PowerShell module. Will install or update if needed."/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Winget Search Panel -->
|
||||
<StackPanel x:Name="wingetSearchPanel" Visibility="Collapsed" Margin="25,10,5,20">
|
||||
|
||||
<TextBlock Text="Winget Search" FontWeight="Bold" Margin="0,0,0,5"/>
|
||||
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="100"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBox x:Name="txtWingetSearch" Grid.Column="0" Margin="0,0,10,0" Height="24" VerticalContentAlignment="Center" VerticalAlignment="Center" ToolTip="Enter an application name to search for"/>
|
||||
|
||||
<Button x:Name="btnWingetSearch" Grid.Column="1" Content="Search" Width="100" Height="24" ToolTip="Search for applications using Windows Package Manager"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Results ListView -->
|
||||
<ListView x:Name="lstWingetResults" Height="300" Margin="0,0,0,10" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto"/>
|
||||
|
||||
<!-- Save/Import/Clear Buttons -->
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
|
||||
<Button x:Name="btnSaveWingetList" Content="Save AppList.json" Padding="15,5" Margin="0,0,10,0" ToolTip="Save selected applications to a JSON file"/>
|
||||
<Button x:Name="btnImportWingetList" Content="Import AppList.json" Padding="15,5" Margin="0,0,10,0" ToolTip="Import applications from a JSON file"/>
|
||||
<Button x:Name="btnDownloadSelected" Content="Download Selected" Padding="15,5" Margin="0,0,10,0" ToolTip="Download all selected applications"/>
|
||||
<Button x:Name="btnClearWingetList" Content="Clear List" Padding="15,5" ToolTip="Clear all applications from the list"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<CheckBox x:Name="chkBringYourOwnApps" Content="Bring Your Own Applications" Margin="5" ToolTip="Enable to bring your own applications during the build process"/>
|
||||
|
||||
<!-- Application Information Section -->
|
||||
<StackPanel x:Name="byoApplicationPanel" Visibility="Collapsed" Margin="25,0,5,20">
|
||||
<TextBlock Text="Application Information" FontWeight="Bold" Margin="0,5,0,10"/>
|
||||
|
||||
<!-- Name -->
|
||||
<TextBlock Text="Name:" Margin="0,0,0,5"/>
|
||||
<TextBox x:Name="txtAppName" Margin="0,0,0,10" ToolTip="Enter the name of the application"/>
|
||||
|
||||
<!-- Command Line -->
|
||||
<TextBlock Text="Command Line:" Margin="0,0,0,5"/>
|
||||
<TextBox x:Name="txtAppCommandLine" Margin="0,0,0,10" ToolTip="Enter the full path to the command line to install the application. This should start with D:\Win32 for exe, cmd, etc types of deployments (e.g. D:\Win32\Mozilla FireFox\setup.exe). For MSI installs, use msiexec and then fill in the rest of the arguments in the arguments field."/>
|
||||
|
||||
<!-- Arguments -->
|
||||
<TextBlock Text="Arguments:" Margin="0,0,0,5"/>
|
||||
<TextBox x:Name="txtAppArguments" Margin="0,0,0,10" ToolTip="Enter the arguments for the command line. If the application is an msi, the command line should only contain msiexec and the rest of the command line arguments would go here (e.g. /i "D:\Win32\Mozilla firefox\setup.msi" /qn /norestart)."/>
|
||||
|
||||
<!-- Source -->
|
||||
<TextBlock Text="Source:" Margin="0,0,0,5"/>
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="txtAppSource" Grid.Column="0" VerticalAlignment="Center" ToolTip="Optional: Enter the source folder path of the application installation files. This is used to copy the files to the $AppsPath\Win32 directory by clicking the Copy Apps button"/>
|
||||
<Button x:Name="btnBrowseAppSource" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Additional Exit Codes -->
|
||||
<TextBlock Text="Additional Exit Codes:" Margin="0,0,0,5"/>
|
||||
<TextBox x:Name="txtAppAdditionalExitCodes" Margin="0,0,0,10" ToolTip="Enter a comma-separated list of additional success exit codes."/>
|
||||
|
||||
<!-- Ignore Non-Zero Exit Codes Checkbox -->
|
||||
<CheckBox x:Name="chkIgnoreExitCodes" Content="Ignore all non-zero exit codes" Margin="0,0,0,10" ToolTip="If checked, any non-zero exit code will be considered a success."/>
|
||||
|
||||
<!-- Add Application Button -->
|
||||
<Button x:Name="btnAddApplication" Content="Add Application" Width="120" HorizontalAlignment="Left" Margin="0,10,0,10" Padding="10,5" ToolTip="Add the application to the list"/>
|
||||
|
||||
<!-- Grid to hold ListView and Reorder Buttons -->
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Applications ListView -->
|
||||
<ListView x:Name="lstApplications" Grid.Column="0" Height="200">
|
||||
</ListView>
|
||||
|
||||
<!-- Reorder Buttons -->
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Margin="10,0,0,0">
|
||||
<Button x:Name="btnMoveTop" Content="⤒" Width="40" Height="40" FontSize="28" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Padding="0" Margin="0,0,0,5" ToolTip="Move selected application to the top" />
|
||||
<Button x:Name="btnMoveUp" Content="↑" Width="40" Height="40" FontSize="28" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Padding="0" Margin="0,0,0,5" ToolTip="Move selected application up" />
|
||||
<Button x:Name="btnMoveDown" Content="↓" Width="40" Height="40" FontSize="28" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Padding="0" Margin="0,0,0,5" ToolTip="Move selected application down" />
|
||||
<Button x:Name="btnMoveBottom" Content="⤓" Width="40" Height="40" FontSize="28" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Padding="0" ToolTip="Move selected application to the bottom" />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- Save/Import/Clear Buttons -->
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" Margin="0,0,0,10">
|
||||
<Button x:Name="btnSaveBYOApplications" Content="Save UserAppList.json" Margin="0,0,10,0" Padding="10,5" ToolTip="Save application list to JSON file"/>
|
||||
<Button x:Name="btnLoadBYOApplications" Content="Import UserAppList.json" Margin="0,0,10,0" Padding="10,5" ToolTip="Import application list from JSON file"/>
|
||||
<Button x:Name="btnEditApplication" Content="Edit Application" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Edit the selected application's details"/>
|
||||
<Button x:Name="btnCopyBYOApps" Content="Copy Apps" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Copy applications with a specified source path to the AppsPath\Win32 folder"/>
|
||||
<Button x:Name="btnRemoveSelectedBYOApps" Content="Remove Selected" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Remove selected applications from the list"/>
|
||||
<Button x:Name="btnClearBYOApplications" Content="Clear List" Padding="10,5" ToolTip="Clear all applications from the list"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- AppsScriptVariables Section -->
|
||||
<CheckBox x:Name="chkDefineAppsScriptVariables" Content="Define Apps Script Variables" Margin="5" ToolTip="Enable to define key-value pairs for Apps Script Variables"/>
|
||||
<StackPanel x:Name="appsScriptVariablesPanel" Visibility="Collapsed" Margin="25,0,5,20">
|
||||
<TextBlock Text="Apps Script Variables" FontWeight="Bold" Margin="0,5,0,10"/>
|
||||
<!-- Key Input -->
|
||||
<TextBlock Text="Key:" Margin="0,0,0,5"/>
|
||||
<TextBox x:Name="txtAppsScriptKey" Margin="0,0,0,10" ToolTip="Enter the variable key"/>
|
||||
|
||||
<!-- Value Input -->
|
||||
<TextBlock Text="Value:" Margin="0,0,0,5"/>
|
||||
<TextBox x:Name="txtAppsScriptValue" Margin="0,0,0,10" ToolTip="Enter the variable value"/>
|
||||
|
||||
<!-- Add Variable Button -->
|
||||
<Button x:Name="btnAddAppsScriptVariable" Content="Add Variable" Width="120" HorizontalAlignment="Left" Margin="0,10,0,10" Padding="10,5" ToolTip="Add the key-value pair to the list"/>
|
||||
|
||||
<!-- ListView for AppsScriptVariables -->
|
||||
<ListView x:Name="lstAppsScriptVariables" Height="150" Margin="0,0,0,10">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="Key" DisplayMemberBinding="{Binding Key}" Width="200"/>
|
||||
<GridViewColumn Header="Value" DisplayMemberBinding="{Binding Value}" Width="300"/>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
|
||||
<!-- Action Buttons for ListView -->
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
|
||||
<Button x:Name="btnRemoveSelectedAppsScriptVariables" Content="Remove Selected" Margin="0,0,10,0" Padding="10,5" ToolTip="Remove the selected variable(s) from the list"/>
|
||||
<Button x:Name="btnClearAppsScriptVariables" Content="Clear All" Padding="10,5" ToolTip="Clear all variables from the list"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<!-- End AppsScriptVariables Section -->
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- TAB: M365 Apps/Office -->
|
||||
<TabItem Header="M365 Apps/Office" Padding="20">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="250"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
|
||||
<CheckBox x:Name="chkInstallOffice" Content="Install Office" Margin="0,0,5,0" ToolTip="Install Microsoft Office if set to $true. The script will download the latest ODT and Office files in the $FFUDevelopmentPath\Apps\Office folder and install Office in the FFU via VM."/>
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="OfficePathStackPanel" Grid.Row="1" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
|
||||
<TextBlock Text="Office Path" ToolTip="Path to the Office directory that contains the DownloadFFU.xml and DeployFFU.xml files. This is where Office will be downloaded to from the ODT."/>
|
||||
</StackPanel>
|
||||
<Grid x:Name="OfficePathGrid" Grid.Row="1" Grid.Column="1" Margin="5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="txtOfficePath" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Path to the Office directory that contains the DownloadFFU.xml and DeployFFU.xml files. This is where Office will be downloaded to from the ODT."/>
|
||||
<Button x:Name="btnBrowseOfficePath" Grid.Column="1" Content="Browse..." Width="80" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
<StackPanel x:Name="CopyOfficeConfigXMLStackPanel" Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
|
||||
<CheckBox x:Name="chkCopyOfficeConfigXML" Content="Copy Office Configuration XML" Margin="0,0,5,0" ToolTip="Enable to copy an Office configuration XML file to the Office folder."/>
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="OfficeConfigurationXMLFileStackPanel" Grid.Row="3" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5" Visibility="Collapsed">
|
||||
<TextBlock Text="Office Configuration XML File" ToolTip="Specify the path to an Office configuration XML file. This file will be copied into the Office folder and used for the deployment of Office."/>
|
||||
</StackPanel>
|
||||
<Grid x:Name="OfficeConfigurationXMLFileGrid" Grid.Row="3" Grid.Column="1" Margin="5" Visibility="Collapsed">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="txtOfficeConfigXMLFilePath" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Specify the path to the Office configuration XML file."/>
|
||||
<Button x:Name="btnBrowseOfficeConfigXMLFile" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- TAB: Drivers -->
|
||||
<TabItem Header="Drivers" Padding="20">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Drivers Folder -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- PE Drivers Folder -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Drivers.json Path -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Download Drivers Checkbox -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Make Section (Indented) -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Get Models Button (Indented) -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Model Filter Section (Indented) -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Driver Models ListView (Indented) -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Driver Action Buttons (Indented) -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Install Drivers to FFU -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Copy Drivers to USB -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Compress Driver Model Folder to WIM -->
|
||||
<RowDefinition Height="*"/>
|
||||
<!-- Copy PE Drivers Checkbox & Spacer/Remaining -->
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<!-- Span full width for StackPanels -->
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Row 0: Drivers Folder -->
|
||||
<StackPanel Grid.Row="0" Margin="5">
|
||||
<TextBlock Text="Drivers Folder:" VerticalAlignment="Center" ToolTip="Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers." Margin="0,0,0,5"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="txtDriversFolder" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers."/>
|
||||
<Button x:Name="btnBrowseDriversFolder" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 1: PE Drivers Folder -->
|
||||
<StackPanel Grid.Row="1" Margin="5">
|
||||
<TextBlock Text="PE Drivers Folder:" VerticalAlignment="Center" ToolTip="Path to the PE drivers folder. Default is $FFUDevelopmentPath\PEDrivers." Margin="0,0,0,5"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="txtPEDriversFolder" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Path to the PE drivers folder. Default is $FFUDevelopmentPath\PEDrivers."/>
|
||||
<Button x:Name="btnBrowsePEDriversFolder" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 2: Drivers.json Path -->
|
||||
<StackPanel Grid.Row="2" Margin="5">
|
||||
<TextBlock Text="Drivers.json Path:" VerticalAlignment="Center" ToolTip="Path to the Drivers.json file. Default is $FFUDevelopmentPath\Drivers\Drivers.json." Margin="0,0,0,5"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="txtDriversJsonPath" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Path to the Drivers.json file. Default is $FFUDevelopmentPath\Drivers\Drivers.json."/>
|
||||
<Button x:Name="btnBrowseDriversJsonPath" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 3: Download Drivers Checkbox -->
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" Margin="5">
|
||||
<CheckBox x:Name="chkDownloadDrivers" Content="Download Drivers" Margin="0,0,5,0" ToolTip="Download the drivers and put them in the Drivers folder."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 4: Make Section (Indented) -->
|
||||
<StackPanel x:Name="spMakeSection" Grid.Row="4" Visibility="Collapsed" Margin="25,5,5,0">
|
||||
<TextBlock Text="Make:" Margin="0,0,0,5" ToolTip="Make of the device to download drivers. Accepted values are: 'Microsoft', 'Dell', 'HP', 'Lenovo'."/>
|
||||
<ComboBox x:Name="cmbMake" Margin="0,0,0,5" HorizontalAlignment="Left" Width="200"/>
|
||||
<!-- Model TextBox is removed from here, filtering will be done below -->
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 5: Get Models Button (Indented) -->
|
||||
<Button x:Name="btnGetModels" Grid.Row="5" Content="Get Models" Width="150" Margin="25,5,5,10" HorizontalAlignment="Left" ToolTip="Retrieve available models for the selected Make." Visibility="Collapsed" Padding="10,5"/>
|
||||
|
||||
<!-- Row 6: Model Filter Section (Indented) -->
|
||||
<StackPanel x:Name="spModelFilterSection" Grid.Row="6" Visibility="Collapsed" Margin="25,5,5,0">
|
||||
<TextBlock Text="Model Filter" FontWeight="Bold" Margin="0,0,0,5"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<!-- Removed Search button column -->
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="txtModelFilter" Grid.Column="0" Margin="0,0,0,10" Height="24" VerticalContentAlignment="Center" ToolTip="Type to filter models in the list below"/>
|
||||
<!-- Search button removed, filtering is real-time -->
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 7: Driver Models ListView (Indented) -->
|
||||
<ListView x:Name="lstDriverModels" Grid.Row="7" Margin="25,0,5,5" Height="300" Visibility="Collapsed" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto">
|
||||
</ListView>
|
||||
|
||||
<!-- Row 8: Driver Action Buttons (Indented) -->
|
||||
<StackPanel x:Name="spDriverActionButtons" Grid.Row="8" Orientation="Horizontal" HorizontalAlignment="Left" Margin="25,5,5,10" Visibility="Collapsed">
|
||||
<Button x:Name="btnSaveDriversJson" Content="Save Drivers.json" Padding="15,5" Margin="0,0,10,0" ToolTip="Save selected drivers to a JSON file (Not Implemented)"/>
|
||||
<Button x:Name="btnImportDriversJson" Content="Import Drivers.json" Padding="15,5" Margin="0,0,10,0" ToolTip="Import drivers from a JSON file (Not Implemented)"/>
|
||||
<Button x:Name="btnDownloadSelectedDrivers" Content="Download Selected" Padding="15,5" Margin="0,0,10,0" ToolTip="Download all selected drivers"/>
|
||||
<Button x:Name="btnClearDriverList" Content="Clear List" Padding="15,5" ToolTip="Clear all drivers from the list (Not Implemented)"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 9: Install Drivers to FFU -->
|
||||
<StackPanel Grid.Row="9" Orientation="Horizontal" Margin="5">
|
||||
<CheckBox x:Name="chkInstallDrivers" Content="Install Drivers to FFU" Margin="0,0,5,0" ToolTip="Install device drivers from the specified $FFUDevelopmentPath\Drivers folder if set to $true. Download the drivers and put them in the Drivers folder. The script will recurse the drivers folder and add the drivers to the FFU."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 10: Copy Drivers to USB -->
|
||||
<StackPanel Grid.Row="10" Orientation="Horizontal" Margin="5">
|
||||
<CheckBox x:Name="chkCopyDrivers" Content="Copy Drivers to USB drive" Margin="0,0,5,0" ToolTip="When set to $true, will copy the drivers from the $FFUDevelopmentPath\Drivers folder to the Drivers folder on the deploy partition of the USB drive. Default is $false."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 11: Compress Driver Model Folder to WIM -->
|
||||
<StackPanel Grid.Row="11" Orientation="Horizontal" Margin="5">
|
||||
<CheckBox x:Name="chkCompressDriversToWIM" Content="Compress Driver Model Folder to WIM" Margin="0,0,5,0" ToolTip="When set to $true, will compress each downloaded driver model folder into a separate WIM file within the Drivers folder. This is useful with Copy Drivers to USB drive."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 12: PE Driver Options (UseDriversAsPEDrivers is a dependent sub-option) -->
|
||||
<StackPanel Grid.Row="12" Margin="5">
|
||||
<CheckBox x:Name="chkCopyPEDrivers" Content="Copy PE Drivers" Margin="0,0,0,5" ToolTip="When set to $true, will copy the drivers from the $FFUDevelopmentPath\PEDrivers folder to the WinPE deployment media. Default is $false."/>
|
||||
<CheckBox x:Name="chkUseDriversAsPEDrivers" Content="Use Drivers Folder as PE Drivers Source" Margin="25,0,0,0" Visibility="Collapsed" ToolTip="When set to $true (and Copy PE Drivers is also checked), bypasses the PE Drivers Folder path and instead scans the Drivers folder to gather only required WinPE drivers. Hidden unless Copy PE Drivers is checked."/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- TAB: Build -->
|
||||
<TabItem Header="Build" Padding="20">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<Grid Margin="10">
|
||||
<!-- Define 12 rows for the Build tab -->
|
||||
<Grid.RowDefinitions>
|
||||
<!-- Row 0: Header -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 1: FFU Development Path -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 2: Custom FFU Name Template -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 3: FFU Capture Location -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 4: Share Name -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 5: Username -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 6: Threads -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 7: BITS Priority -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 8: General Build Options Header -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 9: General Build Options Checkboxes -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 10: Build USB Drive Section -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 11: Post-Build Cleanup -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- Row 0: Header -->
|
||||
<TextBlock Grid.Row="0" Text="FFU Build Settings" FontWeight="Bold" FontSize="16" Margin="0,0,0,10"/>
|
||||
<!-- Row 1: FFU Development Path -->
|
||||
<Grid Grid.Row="1" Margin="0,5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="FFU Development Path" VerticalAlignment="Center" ToolTip="Path to the FFU development folder."/>
|
||||
<TextBox x:Name="txtFFUDevPath" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Path to the FFU development folder."/>
|
||||
<Button x:Name="btnBrowseFFUDevPath" Grid.Column="2" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
<!-- Row 2: Custom FFU Name Template -->
|
||||
<Grid Grid.Row="2" Margin="0,5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="Custom FFU Name Template" VerticalAlignment="Center" ToolTip="Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}."/>
|
||||
<TextBox x:Name="txtCustomFFUNameTemplate" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}."/>
|
||||
</Grid>
|
||||
<!-- Row 3: FFU Capture Location -->
|
||||
<Grid Grid.Row="3" Margin="0,5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="FFU Capture Location" VerticalAlignment="Center" ToolTip="Path to the folder where the captured FFU will be stored."/>
|
||||
<TextBox x:Name="txtFFUCaptureLocation" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Path to the folder where the captured FFU will be stored."/>
|
||||
<Button x:Name="btnBrowseFFUCaptureLocation" Grid.Column="2" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
<!-- Row 4: Share Name -->
|
||||
<Grid Grid.Row="4" Margin="0,5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="Share Name" VerticalAlignment="Center" ToolTip="Name of the shared folder for FFU capture. The default is FFUCaptureShare. This share will be created with rights for the user account. When finished, the share will be removed."/>
|
||||
<TextBox x:Name="txtShareName" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Name of the shared folder for FFU capture. The default is FFUCaptureShare. This share will be created with rights for the user account. When finished, the share will be removed."/>
|
||||
</Grid>
|
||||
<!-- Row 5: Username -->
|
||||
<Grid Grid.Row="5" Margin="0,5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="Username" VerticalAlignment="Center" ToolTip="Username for accessing the shared folder. The default is ffu_user. The script will auto-create the account and password. When finished, it will remove the account."/>
|
||||
<TextBox x:Name="txtUsername" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Username for accessing the shared folder. The default is ffu_user. The script will auto-create the account and password. When finished, it will remove the account."/>
|
||||
</Grid>
|
||||
<!-- Row 6: Threads -->
|
||||
<Grid Grid.Row="6" Margin="0,5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="Threads" VerticalAlignment="Center" ToolTip="Controls the number of parallel threads used by ForEach-Object -Parallel and sets the value of the -ThrottleLimit parameter. Default is 5. Used in Winget, Application Copy, and driver downloads"/>
|
||||
<TextBox x:Name="txtThreads" Grid.Column="1" Margin="5" VerticalAlignment="Center" Width="50" HorizontalAlignment="Left" Text="5" ToolTip="Controls the number of parallel threads used by ForEach-Object -Parallel and sets the value of the -ThrottleLimit parameter. Default is 5. Used in Winget, Application Copy, and driver downloads"/>
|
||||
</Grid>
|
||||
<!-- Row 7: BITS Priority -->
|
||||
<Grid Grid.Row="7" Margin="0,5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="BITS Priority" VerticalAlignment="Center" ToolTip="Controls the BITS download priority used by the UI and BuildFFUVM.ps1. Switch to Foreground to maximize download speed if needed."/>
|
||||
<ComboBox x:Name="cmbBitsPriority" Grid.Column="1" Margin="5" VerticalAlignment="Center" Width="150" HorizontalAlignment="Left" ToolTip="Controls the BITS download priority used by the UI and BuildFFUVM.ps1. Switch to Foreground to maximize download speed if needed.">
|
||||
<sys:String>Foreground</sys:String>
|
||||
<sys:String>High</sys:String>
|
||||
<sys:String>Normal</sys:String>
|
||||
<sys:String>Low</sys:String>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
<!-- Row 8: General Build Options Header -->
|
||||
<TextBlock Grid.Row="8" Text="General Build Options" FontWeight="Bold" FontSize="16" Margin="0,10,0,5"/>
|
||||
|
||||
<!-- Row 9: General Build Options Checkboxes -->
|
||||
<WrapPanel Grid.Row="9" Margin="0,5">
|
||||
<CheckBox x:Name="chkBuildUSBDriveEnable" Content="Build USB Drive" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will partition and format a USB drive and copy the captured FFU to the drive."/>
|
||||
<CheckBox x:Name="chkCompactOS" Content="Compact OS" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will compact the OS when building the FFU."/>
|
||||
<CheckBox x:Name="chkUpdateADK" Content="Update ADK" Margin="5" VerticalAlignment="Center" Tag="When set to $true, the script will check for and install/update to the latest Windows ADK and WinPE add-on."/>
|
||||
<CheckBox x:Name="chkOptimize" Content="Optimize" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will optimize the OS when building the FFU."/>
|
||||
<CheckBox x:Name="chkAllowVHDXCaching" Content="Allow VHDX Caching" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will cache the VHDX file to cache folder and create a config json file to track Windows build information."/>
|
||||
<CheckBox x:Name="chkCreateCaptureMedia" Content="Create Capture Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, this will create WinPE capture media for use when InstallApps is set to $true."/>
|
||||
<CheckBox x:Name="chkCreateDeploymentMedia" Content="Create Deployment Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, this will create WinPE deployment media for use when deploying to a physical device."/>
|
||||
<CheckBox x:Name="chkInjectUnattend" Content="Inject Unattend.xml" Margin="5" VerticalAlignment="Center" Tag="When set to $true and Install Apps is enabled, copies unattend_[arch].xml from $FFUDevelopmentPath\unattend into Apps\Unattend\Unattend.xml to be used by sysprep."/>
|
||||
<CheckBox x:Name="chkVerbose" Content="Verbose" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will enable write-verbose output to the console for the build script."/>
|
||||
</WrapPanel>
|
||||
|
||||
<!-- Row 10: Build USB Drive Section -->
|
||||
<StackPanel Grid.Row="10" Margin="0,10,0,5" x:Name="usbDriveSection" Visibility="Collapsed">
|
||||
<TextBlock Text="Build USB Drive Settings" FontWeight="Bold" FontSize="16" Margin="0,0,0,10"/>
|
||||
<StackPanel Margin="5,0,0,10">
|
||||
<CheckBox x:Name="chkAllowExternalHardDiskMedia" Content="Allow External Hard Disk Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will allow the use of external hard disk media."/>
|
||||
<CheckBox x:Name="chkPromptExternalHardDiskMedia" Content="Prompt for External Hard Disk Media" Margin="5,5,5,5" IsEnabled="False" VerticalAlignment="Center" Tag="When set to $true, will prompt before using external hard disk media."/>
|
||||
<CheckBox x:Name="chkSelectSpecificUSBDrives" Content="Select Specific USB Drives" Margin="5" VerticalAlignment="Center" Tag="Enable to select specific USB drives for building"/>
|
||||
<!-- Added Missing Checkboxes -->
|
||||
<CheckBox x:Name="chkCopyAutopilot" Content="Copy Autopilot Profile" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the Autopilot profile to the USB drive."/>
|
||||
<CheckBox x:Name="chkCopyUnattend" Content="Copy Unattend.xml" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the Unattend.xml file to the USB drive."/>
|
||||
<CheckBox x:Name="chkCopyPPKG" Content="Copy Provisioning Package" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the provisioning package to the USB drive."/>
|
||||
<CheckBox x:Name="chkCopyAdditionalFFUFiles" Content="Copy Additional FFU Files" Margin="5" VerticalAlignment="Center" Tag="When set to $true, allows selecting existing FFU files in the capture folder to also copy to the USB drive."/>
|
||||
|
||||
<!-- Additional FFU Selection Section -->
|
||||
<Grid x:Name="additionalFFUPanel" Margin="5,0,0,10" Visibility="Collapsed">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<!-- Header row -->
|
||||
<DockPanel Grid.Row="0" Margin="0,5" LastChildFill="False">
|
||||
<TextBlock Text="Additional FFU Files" DockPanel.Dock="Left" FontWeight="Bold" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||
<Button x:Name="btnRefreshAdditionalFFUs" Content="Refresh" DockPanel.Dock="Left" Padding="10,5" ToolTip="Refresh the list of FFU files from the capture folder"/>
|
||||
</DockPanel>
|
||||
<!-- ListView row -->
|
||||
<ListView x:Name="lstAdditionalFFUs" Grid.Row="1" Margin="0,5" Height="150">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="FFU Name" DisplayMemberBinding="{Binding Name}" Width="300"/>
|
||||
<GridViewColumn Header="Last Modified" DisplayMemberBinding="{Binding LastModified}" Width="200"/>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
</Grid>
|
||||
|
||||
<!-- Max USB Drives -->
|
||||
<StackPanel Orientation="Horizontal" Margin="5">
|
||||
<TextBlock Text="Max USB Drives" VerticalAlignment="Center" ToolTip="Maximum number of USB drives to build at once. Enter 0 to process all discovered (or all selected) drives."/>
|
||||
<TextBox x:Name="txtMaxUSBDrives" Width="50" Margin="10,0,0,0" Text="5" VerticalAlignment="Center" ToolTip="Maximum number of USB drives to build at once. Enter 0 to process all discovered (or all selected) drives."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- USB Drive Selection Section -->
|
||||
<Grid x:Name="usbDriveSelectionPanel" Margin="5,0,0,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<!-- Button and Select All row -->
|
||||
<DockPanel Grid.Row="0" Margin="0,5" LastChildFill="False">
|
||||
<Button x:Name="btnCheckUSBDrives" Content="Check USB drives" DockPanel.Dock="Left" Padding="10,5"/>
|
||||
</DockPanel>
|
||||
<!-- ListView row -->
|
||||
<ListView x:Name="lstUSBDrives" Grid.Row="1" Margin="0,5" Height="150">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
|
||||
<GridViewColumn Header="Model" DisplayMemberBinding="{Binding Model}" Width="200"/>
|
||||
<GridViewColumn Header="Unique ID" DisplayMemberBinding="{Binding UniqueId}" Width="300"/>
|
||||
<GridViewColumn Header="Size (GB)" DisplayMemberBinding="{Binding Size}" Width="80"/>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 11: Post-Build Cleanup -->
|
||||
<StackPanel Grid.Row="11" Margin="0,10,0,5">
|
||||
<TextBlock Text="Post-Build Cleanup" FontWeight="Bold" FontSize="16" Margin="0,0,0,5"/>
|
||||
<CheckBox x:Name="chkCleanupAppsISO" Content="Cleanup Apps ISO" Margin="5" VerticalAlignment="Center" Tag="Remove Apps ISO after FFU capture."/>
|
||||
<CheckBox x:Name="chkCleanupCaptureISO" Content="Cleanup Capture ISO" Margin="5" VerticalAlignment="Center" Tag="Remove WinPE capture ISO after FFU capture."/>
|
||||
<CheckBox x:Name="chkCleanupDeployISO" Content="Cleanup Deploy ISO" Margin="5" VerticalAlignment="Center" Tag="Remove WinPE deployment ISO after FFU capture."/>
|
||||
<CheckBox x:Name="chkCleanupDrivers" Content="Cleanup Drivers" Margin="5" VerticalAlignment="Center" Tag="Remove drivers folder after FFU capture."/>
|
||||
<CheckBox x:Name="chkRemoveFFU" Content="Remove FFU" Margin="5" VerticalAlignment="Center" Tag="Remove FFU after copying to USB drive."/>
|
||||
<CheckBox x:Name="chkRemoveApps" Content="Remove Apps Folder Content" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will remove the application content in the Apps folder after the FFU has been captured."/>
|
||||
<CheckBox x:Name="chkRemoveUpdates" Content="Remove Downloaded Update Files" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will remove downloaded CU, .NET, MSRT, Defender, Edge, and OneDrive files after being applied/included."/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- TAB: Monitor -->
|
||||
<TabItem Header="Monitor" x:Name="MonitorTab" Padding="20">
|
||||
<Grid Margin="10">
|
||||
<ListBox x:Name="lstLogOutput" SelectionMode="Extended" VirtualizingStackPanel.IsVirtualizing="True" VirtualizingStackPanel.VirtualizationMode="Recycling" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto" HorizontalContentAlignment="Stretch"/>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<ProgressBar x:Name="progressBar" Height="20" Margin="0,10,0,0" Grid.Row="1" Visibility="Collapsed"/>
|
||||
<!-- Status Text -->
|
||||
<TextBlock x:Name="txtStatus" Grid.Row="2" Margin="0,5,0,0"/>
|
||||
<!-- Buttons (Build Config File / Load Config File / Build FFU) -->
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,20,20">
|
||||
<Button x:Name="btnRestoreDefaults" Content="Restore Defaults" Width="150" Margin="0,0,10,0" FontSize="14" Padding="10,5"/>
|
||||
<Button x:Name="btnBuildConfig" Content="Save Config File" Width="150" Margin="0,0,10,0" FontSize="14" Padding="10,5"/>
|
||||
<Button x:Name="btnLoadConfig" Content="Load Config File" Width="150" Margin="0,0,10,0" FontSize="14" Padding="10,5"/>
|
||||
<Button x:Name="btnRun" Content="Build FFU" Width="120" FontSize="14" Padding="10,5"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,209 @@
|
||||
param (
|
||||
[string]$FFUDevelopmentPath = $PSScriptRoot,
|
||||
[string]$adkPath = 'C:\Program Files (x86)\Windows Kits\10\',
|
||||
[string]$WindowsArch = 'x64',
|
||||
[bool]$CopyPEDrivers = $false,
|
||||
[string]$CaptureISO = "$PSScriptRoot\WinPE_FFU_Capture_x64.iso",
|
||||
[string]$DeployISO = "$PSScriptRoot\WinPE_FFU_Deploy_x64.iso",
|
||||
[string]$LogFile = "$PSScriptRoot\Create-PEMedia.log",
|
||||
[bool]$Capture,
|
||||
[bool]$Deploy = $true
|
||||
)
|
||||
|
||||
function WriteLog($LogText) {
|
||||
Add-Content -path $LogFile -value "$((Get-Date).ToString()) $LogText" -Force -ErrorAction SilentlyContinue
|
||||
Write-Verbose $LogText
|
||||
}
|
||||
|
||||
function Invoke-Process {
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param
|
||||
(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$ArgumentList
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
try {
|
||||
$stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
||||
$stdErrTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
||||
|
||||
$startProcessParams = @{
|
||||
FilePath = $FilePath
|
||||
ArgumentList = $ArgumentList
|
||||
RedirectStandardError = $stdErrTempFile
|
||||
RedirectStandardOutput = $stdOutTempFile
|
||||
Wait = $true;
|
||||
PassThru = $true;
|
||||
NoNewWindow = $true;
|
||||
}
|
||||
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
|
||||
$cmd = Start-Process @startProcessParams
|
||||
$cmdOutput = Get-Content -Path $stdOutTempFile -Raw
|
||||
$cmdError = Get-Content -Path $stdErrTempFile -Raw
|
||||
if ($cmd.ExitCode -ne 0) {
|
||||
if ($cmdError) {
|
||||
throw $cmdError.Trim()
|
||||
}
|
||||
if ($cmdOutput) {
|
||||
throw $cmdOutput.Trim()
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
|
||||
WriteLog $cmdOutput
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
#$PSCmdlet.ThrowTerminatingError($_)
|
||||
WriteLog $_
|
||||
Write-Host "Script failed - $Logfile for more info"
|
||||
throw $_
|
||||
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function New-PEMedia {
|
||||
param (
|
||||
[Parameter()]
|
||||
[bool]$Capture,
|
||||
[Parameter()]
|
||||
[bool]$Deploy
|
||||
)
|
||||
#Need to use the Demployment and Imaging tools environment to create winPE media
|
||||
$DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat"
|
||||
$WinPEFFUPath = "$FFUDevelopmentPath\WinPE"
|
||||
|
||||
If (Test-path -Path "$WinPEFFUPath") {
|
||||
WriteLog "Removing old WinPE path at $WinPEFFUPath"
|
||||
Remove-Item -Path "$WinPEFFUPath" -Recurse -Force | out-null
|
||||
}
|
||||
|
||||
WriteLog "Copying WinPE files to $WinPEFFUPath"
|
||||
if($WindowsArch -eq 'x64') {
|
||||
& cmd /c """$DandIEnv"" && copype amd64 $WinPEFFUPath" | Out-Null
|
||||
}
|
||||
elseif($WindowsArch -eq 'arm64') {
|
||||
& cmd /c """$DandIEnv"" && copype arm64 $WinPEFFUPath" | Out-Null
|
||||
}
|
||||
#Invoke-Process cmd "/c ""$DandIEnv"" && copype amd64 $WinPEFFUPath"
|
||||
WriteLog 'Files copied successfully'
|
||||
|
||||
WriteLog 'Mounting WinPE media to add WinPE optional components'
|
||||
Mount-WindowsImage -ImagePath "$WinPEFFUPath\media\sources\boot.wim" -Index 1 -Path "$WinPEFFUPath\mount" | Out-Null
|
||||
WriteLog 'Mounting complete'
|
||||
|
||||
$Packages = @(
|
||||
"WinPE-WMI.cab",
|
||||
"en-us\WinPE-WMI_en-us.cab",
|
||||
"WinPE-NetFX.cab",
|
||||
"en-us\WinPE-NetFX_en-us.cab",
|
||||
"WinPE-Scripting.cab",
|
||||
"en-us\WinPE-Scripting_en-us.cab",
|
||||
"WinPE-PowerShell.cab",
|
||||
"en-us\WinPE-PowerShell_en-us.cab",
|
||||
"WinPE-StorageWMI.cab",
|
||||
"en-us\WinPE-StorageWMI_en-us.cab",
|
||||
"WinPE-DismCmdlets.cab",
|
||||
"en-us\WinPE-DismCmdlets_en-us.cab"
|
||||
)
|
||||
|
||||
if($WindowsArch -eq 'x64'){
|
||||
$PackagePathBase = "$adkPath`Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs\"
|
||||
}
|
||||
elseif($WindowsArch -eq 'arm64'){
|
||||
$PackagePathBase = "$adkPath`Assessment and Deployment Kit\Windows Preinstallation Environment\arm64\WinPE_OCs\"
|
||||
}
|
||||
|
||||
|
||||
foreach ($Package in $Packages) {
|
||||
$PackagePath = Join-Path $PackagePathBase $Package
|
||||
WriteLog "Adding Package $Package"
|
||||
Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
|
||||
WriteLog "Adding package complete"
|
||||
}
|
||||
If ($Capture) {
|
||||
WriteLog "Copying $FFUDevelopmentPath\WinPECaptureFFUFiles\* to WinPE capture media"
|
||||
Copy-Item -Path "$FFUDevelopmentPath\WinPECaptureFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | out-null
|
||||
WriteLog "Copy complete"
|
||||
#Remove Bootfix.bin - for BIOS systems, shouldn't be needed, but doesn't hurt to remove for our purposes
|
||||
#Remove-Item -Path "$WinPEFFUPath\media\boot\bootfix.bin" -Force | Out-null
|
||||
# $WinPEISOName = 'WinPE_FFU_Capture.iso'
|
||||
$WinPEISOFile = $CaptureISO
|
||||
# $Capture = $false
|
||||
}
|
||||
If ($Deploy) {
|
||||
WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
|
||||
Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
|
||||
WriteLog 'Copy complete'
|
||||
#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'
|
||||
$WinPEISOFile = $DeployISO
|
||||
|
||||
# $Deploy = $false
|
||||
}
|
||||
WriteLog 'Dismounting WinPE media'
|
||||
Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null
|
||||
WriteLog 'Dismount complete'
|
||||
#Make ISO
|
||||
if ($WindowsArch -eq 'x64') {
|
||||
$OSCDIMGPath = "$adkPath`Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg"
|
||||
}
|
||||
elseif ($WindowsArch -eq 'arm64') {
|
||||
$OSCDIMGPath = "$adkPath`Assessment and Deployment Kit\Deployment Tools\arm64\Oscdimg"
|
||||
}
|
||||
$OSCDIMG = "$OSCDIMGPath\oscdimg.exe"
|
||||
WriteLog "Creating WinPE ISO at $WinPEISOFile"
|
||||
# & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null
|
||||
if($WindowsArch -eq 'x64'){
|
||||
if($Capture){
|
||||
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
|
||||
}
|
||||
if($Deploy){
|
||||
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
|
||||
}
|
||||
}
|
||||
elseif($WindowsArch -eq 'arm64'){
|
||||
if($Capture){
|
||||
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
|
||||
}
|
||||
if($Deploy){
|
||||
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
|
||||
}
|
||||
|
||||
}
|
||||
Invoke-Process $OSCDIMG $OSCDIMGArgs
|
||||
WriteLog "ISO created successfully"
|
||||
WriteLog "Cleaning up $WinPEFFUPath"
|
||||
Remove-Item -Path "$WinPEFFUPath" -Recurse -Force
|
||||
WriteLog 'Cleanup complete'
|
||||
}
|
||||
if($Capture){
|
||||
New-PEMedia -Capture $Capture
|
||||
}
|
||||
if($Deploy){
|
||||
New-PEMedia -Deploy $Deploy
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Start & Initialization"
|
||||
A[Start] --> B{Load ConfigFile?};
|
||||
B --> C[Process Parameters];
|
||||
C --> D{Validate Parameters};
|
||||
D --> E{"dirty.txt exists?"};
|
||||
E -- Yes --> F[Run Cleanup Routine];
|
||||
F --> G["Create new dirty.txt"];
|
||||
E -- No --> G;
|
||||
end
|
||||
|
||||
G --> H{"-InstallDrivers or -CopyDrivers?"};
|
||||
|
||||
subgraph "Pre-Build Preparations"
|
||||
H -- Yes --> I{Driver Source?};
|
||||
I -- "-DriversJsonPath" --> J[Download Drivers via JSON in Parallel];
|
||||
I -- "-Make and -Model" --> K[Download Drivers for specific Make/Model];
|
||||
I -- "Local Folder" --> L[Use Existing Drivers in Drivers Folder];
|
||||
|
||||
subgraph "ADK & WinPE"
|
||||
M[Check for ADK & WinPE Add-on];
|
||||
M --> N{Latest Version Installed?};
|
||||
N -- No --> O[Uninstall Old & Install Latest ADK/WinPE];
|
||||
N -- Yes --> P[Get ADK Path];
|
||||
O --> P;
|
||||
end
|
||||
|
||||
Q{"-InstallApps?"};
|
||||
subgraph "Application & In-VM Content Preparation"
|
||||
direction LR
|
||||
R[Check for existing downloaded apps];
|
||||
R --> S{Download missing WinGet apps};
|
||||
S --> T{"-InstallOffice?"};
|
||||
T -- Yes --> U[Download ODT & Office content];
|
||||
T -- No --> V[Continue];
|
||||
U --> V;
|
||||
V --> W["Download in-VM updates: Defender, MSRT, etc."];
|
||||
W --> X["Create Apps.iso"];
|
||||
end
|
||||
end
|
||||
|
||||
J --> M;
|
||||
K --> M;
|
||||
L --> M;
|
||||
H -- No --> M;
|
||||
P --> Q;
|
||||
Q -- Yes --> R;
|
||||
X --> Y;
|
||||
Q -- No --> Y{"-AllowVHDXCaching?"};
|
||||
|
||||
subgraph "VHDX Management"
|
||||
Y -- Yes --> Z[Check for matching cached VHDX];
|
||||
Z --> AA{Cache Hit?};
|
||||
AA -- Yes --> AB[Use Cached VHDX];
|
||||
AA -- No --> AC[Create New VHDX];
|
||||
Y -- No --> AC;
|
||||
|
||||
subgraph "VHDX Creation Workflow"
|
||||
AC --> AD{ISOPath provided?};
|
||||
AD -- No --> AE[Download Windows ESD media];
|
||||
AD -- Yes --> AF[Use provided ISO];
|
||||
AE --> AG[Create & Partition VHDX];
|
||||
AF --> AG;
|
||||
AG --> AH[Apply Base Windows Image to VHDX];
|
||||
AH --> AI{"Updates specified? (CU, dotNET, etc.)"};
|
||||
AI -- Yes --> AJ[Apply Updates to Offline VHDX];
|
||||
AJ --> AK[Run Component Cleanup];
|
||||
AI -- No --> AK;
|
||||
AK --> AL{"Optional Features specified?"};
|
||||
AL -- Yes --> AM[Enable Optional Features];
|
||||
AL -- No --> AN[Finalize VHDX Setup];
|
||||
AM --> AN;
|
||||
AN --> AO{"-AllowVHDXCaching?"};
|
||||
AO -- Yes --> AP[Optimize and Copy VHDX to Cache];
|
||||
AO -- No --> AQ[Continue];
|
||||
AP --> AQ;
|
||||
end
|
||||
end
|
||||
|
||||
AB --> BA;
|
||||
AQ --> BA{"-InstallApps?"};
|
||||
|
||||
subgraph "FFU Creation"
|
||||
subgraph "VM-Based Capture (-InstallApps)"
|
||||
direction LR
|
||||
BB[Create Hyper-V VM from VHDX];
|
||||
BB --> BC["Create WinPE Capture Media iso"];
|
||||
BC --> BD[Configure network share for capture];
|
||||
BD --> BE["Start VM: Boots to Audit Mode"];
|
||||
BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down];
|
||||
BF --> BG[VM reboots from Capture Media];
|
||||
BG --> BH["CaptureFFU.ps1 runs, saves FFU to share, shuts down"];
|
||||
end
|
||||
|
||||
subgraph "Direct VHDX Capture"
|
||||
BI[Capture FFU directly from VHDX using DISM];
|
||||
end
|
||||
end
|
||||
|
||||
BA -- Yes --> BB;
|
||||
BA -- No --> BI;
|
||||
|
||||
subgraph "Post-Processing & Media Creation"
|
||||
BK{"-InstallDrivers?"};
|
||||
BK -- Yes --> BL[Mount FFU & Inject Drivers];
|
||||
BK -- No --> BM[Continue];
|
||||
BL --> BM;
|
||||
BM --> BN{"-Optimize?"};
|
||||
BN -- Yes --> BO[Optimize FFU using DISM];
|
||||
BN -- No --> BP[Continue];
|
||||
BO --> BP;
|
||||
BP --> BQ{"-BuildUSBDrive?"};
|
||||
BQ -- Yes --> BR[Create WinPE Deployment Media];
|
||||
BR --> BS["Partition USB Drive(s)"];
|
||||
BS --> BT[Copy FFU, Deploy scripts & other assets to USB];
|
||||
BQ -- No --> BU[Continue];
|
||||
BT --> BU;
|
||||
end
|
||||
|
||||
BH --> BK;
|
||||
BI --> BK;
|
||||
|
||||
subgraph "Final Cleanup"
|
||||
BU --> BV[Cleanup VM, VHDX, temp files];
|
||||
BV --> BW["Remove dirty.txt"];
|
||||
BW --> BX[End];
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
# Provides shared cleanup functionality for both UI and build script.
|
||||
|
||||
function Invoke-FFUPostBuildCleanup {
|
||||
param(
|
||||
[string]$RootPath,
|
||||
[string]$AppsPath,
|
||||
[string]$DriversPath,
|
||||
[string]$FFUCapturePath,
|
||||
[string]$CaptureISOPath,
|
||||
[string]$DeployISOPath,
|
||||
[string]$AppsISOPath,
|
||||
[string]$KBPath,
|
||||
[bool]$RemoveCaptureISO = $false,
|
||||
[bool]$RemoveDeployISO = $false,
|
||||
[bool]$RemoveAppsISO = $false,
|
||||
[bool]$RemoveDrivers = $false,
|
||||
[bool]$RemoveFFU = $false,
|
||||
[bool]$RemoveApps = $false,
|
||||
[bool]$RemoveUpdates = $false
|
||||
)
|
||||
$originalProgressPreference = $ProgressPreference
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
try {
|
||||
WriteLog "CommonCleanup: Starting cleanup (CaptureISO=$RemoveCaptureISO DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates KBPath=$KBPath)."
|
||||
|
||||
# Primary ISO paths (new naming/location)
|
||||
if ($RemoveCaptureISO -and -not [string]::IsNullOrWhiteSpace($CaptureISOPath) -and (Test-Path -LiteralPath $CaptureISOPath)) {
|
||||
WriteLog "CommonCleanup: Removing $CaptureISOPath"
|
||||
try { Remove-Item -LiteralPath $CaptureISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $CaptureISOPath : $($_.Exception.Message)" }
|
||||
}
|
||||
if ($RemoveDeployISO -and -not [string]::IsNullOrWhiteSpace($DeployISOPath) -and (Test-Path -LiteralPath $DeployISOPath)) {
|
||||
WriteLog "CommonCleanup: Removing $DeployISOPath"
|
||||
try { Remove-Item -LiteralPath $DeployISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $DeployISOPath : $($_.Exception.Message)" }
|
||||
}
|
||||
if ($RemoveAppsISO -and -not [string]::IsNullOrWhiteSpace($AppsISOPath) -and (Test-Path -LiteralPath $AppsISOPath)) {
|
||||
WriteLog "CommonCleanup: Removing $AppsISOPath"
|
||||
try { Remove-Item -LiteralPath $AppsISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $AppsISOPath : $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# Legacy / root-level WinPE ISOs (pattern-based)
|
||||
if ($RemoveCaptureISO) {
|
||||
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Capture*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try { WriteLog "CommonCleanup: Removing legacy capture ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy capture ISO $($_.FullName) : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
if ($RemoveDeployISO) {
|
||||
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Deploy*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try { WriteLog "CommonCleanup: Removing legacy deploy ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy deploy ISO $($_.FullName) : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
if ($RemoveDrivers -and -not [string]::IsNullOrWhiteSpace($DriversPath) -and (Test-Path -LiteralPath $DriversPath -PathType Container)) {
|
||||
WriteLog "CommonCleanup: Removing contents of $DriversPath (preserving Drivers.json and DriverMapping.json)"
|
||||
try {
|
||||
# Preserve drivers json files
|
||||
$driverItems = Get-ChildItem -LiteralPath $DriversPath -Force -ErrorAction SilentlyContinue | Where-Object { @('Drivers.json', 'DriverMapping.json') -notcontains $_.Name }
|
||||
if ($driverItems) {
|
||||
$driverItems | Remove-Item -Force -Recurse -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
catch { WriteLog "CommonCleanup: Driver content cleanup issue: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
if ($RemoveFFU -and -not [string]::IsNullOrWhiteSpace($FFUCapturePath) -and (Test-Path -LiteralPath $FFUCapturePath -PathType Container)) {
|
||||
WriteLog "CommonCleanup: Removing FFU files in $FFUCapturePath"
|
||||
Get-ChildItem -LiteralPath $FFUCapturePath -Filter *.ffu -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing FFU $($_.FullName) : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
if ($RemoveApps -and -not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath -PathType Container)) {
|
||||
$win32 = Join-Path $AppsPath 'Win32'
|
||||
$store = Join-Path $AppsPath 'MSStore'
|
||||
if (Test-Path -LiteralPath $win32) {
|
||||
WriteLog "CommonCleanup: Removing $win32"
|
||||
try { Remove-Item -LiteralPath $win32 -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $win32 : $($_.Exception.Message)" }
|
||||
}
|
||||
if (Test-Path -LiteralPath $store) {
|
||||
WriteLog "CommonCleanup: Removing $store"
|
||||
try { Remove-Item -LiteralPath $store -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $store : $($_.Exception.Message)" }
|
||||
}
|
||||
$office = Join-Path $AppsPath 'Office'
|
||||
if ((Test-Path -LiteralPath $office) -and $InstallOffice) {
|
||||
WriteLog "CommonCleanup: Checking for Office artifacts in $office"
|
||||
$officeSub = Join-Path $office 'Office'
|
||||
if (Test-Path -LiteralPath $officeSub) {
|
||||
WriteLog "CommonCleanup: Removing $officeSub"
|
||||
try { Remove-Item -LiteralPath $officeSub -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $officeSub : $($_.Exception.Message)" }
|
||||
}
|
||||
$setupExe = Join-Path $office 'setup.exe'
|
||||
if (Test-Path -LiteralPath $setupExe) {
|
||||
WriteLog "CommonCleanup: Removing $setupExe"
|
||||
try { Remove-Item -LiteralPath $setupExe -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $setupExe : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($RemoveUpdates) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath)) {
|
||||
# Remove per-run app update payloads stored under Apps
|
||||
$appUpdateDirs = @('Defender', 'Edge', 'MSRT', 'OneDrive')
|
||||
foreach ($d in $appUpdateDirs) {
|
||||
$target = Join-Path $AppsPath $d
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
WriteLog "CommonCleanup: Removing update folder $target"
|
||||
try { Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $target : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($KBPath) -and (Test-Path -LiteralPath $KBPath)) {
|
||||
# Remove Windows/.NET CU downloads stored under KB
|
||||
WriteLog "CommonCleanup: Removing downloaded updates in $KBPath"
|
||||
try { Remove-Item -LiteralPath $KBPath -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $KBPath : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
WriteLog "CommonCleanup: Completed."
|
||||
}
|
||||
catch {
|
||||
WriteLog "CommonCleanup: Fatal cleanup error $($_.Exception.Message)"
|
||||
}
|
||||
finally {
|
||||
$ProgressPreference = $originalProgressPreference
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-FFUPostBuildCleanup
|
||||
@@ -0,0 +1,304 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Provides core, shared functions for logging, process execution, and resilient file transfers used across the FFU project.
|
||||
.DESCRIPTION
|
||||
This module is a central component of the FFU project, offering a set of robust, reusable functions.
|
||||
It includes a centralized logging mechanism (WriteLog), a wrapper for running external processes with error handling (Invoke-Process),
|
||||
a retry-aware BITS transfer function for reliable downloads (Start-BitsTransferWithRetry), and a progress reporting helper.
|
||||
This module is designed to be imported by other scripts and modules within the project to ensure consistent behavior for common tasks.
|
||||
#>
|
||||
# Script-scoped variable for the log file path
|
||||
$script:CommonCoreLogFilePath = $null
|
||||
# Mutex for log file access
|
||||
$script:commonCoreLogMutexName = "Global\FFUCommonCoreLogMutex" # Unique name
|
||||
$script:commonCoreLogMutex = New-Object System.Threading.Mutex($false, $script:commonCoreLogMutexName)
|
||||
$script:BitsTransferPriority = 'Normal'
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:FFU_BITS_PRIORITY)) {
|
||||
$script:BitsTransferPriority = $env:FFU_BITS_PRIORITY
|
||||
}
|
||||
|
||||
# Function to set the log file path for this module
|
||||
function Set-CommonCoreLogPath {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
$script:CommonCoreLogFilePath = $Path
|
||||
if (-not [string]::IsNullOrWhiteSpace($script:CommonCoreLogFilePath)) {
|
||||
# This initial WriteLog confirms the path is set and the logger is working.
|
||||
WriteLog "CommonCoreLogPath set to: $script:CommonCoreLogFilePath"
|
||||
}
|
||||
else {
|
||||
# This Write-Warning will appear on console if path is bad, but won't go to log file yet.
|
||||
Write-Warning "Set-CommonCoreLogPath called with an empty or null path."
|
||||
}
|
||||
}
|
||||
|
||||
function Set-BitsTransferPriority {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('Foreground', 'High', 'Normal', 'Low')]
|
||||
[string]$Priority
|
||||
)
|
||||
$script:BitsTransferPriority = $Priority
|
||||
try {
|
||||
Set-Item -Path Env:FFU_BITS_PRIORITY -Value $Priority -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to set FFU_BITS_PRIORITY environment variable: $($_.Exception.Message)"
|
||||
}
|
||||
WriteLog "BITS transfer priority set to $Priority."
|
||||
}
|
||||
|
||||
# Centralized WriteLog function
|
||||
function WriteLog {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LogText
|
||||
)
|
||||
|
||||
# Check if the log file path has been set
|
||||
if ([string]::IsNullOrWhiteSpace($script:CommonCoreLogFilePath)) {
|
||||
Write-Warning "CommonCoreLogFilePath not set. Message: $LogText"
|
||||
return
|
||||
}
|
||||
|
||||
$logEntry = "$((Get-Date).ToString()) $LogText"
|
||||
$streamWriter = $null
|
||||
|
||||
try {
|
||||
$script:commonCoreLogMutex.WaitOne() | Out-Null
|
||||
# Ensure directory exists before writing
|
||||
$logDir = Split-Path -Path $script:CommonCoreLogFilePath -Parent
|
||||
if (-not (Test-Path -Path $logDir -PathType Container)) {
|
||||
New-Item -Path $logDir -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
$streamWriter = New-Object System.IO.StreamWriter($script:CommonCoreLogFilePath, $true, [System.Text.Encoding]::UTF8)
|
||||
$streamWriter.WriteLine($logEntry)
|
||||
|
||||
Write-Verbose $LogText
|
||||
}
|
||||
catch {
|
||||
# Use Write-Host for console visibility as Write-Warning might also try to log
|
||||
Write-Host "WARNING: Error writing to log file '$($script:CommonCoreLogFilePath)': $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $streamWriter) {
|
||||
$streamWriter.Dispose()
|
||||
}
|
||||
$script:commonCoreLogMutex.ReleaseMutex()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Process {
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param
|
||||
(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string[]]$ArgumentList,
|
||||
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[bool]$Wait = $true
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
try {
|
||||
$stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
||||
$stdErrTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
||||
|
||||
$startProcessParams = @{
|
||||
FilePath = $FilePath
|
||||
ArgumentList = $ArgumentList
|
||||
RedirectStandardError = $stdErrTempFile
|
||||
RedirectStandardOutput = $stdOutTempFile
|
||||
Wait = $($Wait);
|
||||
PassThru = $true;
|
||||
NoNewWindow = $true;
|
||||
}
|
||||
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
|
||||
$cmd = Start-Process @startProcessParams
|
||||
$cmdOutput = Get-Content -Path $stdOutTempFile -Raw
|
||||
$cmdError = Get-Content -Path $stdErrTempFile -Raw
|
||||
if ($cmd.ExitCode -ne 0 -and $wait -eq $true) {
|
||||
if ($cmdError) {
|
||||
throw $cmdError.Trim()
|
||||
}
|
||||
if ($cmdOutput) {
|
||||
throw $cmdOutput.Trim()
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
|
||||
WriteLog $cmdOutput
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
#$PSCmdlet.ThrowTerminatingError($_)
|
||||
WriteLog $_
|
||||
# Write-Host "Script failed - $Logfile for more info"
|
||||
throw $_
|
||||
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
|
||||
}
|
||||
return $cmd
|
||||
}
|
||||
|
||||
# Function to download a file using BITS with retry and error handling
|
||||
function Start-BitsTransferWithRetry {
|
||||
param (
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Source,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Destination,
|
||||
[int]$Retries = 3,
|
||||
[ValidateSet('Foreground','High','Normal','Low')]
|
||||
[string]$Priority
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Priority)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:FFU_BITS_PRIORITY)) {
|
||||
$Priority = $env:FFU_BITS_PRIORITY
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace($script:BitsTransferPriority)) {
|
||||
$Priority = $script:BitsTransferPriority
|
||||
}
|
||||
else {
|
||||
$Priority = 'Normal'
|
||||
}
|
||||
}
|
||||
|
||||
$attempt = 0
|
||||
$lastError = $null
|
||||
$notLoggedOnHResult = [int]0x800704dd
|
||||
$fallbackTriggered = $false
|
||||
|
||||
while ($attempt -lt $Retries -and -not $fallbackTriggered) {
|
||||
$OriginalVerbosePreference = $VerbosePreference
|
||||
$OriginalProgressPreference = $ProgressPreference
|
||||
try {
|
||||
$VerbosePreference = 'SilentlyContinue'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
Start-BitsTransfer -Source $Source -Destination $Destination -Priority $Priority -ErrorAction Stop
|
||||
|
||||
$ProgressPreference = $OriginalProgressPreference
|
||||
$VerbosePreference = $OriginalVerbosePreference
|
||||
WriteLog "Successfully transferred $Source to $Destination."
|
||||
return
|
||||
}
|
||||
catch {
|
||||
$lastError = $_
|
||||
$attempt++
|
||||
$errorMessage = $lastError.Exception.Message
|
||||
WriteLog "Attempt $attempt of $Retries failed to download $Source. Error: $errorMessage."
|
||||
$hResult = $null
|
||||
if ($null -ne $lastError.Exception) {
|
||||
$hResult = $lastError.Exception.HResult
|
||||
}
|
||||
$needsHttpFallback = $false
|
||||
if ($hResult -eq $notLoggedOnHResult) {
|
||||
$needsHttpFallback = $true
|
||||
}
|
||||
elseif ($errorMessage -match '0x800704DD' -or $errorMessage -match 'not.*logged on to the network') {
|
||||
$needsHttpFallback = $true
|
||||
}
|
||||
if ($needsHttpFallback) {
|
||||
WriteLog "BITS cannot download $Source because the current session is not logged on to the network. Falling back to Invoke-WebRequest."
|
||||
$fallbackTriggered = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Seconds (1 * $attempt)
|
||||
}
|
||||
finally {
|
||||
if (Get-Variable -Name 'OriginalProgressPreference' -ErrorAction SilentlyContinue) {
|
||||
$ProgressPreference = $OriginalProgressPreference
|
||||
}
|
||||
if (Get-Variable -Name 'OriginalVerbosePreference' -ErrorAction SilentlyContinue) {
|
||||
$VerbosePreference = $OriginalVerbosePreference
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($fallbackTriggered) {
|
||||
$remainingAttempts = $Retries - $attempt
|
||||
if ($remainingAttempts -lt 1) {
|
||||
$remainingAttempts = 1
|
||||
}
|
||||
$httpAttempt = 0
|
||||
while ($httpAttempt -lt $remainingAttempts) {
|
||||
$httpAttempt++
|
||||
$OriginalVerbosePreference = $VerbosePreference
|
||||
$OriginalProgressPreference = $ProgressPreference
|
||||
try {
|
||||
$VerbosePreference = 'SilentlyContinue'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Invoke-WebRequest -Uri $Source -OutFile $Destination -ErrorAction Stop
|
||||
$ProgressPreference = $OriginalProgressPreference
|
||||
$VerbosePreference = $OriginalVerbosePreference
|
||||
WriteLog "Successfully transferred $Source to $Destination via HTTP fallback."
|
||||
return
|
||||
}
|
||||
catch {
|
||||
$lastError = $_
|
||||
WriteLog "HTTP fallback attempt $httpAttempt of $remainingAttempts failed to download $Source. Error: $($lastError.Exception.Message)."
|
||||
Start-Sleep -Seconds (1 * $httpAttempt)
|
||||
}
|
||||
finally {
|
||||
if (Get-Variable -Name 'OriginalProgressPreference' -ErrorAction SilentlyContinue) {
|
||||
$ProgressPreference = $OriginalProgressPreference
|
||||
}
|
||||
if (Get-Variable -Name 'OriginalVerbosePreference' -ErrorAction SilentlyContinue) {
|
||||
$VerbosePreference = $OriginalVerbosePreference
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WriteLog "Failed to download $Source after $Retries attempts. Last Error: $($lastError.Exception.Message)"
|
||||
throw $lastError
|
||||
}
|
||||
|
||||
function Set-Progress {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[int]$Percentage,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Message
|
||||
)
|
||||
WriteLog "[PROGRESS] $Percentage | $Message"
|
||||
}
|
||||
|
||||
function ConvertTo-SafeName {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
# Replace invalid Windows filename characters (<>:"/\|?* and control chars) with a dash
|
||||
$sanitized = $Name -replace '[<>:\"/\\|?*\x00-\x1F]', '-'
|
||||
# Collapse multiple consecutive dashes
|
||||
$sanitized = $sanitized -replace '-{2,}', '-'
|
||||
# Trim leading/trailing spaces, periods, and dashes
|
||||
$sanitized = $sanitized.Trim(' ', '.', '-')
|
||||
if ([string]::IsNullOrWhiteSpace($sanitized)) {
|
||||
$sanitized = 'Unnamed'
|
||||
}
|
||||
return $sanitized
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -0,0 +1,300 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Common Dell driver helpers (catalog index, model listing, latest package selection).
|
||||
#>
|
||||
|
||||
function Convert-DellVendorVersion {
|
||||
param([Parameter(Mandatory=$true)][string]$VendorVersion)
|
||||
$segments = $VendorVersion.Split('.') | ForEach-Object {
|
||||
if ($_ -match '^\d+$') { [int]$_ } else { 0 }
|
||||
}
|
||||
return ,$segments
|
||||
}
|
||||
|
||||
function Compare-DellVendorVersion {
|
||||
param(
|
||||
[int[]]$Left,
|
||||
[int[]]$Right
|
||||
)
|
||||
$len = [Math]::Max($Left.Length,$Right.Length)
|
||||
for ($i=0; $i -lt $len; $i++) {
|
||||
$l = if ($i -lt $Left.Length) { $Left[$i] } else { 0 }
|
||||
$r = if ($i -lt $Right.Length) { $Right[$i] } else { 0 }
|
||||
if ($l -gt $r) { return 1 }
|
||||
if ($l -lt $r) { return -1 }
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-DellCatalogIndex {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$DriversFolder
|
||||
)
|
||||
|
||||
$dellFolder = Join-Path $DriversFolder 'Dell'
|
||||
if (-not (Test-Path $dellFolder)) { New-Item -Path $dellFolder -ItemType Directory -Force | Out-Null }
|
||||
$cabPath = Join-Path $dellFolder 'CatalogIndexPC.cab'
|
||||
$xmlPath = Join-Path $dellFolder 'CatalogIndexPC.xml'
|
||||
$url = 'https://downloads.dell.com/catalog/CatalogIndexPC.cab'
|
||||
|
||||
$need = $true
|
||||
if (Test-Path $xmlPath) {
|
||||
$ageDays = ((Get-Date) - (Get-Item $xmlPath).CreationTime).TotalDays
|
||||
if ($ageDays -lt 7) { $need = $false }
|
||||
}
|
||||
|
||||
if ($need) {
|
||||
if (Test-Path $cabPath) { Remove-Item $cabPath -Force -ErrorAction SilentlyContinue }
|
||||
if (Test-Path $xmlPath) { Remove-Item $xmlPath -Force -ErrorAction SilentlyContinue }
|
||||
Start-BitsTransferWithRetry -Source $url -Destination $cabPath
|
||||
Invoke-Process -FilePath Expand.exe -ArgumentList """$cabPath"" ""$xmlPath""" | Out-Null
|
||||
Remove-Item $cabPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if (-not (Test-Path $xmlPath)) { throw "Dell CatalogIndexPC XML missing: $xmlPath" }
|
||||
return $xmlPath
|
||||
}
|
||||
|
||||
function Get-DellClientModels {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$CatalogIndexXmlPath
|
||||
)
|
||||
|
||||
$settings = New-Object System.Xml.XmlReaderSettings
|
||||
$settings.IgnoreWhitespace = $true
|
||||
$settings.IgnoreComments = $true
|
||||
$reader = [System.Xml.XmlReader]::Create($CatalogIndexXmlPath,$settings)
|
||||
|
||||
$models = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
try {
|
||||
while ($reader.Read()) {
|
||||
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'GroupManifest') {
|
||||
# Read subtree to pick out brand/model/systemID + path
|
||||
$sub = $reader.ReadSubtree()
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.Load($sub)
|
||||
$sub.Dispose()
|
||||
|
||||
# Use local-name() to ignore namespaces
|
||||
$brandNode = $doc.SelectSingleNode("//*[local-name()='SupportedSystems']/*[local-name()='Brand']")
|
||||
if (-not $brandNode) { continue }
|
||||
$brandDisplay = ($brandNode.SelectSingleNode("*[local-name()='Display']")?.InnerText).Trim()
|
||||
$modelNode = $brandNode.SelectSingleNode("*[local-name()='Model']")
|
||||
if (-not $modelNode) { continue }
|
||||
$modelNumber = ($modelNode.SelectSingleNode("*[local-name()='Display']")?.InnerText).Trim()
|
||||
$systemId = $modelNode.GetAttribute('systemID')
|
||||
$manifestInfo = $doc.SelectSingleNode("//*[local-name()='ManifestInformation']")
|
||||
if (-not $manifestInfo) { continue }
|
||||
$pathAttr = $manifestInfo.GetAttribute('path')
|
||||
if (-not $pathAttr) { continue }
|
||||
$cabUrl = 'https://downloads.dell.com/' + $pathAttr
|
||||
# Normalize model display using GroupManifest Display CDATA if available (strip 'PDK Catalog for')
|
||||
$gmDisplayNode = $doc.SelectSingleNode("/*[local-name()='GroupManifest']/*[local-name()='Display']")
|
||||
$modelFull = $null
|
||||
if ($gmDisplayNode -and $gmDisplayNode.InnerText) {
|
||||
$rawDisplay = $gmDisplayNode.InnerText.Trim()
|
||||
$modelFull = ($rawDisplay -replace '^\s*PDK Catalog for\s+','').Trim()
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($modelFull)) {
|
||||
# Fallback: assemble from brand/model nodes (legacy heuristic)
|
||||
$prefixedModelNumber = $modelNumber
|
||||
if ($modelNumber -and $brandDisplay) {
|
||||
if ($modelNumber.StartsWith($brandDisplay,[System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$prefixedModelNumber = $modelNumber
|
||||
}
|
||||
else {
|
||||
$prefixedModelNumber = "$brandDisplay $modelNumber"
|
||||
}
|
||||
}
|
||||
elseif ($brandDisplay -and -not $modelNumber) {
|
||||
$prefixedModelNumber = $brandDisplay
|
||||
}
|
||||
$modelFull = $prefixedModelNumber
|
||||
}
|
||||
$modelDisplay = "$modelFull ($systemId)"
|
||||
$models.Add([pscustomobject]@{
|
||||
Brand = $brandDisplay
|
||||
ModelNumber = $modelNumber
|
||||
SystemId = $systemId
|
||||
CabRelativePath = $pathAttr
|
||||
CabUrl = $cabUrl
|
||||
ModelDisplay = $modelDisplay
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
return $models
|
||||
}
|
||||
|
||||
function Get-DellLatestDriverPackages {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$ModelXmlPath,
|
||||
[Parameter(Mandatory=$true)][string]$WindowsArch,
|
||||
[Parameter(Mandatory=$true)][int]$WindowsRelease
|
||||
)
|
||||
|
||||
if (-not (Test-Path $ModelXmlPath)) { throw "Model XML not found: $ModelXmlPath" }
|
||||
|
||||
$xml = [xml](Get-Content -Path $ModelXmlPath -Raw)
|
||||
|
||||
# Collect all SoftwareComponent nodes
|
||||
$components = $xml.SelectNodes("//*[local-name()='SoftwareComponent']")
|
||||
if (-not $components) { return @() }
|
||||
|
||||
$rawPackages = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
|
||||
foreach ($comp in $components) {
|
||||
$ctype = $comp.SelectSingleNode("*[local-name()='ComponentType']")
|
||||
if (-not $ctype) { continue }
|
||||
if ($ctype.GetAttribute('value') -ne 'DRVR') { continue }
|
||||
|
||||
# OS filtering (arch only – release filtering intentionally minimal for now)
|
||||
$osNodes = @($comp.SelectNodes("*[local-name()='SupportedOperatingSystems']/*[local-name()='OperatingSystem']"))
|
||||
if (-not $osNodes) { continue }
|
||||
$validOS = $osNodes | Where-Object { $_.GetAttribute('osArch') -eq $WindowsArch } | Select-Object -First 1
|
||||
if (-not $validOS) { continue }
|
||||
|
||||
$path = $comp.GetAttribute('path')
|
||||
if (-not $path) { continue }
|
||||
|
||||
$downloadUrl = "https://downloads.dell.com/$path"
|
||||
$fileName = [IO.Path]::GetFileName($path)
|
||||
$vendorVersion = $comp.GetAttribute('vendorVersion')
|
||||
$versionArr = if ($vendorVersion) { Convert-DellVendorVersion $vendorVersion } else { @(0) }
|
||||
$dateTimeAttr = $comp.GetAttribute('dateTime')
|
||||
$dt = Get-Date
|
||||
if ($dateTimeAttr) {
|
||||
try { $dt = [DateTime]::Parse($dateTimeAttr) } catch { }
|
||||
}
|
||||
|
||||
$categoryNode = $comp.SelectSingleNode("*[local-name()='Category']/*[local-name()='Display']")
|
||||
$category = if ($categoryNode) { $categoryNode.InnerText.Trim() } else { 'Uncategorized' }
|
||||
|
||||
# Collect componentIDs (SupportedDevices + SupportedDCHDevices)
|
||||
$compIds = [System.Collections.Generic.List[string]]::new()
|
||||
$devNodes = @($comp.SelectNodes(".//*[local-name()='Device']"))
|
||||
foreach ($dn in $devNodes) {
|
||||
$id = $dn.GetAttribute('componentID')
|
||||
if ($id) { [void]$compIds.Add($id) }
|
||||
}
|
||||
if ($compIds.Count -eq 0) { continue }
|
||||
|
||||
# Build a deterministic sortable key: zero-pad each numeric segment to 6 digits
|
||||
$versionSortable = ($versionArr | ForEach-Object { $_.ToString('D6') }) -join '-'
|
||||
|
||||
# Capture a human‑readable driver name (preserve spaces like HP/Lenovo; remove only illegal path chars and extra whitespace)
|
||||
$displayNode = $comp.SelectSingleNode("*[local-name()='Name']/*[local-name()='Display']")
|
||||
$nameRaw = if ($displayNode) { $displayNode.InnerText.Trim() } else { $fileName }
|
||||
# Remove characters not suitable for display (and disallowed in file names) but keep spaces
|
||||
$nameDisplay = $nameRaw -replace '[\\\/:\*\?\"\<\>\|]', ' ' -replace '[,]', '-'
|
||||
# Collapse multiple spaces to single
|
||||
$nameDisplay = ($nameDisplay -replace '\s+', ' ').Trim()
|
||||
|
||||
$rawPackages.Add([pscustomobject]@{
|
||||
Path = $path
|
||||
DownloadUrl = $downloadUrl
|
||||
FileName = $fileName
|
||||
Name = $nameDisplay
|
||||
Category = $category
|
||||
VendorVersion = $vendorVersion
|
||||
VersionArray = $versionArr
|
||||
VersionSortable = $versionSortable
|
||||
DateTime = $dt
|
||||
ComponentIds = $compIds
|
||||
})
|
||||
}
|
||||
|
||||
if ($rawPackages.Count -eq 0) { return @() }
|
||||
|
||||
# Sort newest first by VersionSortable (lexicographic works due to zero padding) then DateTime
|
||||
$sorted = $rawPackages | Sort-Object -Property @{ Expression = { $_.VersionSortable }; Descending = $true }, @{ Expression = { $_.DateTime }; Descending = $true }
|
||||
|
||||
$chosen = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
$assignedIds = [System.Collections.Generic.HashSet[string]]::new()
|
||||
|
||||
foreach ($pkg in $sorted) {
|
||||
$hasOverlap = $false
|
||||
foreach ($cid in $pkg.ComponentIds) {
|
||||
if ($assignedIds.Contains($cid)) { $hasOverlap = $true; break }
|
||||
}
|
||||
if ($hasOverlap) {
|
||||
WriteLog "Get-DellLatestDriverPackages: Skipping superseded package $($pkg.FileName) (shared componentID with newer package)."
|
||||
continue
|
||||
}
|
||||
|
||||
foreach ($cid in $pkg.ComponentIds) { [void]$assignedIds.Add($cid) }
|
||||
|
||||
$chosen.Add([pscustomobject]@{
|
||||
Path = $pkg.Path
|
||||
DownloadUrl = $pkg.DownloadUrl
|
||||
DriverFileName = $pkg.FileName
|
||||
Name = $pkg.Name
|
||||
Category = $pkg.Category
|
||||
VendorVersion = $pkg.VendorVersion
|
||||
DateTime = $pkg.DateTime
|
||||
ComponentIds = $pkg.ComponentIds
|
||||
})
|
||||
}
|
||||
|
||||
if ($chosen.Count -eq 0) {
|
||||
WriteLog "Get-DellLatestDriverPackages: No qualifying driver packages after supersedence."
|
||||
return @()
|
||||
}
|
||||
|
||||
WriteLog ("Get-DellLatestDriverPackages: Selected {0} package(s) after supersedence." -f $chosen.Count)
|
||||
return $chosen
|
||||
}
|
||||
|
||||
# Resolve a Dell per‑model CabUrl when missing by inspecting CatalogIndexPC
|
||||
function Resolve-DellCabUrlFromModel {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$DriversFolder,
|
||||
[Parameter()][string]$ModelDisplay,
|
||||
[Parameter()][string]$SystemId
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($SystemId) -and -not [string]::IsNullOrWhiteSpace($ModelDisplay)) {
|
||||
# Try to parse the trailing (XXXX) token (SystemId)
|
||||
if ($ModelDisplay -match '\(([0-9A-Fa-f]{4})\)\s*$') {
|
||||
$SystemId = $matches[1].ToUpperInvariant()
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($SystemId)) {
|
||||
WriteLog "Resolve-DellCabUrlFromModel: No SystemId could be determined from '$ModelDisplay'."
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$indexXml = Get-DellCatalogIndex -DriversFolder $DriversFolder
|
||||
# Reuse existing model parsing to avoid duplicating streaming logic
|
||||
$allModels = Get-DellClientModels -CatalogIndexXmlPath $indexXml
|
||||
$match = $allModels | Where-Object { $_.SystemId -eq $SystemId } | Select-Object -First 1
|
||||
if ($null -eq $match) {
|
||||
WriteLog "Resolve-DellCabUrlFromModel: SystemId '$SystemId' not found in CatalogIndexPC.xml."
|
||||
return $null
|
||||
}
|
||||
WriteLog "Resolve-DellCabUrlFromModel: Resolved CabUrl for '$($match.ModelDisplay)' -> $($match.CabUrl)"
|
||||
return [pscustomobject]@{
|
||||
Brand = $match.Brand
|
||||
ModelNumber = $match.ModelNumber
|
||||
SystemId = $match.SystemId
|
||||
CabRelativePath = $match.CabRelativePath
|
||||
CabUrl = $match.CabUrl
|
||||
ModelDisplay = $match.ModelDisplay
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Resolve-DellCabUrlFromModel: Failure resolving CabUrl for '$ModelDisplay' / SystemId '$SystemId' : $($_.Exception.Message)"
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Convert-DellVendorVersion,Compare-DellVendorVersion,Get-DellCatalogIndex,Get-DellClientModels,Get-DellLatestDriverPackages,Resolve-DellCabUrlFromModel
|
||||
@@ -0,0 +1,591 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Common Microsoft/Surface driver helpers (cache index, SKU mapping).
|
||||
.DESCRIPTION
|
||||
This module contains Microsoft/Surface-specific functions used by the UI and scripts
|
||||
to map Surface driver packs to System SKU values using:
|
||||
- Source A: Surface System SKU reference (Learn)
|
||||
- Source B: Support page model list
|
||||
- Source C: Download Center details (window.__DLCDetails__)
|
||||
#>
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SECTION: Microsoft Surface Driver Index Cache (Sources A/B/C)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
function Get-SurfaceDriverIndexCachePath {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder
|
||||
)
|
||||
|
||||
# Store the cache under Drivers\Microsoft so it travels with the driver content
|
||||
$microsoftDriversFolder = Join-Path -Path $DriversFolder -ChildPath 'Microsoft'
|
||||
if (-not (Test-Path -Path $microsoftDriversFolder -PathType Container)) {
|
||||
New-Item -Path $microsoftDriversFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
return (Join-Path -Path $microsoftDriversFolder -ChildPath 'SurfaceDriverIndex.json')
|
||||
}
|
||||
|
||||
function Import-SurfaceDriverIndexCache {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder
|
||||
)
|
||||
|
||||
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
|
||||
|
||||
# Surface cache TTL (7 days): treat stale caches as missing so we re-download Sources A/B/C as needed.
|
||||
$cacheTtlDays = 7
|
||||
if (-not (Test-Path -Path $cachePath -PathType Leaf)) {
|
||||
return [pscustomobject]@{
|
||||
ModelIndex = @()
|
||||
SkuIndex = @()
|
||||
DownloadCenterDetails = @()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$cacheAgeDays = ((Get-Date) - (Get-Item -Path $cachePath -ErrorAction Stop).LastWriteTime).TotalDays
|
||||
if ($cacheAgeDays -ge $cacheTtlDays) {
|
||||
WriteLog "Surface cache: Cache file '$cachePath' is older than $cacheTtlDays days ($([math]::Round($cacheAgeDays, 1)) days). Refreshing."
|
||||
return [pscustomobject]@{
|
||||
ModelIndex = @()
|
||||
SkuIndex = @()
|
||||
DownloadCenterDetails = @()
|
||||
}
|
||||
}
|
||||
|
||||
WriteLog "Surface cache: Loading cached SurfaceDriverIndex.json from '$cachePath' (age: $([math]::Round($cacheAgeDays, 1)) days)."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Surface cache: Failed to read cache timestamp for '$cachePath'. Refreshing. Error: $($_.Exception.Message)"
|
||||
return [pscustomobject]@{
|
||||
ModelIndex = @()
|
||||
SkuIndex = @()
|
||||
DownloadCenterDetails = @()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$cache = Get-Content -Path $cachePath -Raw | ConvertFrom-Json -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Could not read Surface driver cache '$cachePath'. Creating a new cache. Error: $($_.Exception.Message)"
|
||||
return [pscustomobject]@{
|
||||
ModelIndex = @()
|
||||
SkuIndex = @()
|
||||
DownloadCenterDetails = @()
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $cache) {
|
||||
return [pscustomobject]@{
|
||||
ModelIndex = @()
|
||||
SkuIndex = @()
|
||||
DownloadCenterDetails = @()
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure expected properties exist (backward compatible with earlier cache shapes)
|
||||
if (-not $cache.PSObject.Properties['ModelIndex']) {
|
||||
$cache | Add-Member -NotePropertyName ModelIndex -NotePropertyValue @()
|
||||
}
|
||||
if (-not $cache.PSObject.Properties['SkuIndex']) {
|
||||
$cache | Add-Member -NotePropertyName SkuIndex -NotePropertyValue @()
|
||||
}
|
||||
if (-not $cache.PSObject.Properties['DownloadCenterDetails']) {
|
||||
$cache | Add-Member -NotePropertyName DownloadCenterDetails -NotePropertyValue @()
|
||||
}
|
||||
|
||||
return $cache
|
||||
}
|
||||
|
||||
function Save-SurfaceDriverIndexCache {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$Cache,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder
|
||||
)
|
||||
|
||||
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
|
||||
$Cache | ConvertTo-Json -Depth 10 | Set-Content -Path $cachePath -Encoding UTF8
|
||||
}
|
||||
|
||||
function ConvertTo-SurfaceComparableName {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Text
|
||||
)
|
||||
|
||||
# Normalize Surface marketing strings into a comparable family key.
|
||||
# This intentionally strips consumer/commercial/processor qualifiers so we can join Sources A/B/C.
|
||||
$value = [System.Net.WebUtility]::HtmlDecode($Text)
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$value = $value.Trim()
|
||||
$value = $value -replace '\(', ' '
|
||||
$value = $value -replace '\)', ' '
|
||||
$value = $value -replace ',', ' '
|
||||
|
||||
# Normalize punctuation that frequently differs between Support/Learn pages
|
||||
# (e.g. Wi‑Fi unicode hyphen, AT&T, Y!mobile)
|
||||
$value = $value -replace '[-\u2010\u2011\u2012\u2013\u2014\u2212]', ' '
|
||||
$value = $value -replace '&', ' '
|
||||
$value = $value -replace '!', ' '
|
||||
$value = $value -replace '™', ' '
|
||||
|
||||
$value = $value -replace '(?i)\bMicrosoft\b', ''
|
||||
$value = $value -replace '(?i)\bfor\s+Business\b', ''
|
||||
$value = $value -replace '(?i)\bConsumer\b', ''
|
||||
$value = $value -replace '(?i)\bCommercial\b', ''
|
||||
|
||||
# Strip processor/connection qualifiers that cause mismatches between WMI, Learn, and Support naming.
|
||||
$value = $value -replace '(?i)\bwith\s+Intel\b', ''
|
||||
$value = $value -replace '(?i)\bIntel\s+processor\b', ''
|
||||
$value = $value -replace '(?i)\bIntel\b', ''
|
||||
$value = $value -replace '(?i)\bSnapdragon\s+processor\b', ''
|
||||
$value = $value -replace '(?i)\bSnapdragon\b', ''
|
||||
$value = $value -replace '(?i)\bwith\s+5G\b', ''
|
||||
$value = $value -replace '(?i)\bLTE\b', ''
|
||||
$value = $value -replace '(?i)\b4G\b', ''
|
||||
$value = $value -replace '(?i)\bprocessor\b', ''
|
||||
|
||||
# Cleanup: remove orphaned "with" left behind by earlier removals (e.g., "Surface Pro 9 with Intel Processor")
|
||||
$value = $value -replace '(?i)\bwith\b', ''
|
||||
$value = $value -replace '\s+', ' '
|
||||
|
||||
return $value.Trim().ToUpperInvariant()
|
||||
}
|
||||
|
||||
function Get-SurfaceSystemSkuReferenceIndex {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder
|
||||
)
|
||||
|
||||
# Source A: Learn page with authoritative Device / System Model / System SKU table
|
||||
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
|
||||
if ($cache.SkuIndex -and $cache.SkuIndex.Count -gt 0) {
|
||||
return @($cache.SkuIndex)
|
||||
}
|
||||
|
||||
$url = 'https://learn.microsoft.com/en-us/surface/surface-system-sku-reference'
|
||||
WriteLog "Surface cache: Downloading System SKU reference table from $url"
|
||||
|
||||
$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }
|
||||
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $headers
|
||||
$html = $webContent.Content
|
||||
|
||||
$skuRows = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
|
||||
$rowMatches = [regex]::Matches($html, '<tr[^>]*>(.*?)</tr>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
|
||||
foreach ($rowMatch in $rowMatches) {
|
||||
$rowContent = $rowMatch.Groups[1].Value
|
||||
$cellMatches = [regex]::Matches($rowContent, '<td[^>]*>\s*(?:<p[^>]*>)?(.*?)(?:</p>)?\s*</td>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
|
||||
if ($cellMatches.Count -lt 3) { continue }
|
||||
|
||||
$device = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[0].Groups[1].Value).Trim()))
|
||||
$systemModel = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[1].Groups[1].Value).Trim()))
|
||||
$systemSkuRaw = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[2].Groups[1].Value).Trim()))
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($device) -or [string]::IsNullOrWhiteSpace($systemSkuRaw)) { continue }
|
||||
|
||||
$skuList = @($systemSkuRaw)
|
||||
|
||||
foreach ($sku in $skuList) {
|
||||
if ([string]::IsNullOrWhiteSpace($sku)) { continue }
|
||||
$skuRows.Add([pscustomobject]@{
|
||||
Device = $device
|
||||
SystemModel = $systemModel
|
||||
SystemSku = $sku.Trim().ToUpperInvariant()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
$cache.SkuIndex = @($skuRows)
|
||||
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
|
||||
WriteLog "Surface cache: Stored $($skuRows.Count) SKU entries."
|
||||
|
||||
return @($skuRows)
|
||||
}
|
||||
|
||||
function Get-SurfaceDownloadCenterDetails {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ModelLink,
|
||||
[Parameter()]
|
||||
[string]$ModelName = $null
|
||||
)
|
||||
|
||||
# Source C: Download Center details page (window.__DLCDetails__) containing file names + direct URLs
|
||||
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
|
||||
$existing = @($cache.DownloadCenterDetails | Where-Object { $_.Link -eq $ModelLink } | Select-Object -First 1)
|
||||
if ($existing.Count -gt 0 -and $existing[0].Files -and $existing[0].Files.Count -gt 0) {
|
||||
# Backfill Model into cache when available
|
||||
if (-not [string]::IsNullOrWhiteSpace($ModelName)) {
|
||||
if (-not $existing[0].PSObject.Properties['Model'] -or [string]::IsNullOrWhiteSpace($existing[0].Model)) {
|
||||
try {
|
||||
$existing[0] | Add-Member -NotePropertyName Model -NotePropertyValue $ModelName -Force
|
||||
|
||||
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
foreach ($item in @($cache.DownloadCenterDetails)) {
|
||||
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $ModelLink) {
|
||||
$newDetails.Add($item)
|
||||
}
|
||||
}
|
||||
$newDetails.Add($existing[0])
|
||||
$cache.DownloadCenterDetails = @($newDetails)
|
||||
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
|
||||
}
|
||||
catch {
|
||||
WriteLog "Surface cache: Failed to backfill Model for DownloadCenterDetails entry '$ModelLink'. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return @($existing[0].Files)
|
||||
}
|
||||
|
||||
WriteLog "Surface cache: Downloading Download Center details from $ModelLink"
|
||||
$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }
|
||||
$downloadPageContent = Invoke-WebRequest -Uri $ModelLink -UseBasicParsing -Headers $headers
|
||||
|
||||
$scriptPattern = '<script>window.__DLCDetails__={(.*?)}<\/script>'
|
||||
$scriptMatch = [regex]::Match($downloadPageContent.Content, $scriptPattern)
|
||||
if (-not $scriptMatch.Success) {
|
||||
WriteLog "Surface cache: Could not find window.__DLCDetails__ on $ModelLink"
|
||||
return @()
|
||||
}
|
||||
|
||||
$scriptContent = $scriptMatch.Groups[1].Value
|
||||
$downloadFilePattern = '"name":"([^"]+\.(?:msi|zip))",[^}]*?"url":"(.*?)"'
|
||||
$downloadFileMatches = [regex]::Matches($scriptContent, $downloadFilePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
|
||||
$files = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
foreach ($downloadFile in $downloadFileMatches) {
|
||||
$currentFileName = $downloadFile.Groups[1].Value
|
||||
$fileUrl = $downloadFile.Groups[2].Value
|
||||
if ([string]::IsNullOrWhiteSpace($currentFileName) -or [string]::IsNullOrWhiteSpace($fileUrl)) { continue }
|
||||
|
||||
$files.Add([pscustomobject]@{
|
||||
Name = $currentFileName
|
||||
Url = $fileUrl
|
||||
})
|
||||
}
|
||||
|
||||
# Persist into cache
|
||||
if ($files.Count -gt 0) {
|
||||
$detailsEntry = [pscustomobject][ordered]@{
|
||||
Model = $ModelName
|
||||
Link = $ModelLink
|
||||
Files = @($files)
|
||||
}
|
||||
|
||||
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
foreach ($item in @($cache.DownloadCenterDetails)) {
|
||||
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $ModelLink) {
|
||||
$newDetails.Add($item)
|
||||
}
|
||||
}
|
||||
$newDetails.Add($detailsEntry)
|
||||
$cache.DownloadCenterDetails = @($newDetails)
|
||||
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
|
||||
}
|
||||
|
||||
return @($files)
|
||||
}
|
||||
|
||||
function Get-SurfaceSystemSkuListForMicrosoftDriver {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ModelName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ModelLink
|
||||
)
|
||||
|
||||
$skuIndex = Get-SurfaceSystemSkuReferenceIndex -DriversFolder $DriversFolder
|
||||
if ($null -eq $skuIndex -or $skuIndex.Count -eq 0) {
|
||||
return @()
|
||||
}
|
||||
|
||||
$files = Get-SurfaceDownloadCenterDetails -DriversFolder $DriversFolder -ModelLink $ModelLink -ModelName $ModelName
|
||||
$fileNames = @($files | ForEach-Object { $_.Name })
|
||||
|
||||
# Infer architecture hints from the MSI naming convention (best-effort)
|
||||
$archHint = $null
|
||||
if ($fileNames -match '(?i)_ARM_') {
|
||||
$archHint = 'ARM64'
|
||||
}
|
||||
elseif ($fileNames -match '(?i)withIntel|_Intel_|Intel') {
|
||||
$archHint = 'x64'
|
||||
}
|
||||
elseif ($ModelName -match '(?i)\bSQ3\b|\bSnapdragon\b') {
|
||||
$archHint = 'ARM64'
|
||||
}
|
||||
elseif ($ModelName -match '(?i)with Intel') {
|
||||
$archHint = 'x64'
|
||||
}
|
||||
|
||||
# Surface Pro (generic) is ambiguous in the SKU table because Surface Pro (5th Gen) and
|
||||
# Surface Pro with LTE Advanced (5th Gen) both reuse SystemModel="Surface Pro".
|
||||
# The "Surface Pro" driver pack does not have a unique SystemSKU value on the Learn page.
|
||||
if ($ModelName.Trim() -match '(?i)^Surface\s+Pro$') {
|
||||
return @()
|
||||
}
|
||||
|
||||
# Build multiple candidate keys for models that contain multiple variants in one string
|
||||
# Example: "Surface Pro 7+ and Surface Pro 7+ LTE"
|
||||
$familyKeyCandidates = [System.Collections.Generic.List[string]]::new()
|
||||
$familyKeySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
$primaryKey = ConvertTo-SurfaceComparableName -Text $ModelName
|
||||
if (-not [string]::IsNullOrWhiteSpace($primaryKey) -and $familyKeySet.Add($primaryKey)) {
|
||||
$familyKeyCandidates.Add($primaryKey) | Out-Null
|
||||
}
|
||||
|
||||
$parts = [regex]::Split($ModelName, '(?i)\s+and\s+')
|
||||
|
||||
# Track when the model text contains both LTE and non-LTE variants (e.g. "Surface Go 2 and Surface Go 2 LTE")
|
||||
$hasLtePart = (@($parts | Where-Object { $_ -match '(?i)\bLTE\b' }).Count -gt 0)
|
||||
$hasNonLtePart = (@($parts | Where-Object { $_ -notmatch '(?i)\bLTE\b' }).Count -gt 0)
|
||||
|
||||
foreach ($part in @($parts)) {
|
||||
if ([string]::IsNullOrWhiteSpace($part)) { continue }
|
||||
$candidate = ConvertTo-SurfaceComparableName -Text $part
|
||||
if (-not [string]::IsNullOrWhiteSpace($candidate) -and $familyKeySet.Add($candidate)) {
|
||||
$familyKeyCandidates.Add($candidate) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
if ($familyKeyCandidates.Count -eq 0) {
|
||||
return @()
|
||||
}
|
||||
|
||||
# Surface 3 has multiple carrier/region variants that share the same SystemModel ("Surface 3").
|
||||
# Add a base key so we can match all Surface 3 SKU rows, then refine down to the correct variant.
|
||||
if ($ModelName -match '(?i)^Surface\s+3\b') {
|
||||
$surface3BaseKey = 'SURFACE 3'
|
||||
if ($familyKeySet.Add($surface3BaseKey)) {
|
||||
$familyKeyCandidates.Add($surface3BaseKey) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Surface Go variants share the same SystemModel ("Surface Go") in the SKU table.
|
||||
# Use a generation-aware base key so we don't cross-match Go vs Go 2/3/4 SKU rows.
|
||||
if ($ModelName -match '(?i)^Surface\s+Go\s+2\b') {
|
||||
$surfaceGoBaseKey = 'SURFACE GO 2'
|
||||
if ($familyKeySet.Add($surfaceGoBaseKey)) {
|
||||
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
|
||||
}
|
||||
}
|
||||
elseif ($ModelName -match '(?i)^Surface\s+Go\s+3\b') {
|
||||
$surfaceGoBaseKey = 'SURFACE GO 3'
|
||||
if ($familyKeySet.Add($surfaceGoBaseKey)) {
|
||||
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
|
||||
}
|
||||
}
|
||||
elseif ($ModelName -match '(?i)^Surface\s+Go\s+4\b') {
|
||||
$surfaceGoBaseKey = 'SURFACE GO 4'
|
||||
if ($familyKeySet.Add($surfaceGoBaseKey)) {
|
||||
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
|
||||
}
|
||||
}
|
||||
elseif ($ModelName -match '(?i)^Surface\s+Go\b') {
|
||||
$surfaceGoBaseKey = 'SURFACE GO'
|
||||
if ($familyKeySet.Add($surfaceGoBaseKey)) {
|
||||
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Surface Pro 9 with 5G: the SKU table rows use SystemModel "Surface Pro 9".
|
||||
# Add a base key so we can match the Pro 9 SKU rows, then refine down to the 5G rows.
|
||||
if (($ModelName -match '(?i)^Surface\s+Pro\s+9\b') -and ($ModelName -match '(?i)\b5G\b')) {
|
||||
$surfacePro9BaseKey = 'SURFACE PRO 9'
|
||||
if ($familyKeySet.Add($surfacePro9BaseKey)) {
|
||||
$familyKeyCandidates.Add($surfacePro9BaseKey) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Surface Pro with LTE Advanced maps to the "Surface Pro with LTE Advanced (5th Gen)" SKU table row.
|
||||
# Add a base key so we can match Surface Pro rows, then refine to the LTE Advanced SKU.
|
||||
if ($ModelName -match '(?i)^Surface\s+Pro\s+with\s+LTE\s+Advanced\b') {
|
||||
$surfaceProBaseKey = 'SURFACE PRO'
|
||||
if ($familyKeySet.Add($surfaceProBaseKey)) {
|
||||
$familyKeyCandidates.Add($surfaceProBaseKey) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Surface Laptop (1st Gen) maps to the base "Surface Laptop" SKU table row.
|
||||
if (($ModelName -match '(?i)^Surface\s+Laptop\b') -and ($ModelName -match '(?i)\bGen\b')) {
|
||||
$surfaceLaptopBaseKey = 'SURFACE LAPTOP'
|
||||
if ($familyKeySet.Add($surfaceLaptopBaseKey)) {
|
||||
$familyKeyCandidates.Add($surfaceLaptopBaseKey) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Surface Studio (1st Gen) maps to the base "Surface Studio" SKU table row.
|
||||
if (($ModelName -match '(?i)^Surface\s+Studio\b') -and ($ModelName -match '(?i)\bGen\b')) {
|
||||
$surfaceStudioBaseKey = 'SURFACE STUDIO'
|
||||
if ($familyKeySet.Add($surfaceStudioBaseKey)) {
|
||||
$familyKeyCandidates.Add($surfaceStudioBaseKey) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Surface Laptop 3/4 AMD/Intel packs map to the "Surface Laptop 3/4" SystemModel rows in the SKU table.
|
||||
if ($ModelName -match '(?i)^Surface\s+Laptop\s+(3|4)\b' -and $ModelName -match '(?i)\b(AMD|Intel)\b') {
|
||||
$generationMatch = [regex]::Match($ModelName, '(?i)^Surface\s+Laptop\s+(3|4)\b')
|
||||
if ($generationMatch.Success) {
|
||||
$surfaceLaptopGenBaseKey = "SURFACE LAPTOP $($generationMatch.Groups[1].Value)"
|
||||
if ($familyKeySet.Add($surfaceLaptopGenBaseKey)) {
|
||||
$familyKeyCandidates.Add($surfaceLaptopGenBaseKey) | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Match by any candidate key against the SKU table
|
||||
$skuMatches = @($skuIndex | Where-Object {
|
||||
$deviceKey = ConvertTo-SurfaceComparableName -Text $_.Device
|
||||
$modelKey = ConvertTo-SurfaceComparableName -Text $_.SystemModel
|
||||
|
||||
foreach ($candidateKey in $familyKeyCandidates) {
|
||||
if ($deviceKey -eq $candidateKey -or $modelKey -eq $candidateKey) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
})
|
||||
|
||||
# Surface Hub 2 driver packs cover Surface Hub 2S + Surface Hub 3 devices.
|
||||
# The System SKU table does not have a "Surface Hub 2" row, so map Hub 2 to all Hub SKUs.
|
||||
if ($ModelName -match '(?i)^Surface\s+Hub\s+2\b') {
|
||||
$hubSkuRows = @($skuIndex | Where-Object { $_.Device -match '(?i)^Surface\s+Hub' })
|
||||
if ($hubSkuRows.Count -gt 0) {
|
||||
$skuMatches = @($hubSkuRows)
|
||||
}
|
||||
}
|
||||
|
||||
# Surface 3: refine down to the correct SKU row based on the model variant text
|
||||
# Use normalized text so punctuation/Unicode differences don't drop matches to zero.
|
||||
if ($ModelName -match '(?i)^Surface\s+3\b') {
|
||||
$modelNorm = ConvertTo-SurfaceComparableName -Text $ModelName
|
||||
|
||||
if ($modelNorm -match '(?i)\bWI\s+FI\b') {
|
||||
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bWI\s+FI\b' })
|
||||
}
|
||||
elseif ($modelNorm -match '(?i)\bVERIZON\b') {
|
||||
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bVERIZON\b' })
|
||||
}
|
||||
elseif ($modelNorm -match '(?i)\bOUTSIDE\s+OF\s+NORTH\s+AMERICA\b|\bY\s+MOBILE\b') {
|
||||
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bOUTSIDE\s+OF\s+NORTH\s+AMERICA\b|\bY\s+MOBILE\b' })
|
||||
}
|
||||
elseif ($modelNorm -match '(?i)\bNORTH\s+AMERICA\b') {
|
||||
# "North America (non-AT&T)" should map to the North America row (not AT&T/Verizon/outside-of-North-America)
|
||||
$skuMatches = @($skuMatches | Where-Object {
|
||||
$deviceNorm = ConvertTo-SurfaceComparableName -Text $_.Device
|
||||
($deviceNorm -match '(?i)\bNORTH\s+AMERICA\b') -and
|
||||
($deviceNorm -notmatch '(?i)\bOUTSIDE\b|\bY\s+MOBILE\b') -and
|
||||
($deviceNorm -notmatch '(?i)\bAT\s+T\b|\bVERIZON\b')
|
||||
})
|
||||
}
|
||||
elseif (($modelNorm -match '(?i)\bAT\s+T\b') -and ($modelNorm -notmatch '(?i)\bNON\s+AT\s+T\b')) {
|
||||
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bAT\s+T\b' })
|
||||
}
|
||||
}
|
||||
|
||||
# Surface Go: keep LTE SKU only for LTE-only models; exclude LTE SKU for non-LTE-only models.
|
||||
# If the model name includes BOTH LTE and non-LTE variants (joined with "and"), do not filter.
|
||||
# Surface Go 3 driver packs are treated as covering LTE + non-LTE unless explicitly labeled otherwise.
|
||||
if ($ModelName -match '(?i)^Surface\s+Go\b') {
|
||||
$isSurfaceGo3Base = ($ModelName -match '(?i)^Surface\s+Go\s+3\b') -and ($ModelName -notmatch '(?i)\bLTE\b')
|
||||
|
||||
if (-not $isSurfaceGo3Base) {
|
||||
if (-not ($hasLtePart -and $hasNonLtePart)) {
|
||||
if ($ModelName -match '(?i)\bLTE\b') {
|
||||
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bLTE\b' })
|
||||
}
|
||||
else {
|
||||
$skuMatches = @($skuMatches | Where-Object { $_.Device -notmatch '(?i)\bLTE\b' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Surface Pro 9 with 5G (SQ3): keep only the 5G SKU rows (U.S. + outside of U.S.).
|
||||
if (($ModelName -match '(?i)^Surface\s+Pro\s+9\b') -and ($ModelName -match '(?i)\b5G\b')) {
|
||||
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\b5G\b' })
|
||||
}
|
||||
|
||||
# Surface Pro 10: split non-5G vs 5G SKU rows so the two driver packs don't share the same SystemSKUs.
|
||||
if ($ModelName -match '(?i)^Surface\s+Pro\s+10\b') {
|
||||
if ($ModelName -match '(?i)\b5G\b') {
|
||||
$skuMatches = @($skuMatches | Where-Object {
|
||||
($_.SystemSku -match '^SURFACE_PRO_10_WITH_5G_FOR_BUSINESS_') -or
|
||||
($_.Device -match '(?i)\bwith\s+5G\b')
|
||||
})
|
||||
}
|
||||
else {
|
||||
$skuMatches = @($skuMatches | Where-Object { $_.SystemSku -eq 'SURFACE_PRO_10_FOR_BUSINESS_2079' })
|
||||
}
|
||||
}
|
||||
|
||||
# Surface Pro with LTE Advanced: restrict to the LTE Advanced (5th Gen) SKU.
|
||||
if ($ModelName -match '(?i)^Surface\s+Pro\s+with\s+LTE\s+Advanced\b') {
|
||||
$skuMatches = @($skuMatches | Where-Object { $_.SystemSku -eq 'SURFACE_PRO_1807' })
|
||||
}
|
||||
|
||||
# Surface Laptop 3/4: filter to AMD vs Intel rows (prevents AMD packs from inheriting Intel SKUs and vice-versa).
|
||||
if ($ModelName -match '(?i)^Surface\s+Laptop\s+(3|4)\b') {
|
||||
if ($ModelName -match '(?i)\bAMD\b') {
|
||||
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bAMD\b' })
|
||||
}
|
||||
elseif ($ModelName -match '(?i)\bIntel\b') {
|
||||
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bIntel\b' })
|
||||
}
|
||||
}
|
||||
|
||||
# Apply architecture filtering when we can infer it
|
||||
if ($archHint -eq 'ARM64') {
|
||||
# ARM variants are typically called out as Snapdragon / SQ3 / 5G in the Learn table
|
||||
$skuMatches = @($skuMatches | Where-Object {
|
||||
($_.Device -match '(?i)Snapdragon|SQ3|with 5G') -or
|
||||
($_.SystemModel -match '(?i)Snapdragon|SQ3|with 5G')
|
||||
})
|
||||
}
|
||||
elseif ($archHint -eq 'x64') {
|
||||
# x64 variants are often NOT labeled "Intel" in the Learn table (e.g. Surface Pro 9).
|
||||
# Treat "not Snapdragon/SQ3/5G" as the x64 bucket.
|
||||
$skuMatches = @($skuMatches | Where-Object {
|
||||
($_.Device -notmatch '(?i)Snapdragon|SQ3|with 5G') -and
|
||||
($_.SystemModel -notmatch '(?i)Snapdragon|SQ3|with 5G')
|
||||
})
|
||||
}
|
||||
|
||||
$skus = @($skuMatches | ForEach-Object { $_.SystemSku } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
|
||||
return $skus
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function `
|
||||
Get-SurfaceDriverIndexCachePath, `
|
||||
Import-SurfaceDriverIndexCache, `
|
||||
Save-SurfaceDriverIndexCache, `
|
||||
ConvertTo-SurfaceComparableName, `
|
||||
Get-SurfaceSystemSkuReferenceIndex, `
|
||||
Get-SurfaceDownloadCenterDetails, `
|
||||
Get-SurfaceSystemSkuListForMicrosoftDriver
|
||||
@@ -0,0 +1,822 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Provides common functions for driver management, including compression, mapping, and existence checks.
|
||||
|
||||
.DESCRIPTION
|
||||
The FFU.Common.Drivers module contains a set of shared functions used across the FFU project for handling driver packages.
|
||||
This includes compressing driver folders into WIM files for efficient storage and deployment, maintaining a JSON-based mapping
|
||||
of downloaded drivers to their respective makes and models, and checking for the pre-existence of driver packages to avoid
|
||||
redundant downloads.
|
||||
#>
|
||||
function Compress-DriverFolderToWim {
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateScript({ Test-Path -Path $_ -PathType Container })]
|
||||
[string]$SourceFolderPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DestinationWimPath,
|
||||
|
||||
[Parameter()]
|
||||
[string]$WimName, # Optional, defaults to folder name
|
||||
|
||||
[Parameter()]
|
||||
[string]$WimDescription, # Optional, defaults to folder name
|
||||
|
||||
[Parameter()]
|
||||
[bool]$PreserveSource = $false # When $true, do not delete source folder; create marker for deferred cleanup
|
||||
)
|
||||
|
||||
WriteLog "Starting compression of folder '$SourceFolderPath' to '$DestinationWimPath'."
|
||||
|
||||
# Default WIM Name and Description to the source folder name if not provided
|
||||
$sourceFolderName = Split-Path -Path $SourceFolderPath -Leaf
|
||||
if ([string]::IsNullOrWhiteSpace($WimName)) {
|
||||
$WimName = $sourceFolderName
|
||||
WriteLog "WIM Name not provided, defaulting to source folder name: '$WimName'."
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($WimDescription)) {
|
||||
$WimDescription = $sourceFolderName
|
||||
WriteLog "WIM Description not provided, defaulting to source folder name: '$WimDescription'."
|
||||
}
|
||||
|
||||
# Ensure destination directory exists
|
||||
$destinationDir = Split-Path -Path $DestinationWimPath -Parent
|
||||
if (-not (Test-Path -Path $destinationDir -PathType Container)) {
|
||||
WriteLog "Creating destination directory: $destinationDir"
|
||||
try {
|
||||
New-Item -Path $destinationDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to create destination directory '$destinationDir': $($_.Exception.Message)"
|
||||
return $false # Indicate failure
|
||||
}
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ShouldProcess("Folder '$SourceFolderPath'", "Compress to WIM '$DestinationWimPath'")) {
|
||||
try {
|
||||
# Construct arguments for dism.exe
|
||||
$dismArgs = "/Capture-Image /ImageFile:`"$DestinationWimPath`" /CaptureDir:`"$SourceFolderPath`" /Name:`"$WimName`" /Description:`"$WimDescription`" /Compress:Max /CheckIntegrity /Quiet"
|
||||
|
||||
WriteLog "Executing dism.exe via Invoke-Process with arguments:"
|
||||
WriteLog "dism.exe $dismArgs"
|
||||
|
||||
# Call Invoke-Process (assumed to be available from FFUUI.Core.psm1 or another imported module)
|
||||
# Invoke-Process is expected to throw an exception for non-zero exit codes.
|
||||
Invoke-Process -FilePath "dism.exe" -ArgumentList $dismArgs -Wait $true
|
||||
|
||||
WriteLog "Successfully compressed '$SourceFolderPath' to '$DestinationWimPath' using dism.exe."
|
||||
|
||||
# Remove the source folder after successful compression
|
||||
if ($PreserveSource) {
|
||||
WriteLog "Preserving source driver folder for deferred WinPE driver harvesting: $SourceFolderPath"
|
||||
try {
|
||||
$markerFile = Join-Path -Path $SourceFolderPath -ChildPath '__PreservedForPEDrivers.txt'
|
||||
if (-not (Test-Path -Path $markerFile -PathType Leaf)) {
|
||||
New-Item -Path $markerFile -ItemType File -Force | Out-Null
|
||||
WriteLog "Created preservation marker file: $markerFile"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Failed to create preservation marker in $SourceFolderPath. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Removing source driver folder: $SourceFolderPath"
|
||||
try {
|
||||
Remove-Item -Path $SourceFolderPath -Recurse -Force -ErrorAction Stop
|
||||
WriteLog "Successfully removed source folder '$SourceFolderPath'."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Failed to remove source folder '$SourceFolderPath'. Error: $($_.Exception.Message)"
|
||||
# Do not fail the whole operation, just log a warning.
|
||||
}
|
||||
}
|
||||
|
||||
return $true # Indicate success
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to compress folder '$SourceFolderPath' to WIM '$DestinationWimPath' using dism.exe."
|
||||
WriteLog "Error details: $($_.Exception.Message)"
|
||||
# Check if the error message contains details about the DISM log (dism.exe output might be in the exception)
|
||||
if ($_.Exception.Message -match 'DISM log file can be found at (.*)') {
|
||||
$dismLogPath = $matches[1].Trim()
|
||||
WriteLog "Check the DISM log for more details: $dismLogPath"
|
||||
}
|
||||
return $false # Indicate failure
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Compression operation skipped due to -WhatIf."
|
||||
return $false # Indicate skipped operation
|
||||
}
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SECTION: Driver Mapping Function
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
function Update-DriverMappingJson {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[array]$DownloadedDrivers, # Array of PSCustomObjects with Make, Model, DriverPath
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder # Base drivers folder (e.g., C:\FFUDevelopment\Drivers)
|
||||
)
|
||||
|
||||
$mappingFilePath = Join-Path -Path $DriversFolder -ChildPath "DriverMapping.json"
|
||||
WriteLog "Updating driver mapping file at: $mappingFilePath"
|
||||
|
||||
# Load existing mapping file or create a new list
|
||||
$mappingList = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
if (Test-Path -Path $mappingFilePath -PathType Leaf) {
|
||||
try {
|
||||
$existingJson = Get-Content -Path $mappingFilePath -Raw | ConvertFrom-Json
|
||||
# Ensure it's a collection before adding to the list
|
||||
if ($existingJson -is [array]) {
|
||||
# Iterate through the array to avoid type conversion issues with AddRange
|
||||
foreach ($item in $existingJson) {
|
||||
$mappingList.Add($item)
|
||||
}
|
||||
}
|
||||
else {
|
||||
$mappingList.Add($existingJson)
|
||||
}
|
||||
WriteLog "Loaded $($mappingList.Count) existing entries from $mappingFilePath"
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Could not read or parse existing DriverMapping.json. A new file will be created. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
$updatedCount = 0
|
||||
$addedCount = 0
|
||||
|
||||
$hpSystemIdCache = @{}
|
||||
$normalizeHpName = {
|
||||
param([string]$text)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($text)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return ([regex]::Replace($text.ToLowerInvariant(), '[^a-z0-9]', ''))
|
||||
}
|
||||
$getHpSystemId = {
|
||||
param([string]$modelName)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($modelName)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($hpSystemIdCache.ContainsKey($modelName)) {
|
||||
return $hpSystemIdCache[$modelName]
|
||||
}
|
||||
|
||||
$hpFolder = Join-Path -Path $DriversFolder -ChildPath 'HP'
|
||||
if (-not (Test-Path -Path $hpFolder -PathType Container)) {
|
||||
$hpSystemIdCache[$modelName] = $null
|
||||
return $null
|
||||
}
|
||||
|
||||
$platformListXml = Join-Path -Path $hpFolder -ChildPath 'PlatformList.xml'
|
||||
$platformListCab = Join-Path -Path $hpFolder -ChildPath 'platformList.cab'
|
||||
if (-not (Test-Path -Path $platformListXml -PathType Leaf)) {
|
||||
try {
|
||||
WriteLog "Attempting to refresh HP PlatformList.xml for SystemID lookup."
|
||||
Start-BitsTransferWithRetry -Source 'https://hpia.hpcloud.hp.com/ref/platformList.cab' -Destination $platformListCab -ErrorAction Stop
|
||||
if (Test-Path -Path $platformListXml) { Remove-Item -Path $platformListXml -Force -ErrorAction SilentlyContinue }
|
||||
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$platformListCab`"", "`"$platformListXml`"") -ErrorAction Stop | Out-Null
|
||||
if (Test-Path -Path $platformListCab) { Remove-Item -Path $platformListCab -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to refresh HP PlatformList.xml: $($_.Exception.Message)"
|
||||
$hpSystemIdCache[$modelName] = $null
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
[xml]$platformListContent = Get-Content -Path $platformListXml -Raw -Encoding UTF8 -ErrorAction Stop
|
||||
|
||||
$targetName = $modelName.Trim()
|
||||
$normalizedTarget = & $normalizeHpName $targetName
|
||||
|
||||
$modelMatch = $platformListContent.ImagePal.Platform | Where-Object {
|
||||
[string]::Equals($_.ProductName.'#text'.Trim(), $targetName, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} | Select-Object -First 1
|
||||
|
||||
if (-not $modelMatch -and $normalizedTarget) {
|
||||
$modelMatch = $platformListContent.ImagePal.Platform | Where-Object {
|
||||
$candidateName = $_.ProductName.'#text'
|
||||
$normalizedCandidate = & $normalizeHpName $candidateName
|
||||
$normalizedCandidate -eq $normalizedTarget
|
||||
} | Select-Object -First 1
|
||||
}
|
||||
|
||||
if (-not $modelMatch -and $normalizedTarget) {
|
||||
$modelMatch = $platformListContent.ImagePal.Platform | Where-Object {
|
||||
$candidateName = $_.ProductName.'#text'
|
||||
$normalizedCandidate = & $normalizeHpName $candidateName
|
||||
($normalizedCandidate -like "*$normalizedTarget*") -or ($normalizedTarget -like "*$normalizedCandidate*")
|
||||
} | Select-Object -First 1
|
||||
}
|
||||
|
||||
if ($modelMatch -and -not [string]::IsNullOrWhiteSpace($modelMatch.SystemID)) {
|
||||
$resolvedId = $modelMatch.SystemID.Trim().ToUpperInvariant()
|
||||
$hpSystemIdCache[$modelName] = $resolvedId
|
||||
return $resolvedId
|
||||
}
|
||||
else {
|
||||
WriteLog "HP SystemId lookup: no match found in PlatformList.xml for model '$modelName'."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to parse HP PlatformList.xml for model '$modelName': $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$hpSystemIdCache[$modelName] = $null
|
||||
return $null
|
||||
}
|
||||
|
||||
foreach ($driver in $DownloadedDrivers) {
|
||||
if (-not $driver.PSObject.Properties['Make'] -or -not $driver.PSObject.Properties['Model'] -or -not $driver.PSObject.Properties['DriverPath'] -or [string]::IsNullOrWhiteSpace($driver.DriverPath)) {
|
||||
WriteLog "Skipping driver entry due to missing or empty Make, Model, or DriverPath. Details: $(($driver | ConvertTo-Json -Compress -Depth 3))"
|
||||
continue
|
||||
}
|
||||
|
||||
$systemIdValue = $null
|
||||
$machineTypeValue = $null
|
||||
|
||||
if ($driver.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($driver.SystemId)) {
|
||||
$systemIdValue = $driver.SystemId.Trim().ToUpperInvariant()
|
||||
}
|
||||
if ($driver.PSObject.Properties['MachineType'] -and -not [string]::IsNullOrWhiteSpace($driver.MachineType)) {
|
||||
$machineTypeValue = $driver.MachineType.Trim()
|
||||
}
|
||||
|
||||
switch ($driver.Make) {
|
||||
'Dell' {
|
||||
if (-not $systemIdValue -and $driver.Model -match '\(([^)]+)\)\s*$') {
|
||||
$systemIdValue = $matches[1].Trim().ToUpperInvariant()
|
||||
}
|
||||
}
|
||||
'HP' {
|
||||
if (-not $systemIdValue) {
|
||||
$systemIdValue = & $getHpSystemId $driver.Model
|
||||
}
|
||||
}
|
||||
'Lenovo' {
|
||||
if (-not $machineTypeValue -and $driver.Model -match '\(([^)]+)\)\s*$') {
|
||||
$machineTypeValue = $matches[1].Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Microsoft Surface: resolve System SKU list (best-effort) using Sources A + C and cached results
|
||||
$surfaceSystemSkuList = @()
|
||||
if ($driver.Make -eq 'Microsoft') {
|
||||
if ($driver.PSObject.Properties['Link'] -and -not [string]::IsNullOrWhiteSpace($driver.Link)) {
|
||||
try {
|
||||
$surfaceSystemSkuList = Get-SurfaceSystemSkuListForMicrosoftDriver -DriversFolder $DriversFolder -ModelName $driver.Model -ModelLink $driver.Link
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Failed to resolve Surface SystemSku list for '$($driver.Model)'. Error: $($_.Exception.Message)"
|
||||
$surfaceSystemSkuList = @()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$existingEntry = $mappingList | Where-Object { $_.Manufacturer -eq $driver.Make -and $_.Model -eq $driver.Model } | Select-Object -First 1
|
||||
|
||||
if ($null -ne $existingEntry) {
|
||||
$entryUpdated = $false
|
||||
if ($existingEntry.DriverPath -ne $driver.DriverPath) {
|
||||
WriteLog "Updating driver path for '$($driver.Make) - $($driver.Model)' from '$($existingEntry.DriverPath)' to '$($driver.DriverPath)'."
|
||||
$existingEntry.DriverPath = $driver.DriverPath
|
||||
$entryUpdated = $true
|
||||
}
|
||||
|
||||
if ($driver.Make -in @('HP', 'Dell') -and -not [string]::IsNullOrWhiteSpace($systemIdValue)) {
|
||||
if ($existingEntry.PSObject.Properties['SystemId']) {
|
||||
if ($existingEntry.SystemId -ne $systemIdValue) {
|
||||
WriteLog "Updating SystemId for '$($driver.Make) - $($driver.Model)' to '$systemIdValue'."
|
||||
$existingEntry.SystemId = $systemIdValue
|
||||
$entryUpdated = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Adding SystemId '$systemIdValue' for '$($driver.Make) - $($driver.Model)'."
|
||||
$existingEntry | Add-Member -NotePropertyName SystemId -NotePropertyValue $systemIdValue
|
||||
$entryUpdated = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($driver.Make -eq 'Lenovo' -and -not [string]::IsNullOrWhiteSpace($machineTypeValue)) {
|
||||
if ($existingEntry.PSObject.Properties['MachineType']) {
|
||||
if ($existingEntry.MachineType -ne $machineTypeValue) {
|
||||
WriteLog "Updating MachineType for '$($driver.Make) - $($driver.Model)' to '$machineTypeValue'."
|
||||
$existingEntry.MachineType = $machineTypeValue
|
||||
$entryUpdated = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Adding MachineType '$machineTypeValue' for '$($driver.Make) - $($driver.Model)'."
|
||||
$existingEntry | Add-Member -NotePropertyName MachineType -NotePropertyValue $machineTypeValue
|
||||
$entryUpdated = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($driver.Make -eq 'Microsoft' -and $surfaceSystemSkuList -and $surfaceSystemSkuList.Count -gt 0) {
|
||||
$desiredSkus = @($surfaceSystemSkuList | Sort-Object -Unique)
|
||||
if ($existingEntry.PSObject.Properties['SystemSku']) {
|
||||
$currentSkus = @($existingEntry.SystemSku)
|
||||
$currentNormalized = @($currentSkus | ForEach-Object { if ($null -ne $_) { $_.ToString().Trim().ToUpperInvariant() } }) | Sort-Object -Unique
|
||||
$desiredNormalized = @($desiredSkus | ForEach-Object { if ($null -ne $_) { $_.ToString().Trim().ToUpperInvariant() } }) | Sort-Object -Unique
|
||||
|
||||
if (($currentNormalized -join '|') -ne ($desiredNormalized -join '|')) {
|
||||
WriteLog "Updating SystemSku list for 'Microsoft - $($driver.Model)'."
|
||||
$existingEntry.SystemSku = $desiredSkus
|
||||
$entryUpdated = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Adding SystemSku list for 'Microsoft - $($driver.Model)'."
|
||||
$existingEntry | Add-Member -NotePropertyName SystemSku -NotePropertyValue $desiredSkus
|
||||
$entryUpdated = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($entryUpdated) {
|
||||
$updatedCount++
|
||||
}
|
||||
}
|
||||
else {
|
||||
$newEntry = [PSCustomObject]@{
|
||||
Manufacturer = $driver.Make
|
||||
Model = $driver.Model
|
||||
DriverPath = $driver.DriverPath
|
||||
}
|
||||
|
||||
if ($driver.Make -in @('HP', 'Dell') -and -not [string]::IsNullOrWhiteSpace($systemIdValue)) {
|
||||
$newEntry | Add-Member -NotePropertyName SystemId -NotePropertyValue $systemIdValue
|
||||
}
|
||||
if ($driver.Make -eq 'Lenovo' -and -not [string]::IsNullOrWhiteSpace($machineTypeValue)) {
|
||||
$newEntry | Add-Member -NotePropertyName MachineType -NotePropertyValue $machineTypeValue
|
||||
}
|
||||
if ($driver.Make -eq 'Microsoft' -and $surfaceSystemSkuList -and $surfaceSystemSkuList.Count -gt 0) {
|
||||
$newEntry | Add-Member -NotePropertyName SystemSku -NotePropertyValue @($surfaceSystemSkuList | Sort-Object -Unique)
|
||||
}
|
||||
|
||||
$mappingList.Add($newEntry)
|
||||
WriteLog "Adding new mapping for '$($driver.Make) - $($driver.Model)' with path '$($driver.DriverPath)'."
|
||||
$addedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if ($updatedCount -gt 0 -or $addedCount -gt 0) {
|
||||
try {
|
||||
# Sort the list for consistency before saving
|
||||
$sortedList = $mappingList | Sort-Object -Property Manufacturer, Model
|
||||
$sortedList | ConvertTo-Json -Depth 5 | Set-Content -Path $mappingFilePath -Encoding UTF8
|
||||
WriteLog "Successfully saved DriverMapping.json with $addedCount new entries and $updatedCount updated entries."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error saving updated DriverMapping.json: $($_.Exception.Message)"
|
||||
throw "Failed to save driver mapping file."
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "No changes needed for DriverMapping.json."
|
||||
}
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SECTION: Driver Existence Check Function
|
||||
# --------------------------------------------------------------------------
|
||||
function Test-ExistingDriver {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Make,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Model,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Identifier,
|
||||
|
||||
[Parameter()]
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null
|
||||
)
|
||||
|
||||
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
|
||||
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $Model
|
||||
$driverRelativePath = Join-Path -Path $Make -ChildPath $Model
|
||||
|
||||
# Check for WIM file first
|
||||
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($Model).wim"
|
||||
if (Test-Path -Path $wimFilePath -PathType Leaf) {
|
||||
$status = "Already downloaded (WIM)"
|
||||
WriteLog "Driver WIM for '$Identifier' already exists at '$wimFilePath'."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $Identifier -Status $status }
|
||||
$wimRelativePath = Join-Path -Path $Make -ChildPath "$($Model).wim"
|
||||
return [PSCustomObject]@{
|
||||
Model = $Identifier # Return original identifier
|
||||
Status = $status
|
||||
Success = $true
|
||||
DriverPath = $wimRelativePath
|
||||
}
|
||||
}
|
||||
|
||||
# Check for existing driver folder
|
||||
if (Test-Path -Path $modelPath -PathType Container) {
|
||||
$folderSize = (Get-ChildItem -Path $modelPath -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($folderSize -gt 1MB) {
|
||||
$status = "Already downloaded"
|
||||
WriteLog "Drivers for '$Identifier' already exist in '$modelPath'."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $Identifier -Status $status }
|
||||
return [PSCustomObject]@{
|
||||
Model = $Identifier # Return original identifier
|
||||
Status = $status
|
||||
Success = $true
|
||||
DriverPath = $driverRelativePath
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Driver folder '$modelPath' for '$Identifier' exists but is empty or very small. Re-downloading."
|
||||
}
|
||||
}
|
||||
|
||||
# If neither WIM nor a valid folder exists, return null
|
||||
return $null
|
||||
}
|
||||
function Get-LenovoPSREFToken {
|
||||
|
||||
<#
|
||||
.DESCRIPTION
|
||||
Retrieves the Lenovo PSREF token from the Edge browser's local storage.
|
||||
|
||||
.NOTES
|
||||
|
||||
Lenovo's PSREF site creates a cookie/token via javascript when navigating to the PSREF site. This cookie only needs
|
||||
to be retrieved once on a single machine, and every machine within the same network will be able to access the PSREF API.
|
||||
|
||||
Using Invoke-Webrequest with sessionvariable or websession doesn't work because the token is created by javascript.
|
||||
Using edge in headless mode with remote debugging enabled allows for the retrieval of the token via the DevTools protocol.
|
||||
|
||||
You couldn't be more unhappy about this solution than I am, but it works.
|
||||
|
||||
Why use PSREF and not catalogv2.xml? Catalogv2.xml doesn't include all models. PSREF provides an API that can be used to retrieve
|
||||
the friendly model and machine type information for both business and consumer models. Many EDU devices are deemed consumer.
|
||||
|
||||
System Update and other tools rely on the user to input machine type and model information, but finding the machine type is difficult for some.
|
||||
Our solution makes it easier to simply type the model name and you can match the machine type to the model name.
|
||||
|
||||
If you have a better solution, please submit a PR or open a discussion on Github. Happy to consider alternatives. An easy way to test
|
||||
if your alternative works is to see if you can retrieve 100e, 300w, 500w, etc. These don't show up in catalogv2.xml, but they do in PSREF.
|
||||
#>
|
||||
|
||||
$token = $null
|
||||
$socket = $null
|
||||
$edgeProcess = $null
|
||||
$tempProfile = $null
|
||||
$port = $null
|
||||
|
||||
function Get-FreeLocalTcpPort {
|
||||
$listener = $null
|
||||
try {
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
||||
$listener.Start()
|
||||
$endpoint = [System.Net.IPEndPoint]$listener.LocalEndpoint
|
||||
return $endpoint.Port
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $listener) {
|
||||
$listener.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-EdgeDevToolsPageTarget {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][int]$Port,
|
||||
[int]$MaxAttempts = 20,
|
||||
[int]$DelayMilliseconds = 500,
|
||||
[string]$UrlContains
|
||||
)
|
||||
|
||||
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
||||
try {
|
||||
$targets = Invoke-RestMethod -Uri "http://localhost:$Port/json" -ErrorAction Stop
|
||||
if ($null -ne $targets) {
|
||||
if ($targets -isnot [System.Array]) { $targets = @($targets) }
|
||||
$pageTargets = $targets | Where-Object { $_.type -eq 'page' }
|
||||
if (-not [string]::IsNullOrWhiteSpace($UrlContains)) {
|
||||
$pageTargets = $pageTargets | Where-Object {
|
||||
-not [string]::IsNullOrWhiteSpace($_.url) -and $_.url -like "*$UrlContains*"
|
||||
}
|
||||
}
|
||||
|
||||
$target = $pageTargets | Select-Object -First 1
|
||||
if ($null -ne $target) {
|
||||
return $target
|
||||
}
|
||||
|
||||
WriteLog "DevTools endpoint on port $Port returned targets but no page matched the criteria (attempt $attempt of $MaxAttempts)."
|
||||
}
|
||||
else {
|
||||
WriteLog "DevTools endpoint on port $Port returned no targets (attempt $attempt of $MaxAttempts)."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "DevTools endpoint on port $Port not ready (attempt $attempt of $MaxAttempts). Error: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds $DelayMilliseconds
|
||||
}
|
||||
|
||||
throw "Edge DevTools endpoint on port $Port did not expose a matching page target after $MaxAttempts attempts."
|
||||
}
|
||||
|
||||
try {
|
||||
$ffuDevelopmentRoot = Split-Path -Path $PSScriptRoot -Parent
|
||||
WriteLog "Derived FFUDevelopmentPath from module path: $ffuDevelopmentRoot"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ffuDevelopmentRoot)) {
|
||||
throw "FFUDevelopmentPath could not be resolved. Unable to create Edge profile."
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $ffuDevelopmentRoot -PathType Container)) {
|
||||
throw "Resolved FFUDevelopmentPath '$ffuDevelopmentRoot' does not exist."
|
||||
}
|
||||
|
||||
$tempProfile = Join-Path -Path $ffuDevelopmentRoot -ChildPath ("edge-psref-" + [guid]::NewGuid())
|
||||
WriteLog "Creating temporary Edge profile at $tempProfile."
|
||||
New-Item -ItemType Directory -Path $tempProfile -Force | Out-Null
|
||||
|
||||
$edgeExe = "$Env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe"
|
||||
$uri = 'https://psref.lenovo.com'
|
||||
$port = Get-FreeLocalTcpPort
|
||||
WriteLog "Using Edge DevTools port $port for Lenovo PSREF token retrieval."
|
||||
|
||||
$flags = "--headless=new --disable-gpu --remote-debugging-port=$port $uri --user-data-dir=`"$tempProfile`""
|
||||
$edgeProcess = Start-Process -FilePath $edgeExe -ArgumentList $flags -PassThru
|
||||
WriteLog "Edge process started with PID: $($edgeProcess.Id)."
|
||||
|
||||
$pageTarget = Get-EdgeDevToolsPageTarget -Port $port -MaxAttempts 40 -DelayMilliseconds 500 -UrlContains 'psref.lenovo.com'
|
||||
if (-not [string]::IsNullOrWhiteSpace($pageTarget.url)) {
|
||||
WriteLog "Selected DevTools target URL: $($pageTarget.url)"
|
||||
}
|
||||
|
||||
$wsUrl = $pageTarget.webSocketDebuggerUrl
|
||||
if ([string]::IsNullOrWhiteSpace($wsUrl)) {
|
||||
throw "Edge DevTools page target on port $port did not provide a WebSocket URL."
|
||||
}
|
||||
|
||||
$socket = [System.Net.WebSockets.ClientWebSocket]::new()
|
||||
$socket.ConnectAsync($wsUrl, [Threading.CancellationToken]::None).Wait()
|
||||
|
||||
function Send-DevToolsCommand {
|
||||
param([int]$id, [string]$method, [hashtable]$params = @{})
|
||||
$cmd = @{ id = $id; method = $method; params = $params } | ConvertTo-Json -Compress
|
||||
$data = [Text.Encoding]::UTF8.GetBytes($cmd)
|
||||
$socket.SendAsync([ArraySegment[byte]]$data, 'Text', $true, [Threading.CancellationToken]::None).Wait()
|
||||
}
|
||||
|
||||
$buffer = New-Object byte[] 8192
|
||||
|
||||
function Invoke-DevToolsValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][int]$CommandId,
|
||||
[Parameter(Mandatory = $true)][string]$Expression,
|
||||
[int]$MaxPolls = 25
|
||||
)
|
||||
|
||||
Send-DevToolsCommand -id $CommandId -method 'Runtime.evaluate' -params @{
|
||||
expression = $Expression
|
||||
returnByValue = $true
|
||||
awaitPromise = $true
|
||||
}
|
||||
|
||||
for ($poll = 1; $poll -le $MaxPolls; $poll++) {
|
||||
$localStream = $null
|
||||
try {
|
||||
$localStream = New-Object System.IO.MemoryStream
|
||||
do {
|
||||
$segment = [ArraySegment[byte]]::new($buffer)
|
||||
$result = $socket.ReceiveAsync($segment, [Threading.CancellationToken]::None).Result
|
||||
$localStream.Write($buffer, 0, $result.Count)
|
||||
} until ($result.EndOfMessage)
|
||||
|
||||
$jsonBytes = $localStream.ToArray()
|
||||
$jsonText = [Text.Encoding]::UTF8.GetString($jsonBytes)
|
||||
$previewPayload = $jsonText
|
||||
if (-not [string]::IsNullOrEmpty($previewPayload) -and $previewPayload.Length -gt 500) {
|
||||
$previewPayload = $previewPayload.Substring(0, 500) + '...'
|
||||
}
|
||||
WriteLog "DevTools eval payload (cmd $CommandId, poll $poll): $previewPayload"
|
||||
|
||||
$message = $null
|
||||
try {
|
||||
$message = $jsonText | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to parse DevTools eval payload for command id $CommandId (poll $poll): $($_.Exception.Message)"
|
||||
continue
|
||||
}
|
||||
|
||||
if ($message.PSObject.Properties['id'] -and $message.id -eq $CommandId) {
|
||||
if ($message.PSObject.Properties['error']) {
|
||||
$errorMessage = $message.error.message
|
||||
throw "Edge DevTools reported an error for expression '$Expression': $errorMessage"
|
||||
}
|
||||
|
||||
if ($message.PSObject.Properties['result'] -and $message.result.PSObject.Properties['result']) {
|
||||
$innerResult = $message.result.result
|
||||
return [PSCustomObject]@{
|
||||
Value = $innerResult.value
|
||||
Type = $innerResult.type
|
||||
Subtype = $innerResult.subtype
|
||||
}
|
||||
}
|
||||
|
||||
$serializedMessage = $message | ConvertTo-Json -Compress -Depth 5
|
||||
WriteLog "DevTools response for command id $CommandId lacked result data. Message: $serializedMessage"
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($message.PSObject.Properties['method']) {
|
||||
WriteLog "Received DevTools event '$($message.method)' while waiting for command id $CommandId."
|
||||
}
|
||||
else {
|
||||
WriteLog "Received DevTools message without id or method while waiting for command id $CommandId."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $localStream) {
|
||||
$localStream.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw "No DevTools response received for command id $CommandId after $MaxPolls polls."
|
||||
}
|
||||
|
||||
WriteLog "Waiting for PSREF page to initialize local storage context."
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$commandCounter = 1000
|
||||
$rawToken = $null
|
||||
$maxTokenAttempts = 12
|
||||
for ($attempt = 1; $attempt -le $maxTokenAttempts -and [string]::IsNullOrWhiteSpace($rawToken); $attempt++) {
|
||||
$commandCounter++
|
||||
$tokenResponse = Invoke-DevToolsValue -CommandId $commandCounter -Expression "window.localStorage?.getItem('asut')" -MaxPolls 25
|
||||
if ($null -ne $tokenResponse -and -not [string]::IsNullOrWhiteSpace($tokenResponse.Value)) {
|
||||
$rawToken = $tokenResponse.Value
|
||||
WriteLog "DevTools response for command id $commandCounter returned token length $($rawToken.Length)."
|
||||
break
|
||||
}
|
||||
|
||||
WriteLog "Lenovo PSREF token not yet available (attempt $attempt of $maxTokenAttempts)."
|
||||
|
||||
$commandCounter++
|
||||
$keysResponse = Invoke-DevToolsValue -CommandId $commandCounter -Expression "JSON.stringify(Object.keys(window.localStorage || {}))" -MaxPolls 10
|
||||
if ($null -ne $keysResponse -and -not [string]::IsNullOrWhiteSpace($keysResponse.Value)) {
|
||||
WriteLog "Current localStorage keys: $($keysResponse.Value)"
|
||||
}
|
||||
|
||||
$commandCounter++
|
||||
$cookieResponse = Invoke-DevToolsValue -CommandId $commandCounter -Expression "document.cookie" -MaxPolls 10
|
||||
if ($null -ne $cookieResponse -and -not [string]::IsNullOrWhiteSpace($cookieResponse.Value)) {
|
||||
WriteLog "document.cookie contents: $($cookieResponse.Value)"
|
||||
$cookieEntry = ($cookieResponse.Value -split ';') | ForEach-Object { $_.Trim() } | Where-Object { $_ -like 'asut=*' } | Select-Object -First 1
|
||||
if ($cookieEntry) {
|
||||
$rawToken = $cookieEntry.Substring($cookieEntry.IndexOf('=') + 1)
|
||||
WriteLog "Extracted Lenovo PSREF token from cookies with length $($rawToken.Length)."
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 750
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($rawToken)) {
|
||||
throw "Received empty Lenovo PSREF token from Edge DevTools after $maxTokenAttempts attempts."
|
||||
}
|
||||
|
||||
$token = "X-PSREF-USER-TOKEN=$rawToken"
|
||||
WriteLog "Retrieved Lenovo PSREF token: $token"
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to retrieve Lenovo PSREF token. Error: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $socket) {
|
||||
try {
|
||||
$socket.Dispose()
|
||||
WriteLog "Edge DevTools WebSocket disposed."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error disposing Edge DevTools WebSocket: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
$listeningPid = $null
|
||||
if ($null -ne $port) {
|
||||
try {
|
||||
$netstatOutput = netstat -ano -p TCP | Where-Object { $_ -match "127\.0\.0\.1:$port.*LISTENING" }
|
||||
if ($netstatOutput) {
|
||||
$listeningPid = ($netstatOutput -split '\s+')[-1]
|
||||
WriteLog "Found Edge process PID $listeningPid listening on port $port."
|
||||
}
|
||||
else {
|
||||
WriteLog "No process reported as listening on port $port."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Could not run netstat to find listening PID for port $port. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
$pidToKill = $null
|
||||
if ($null -ne $listeningPid) {
|
||||
$pidToKill = $listeningPid
|
||||
}
|
||||
elseif ($null -ne $edgeProcess -and -not $edgeProcess.HasExited) {
|
||||
$pidToKill = $edgeProcess.Id
|
||||
WriteLog "Falling back to initial Edge process PID $pidToKill for termination."
|
||||
}
|
||||
|
||||
if ($null -ne $pidToKill) {
|
||||
try {
|
||||
taskkill /PID $pidToKill /T /F | Out-Null
|
||||
WriteLog "Issued termination command for Edge process tree with PID: $pidToKill."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to terminate Edge process tree with PID: $pidToKill. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "No active Edge process found to terminate."
|
||||
}
|
||||
|
||||
if ($null -ne $edgeProcess) {
|
||||
try {
|
||||
$edgeProcess.WaitForExit(3000) | Out-Null
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error while waiting for Edge process PID $($edgeProcess.Id) to exit: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 250
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($tempProfile) -and (Test-Path -Path $tempProfile -PathType Container)) {
|
||||
$maxRemoveAttempts = 5
|
||||
$originalProgressPreference = $ProgressPreference
|
||||
try {
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
for ($removeAttempt = 1; $removeAttempt -le $maxRemoveAttempts; $removeAttempt++) {
|
||||
try {
|
||||
Remove-Item -Path $tempProfile -Recurse -Force -ErrorAction Stop
|
||||
WriteLog "Removed temporary Edge profile at $tempProfile."
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($removeAttempt -eq $maxRemoveAttempts) {
|
||||
WriteLog "Failed to remove temporary Edge profile at $tempProfile after $maxRemoveAttempts attempts. Error: $($_.Exception.Message)"
|
||||
}
|
||||
else {
|
||||
WriteLog "Temporary Edge profile still locked (attempt $removeAttempt of $maxRemoveAttempts). Retrying..."
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$ProgressPreference = $originalProgressPreference
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $token
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SECTION: Module Export
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Export-ModuleMember -Function `
|
||||
Compress-DriverFolderToWim, `
|
||||
Update-DriverMappingJson, `
|
||||
Test-ExistingDriver, `
|
||||
Get-LenovoPSREFToken
|
||||
@@ -0,0 +1,521 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Manages and executes multiple background tasks in parallel, with support for updating a WPF UI with progress.
|
||||
.DESCRIPTION
|
||||
This function provides a generic framework for running tasks in parallel using PowerShell's ForEach-Object -Parallel.
|
||||
It is designed to process an array of items, executing a specific task for each one. It can operate in two modes: UI mode and non-UI mode.
|
||||
|
||||
In UI mode, it updates a specified ListView control in a WPF window with the status of each item as it's being processed
|
||||
(e.g., Queued, Downloading, Completed, Error). It uses a dispatcher to ensure UI updates are thread-safe.
|
||||
|
||||
In non-UI mode, it runs the tasks and logs the status to the FFUDevelopment.log file.
|
||||
|
||||
The function determines the task to run via the -TaskType parameter and passes necessary arguments using -TaskArguments.
|
||||
It handles module imports and log file setup within each parallel runspace to ensure tasks have the necessary dependencies and logging capabilities.
|
||||
.PARAMETER ItemsToProcess
|
||||
An array of objects, where each object represents an item to be processed by a parallel task. This is a mandatory parameter.
|
||||
.PARAMETER ListViewControl
|
||||
(UI Mode) The WPF ListView control that the function will update with the status of each item. Defaults to $null.
|
||||
.PARAMETER IdentifierProperty
|
||||
The name of the property on the item objects that serves as a unique identifier (e.g., 'Name', 'Id').
|
||||
This is used to find and update the correct row in the ListView.
|
||||
.PARAMETER StatusProperty
|
||||
The name of the property on the item objects that holds the status string. This property will be updated with progress messages.
|
||||
.PARAMETER TaskType
|
||||
A string specifying which task to execute for each item. This is mandatory.
|
||||
Valid values are:
|
||||
- 'WingetDownload': Downloads a Winget application.
|
||||
- 'CopyBYO': Copies a user-provided application.
|
||||
- 'DownloadDriverByMake': Downloads drivers for a specific manufacturer.
|
||||
.PARAMETER TaskArguments
|
||||
A hashtable containing arguments required by the specific task being run (e.g., paths, API keys, configuration settings).
|
||||
.PARAMETER CompletedStatusText
|
||||
The status text to display when an item is processed successfully.
|
||||
.PARAMETER ErrorStatusPrefix
|
||||
A prefix for status messages when an error occurs.
|
||||
.PARAMETER WindowObject
|
||||
(UI Mode) The main WPF Window object, used to access the UI dispatcher for safe UI updates from background threads.
|
||||
.PARAMETER MainThreadLogPath
|
||||
The file path for the log file that should be used by all parallel threads. This ensures consistent logging.
|
||||
.PARAMETER ThrottleLimit
|
||||
The maximum number of parallel jobs to run concurrently. The default is 5.
|
||||
.NOTES
|
||||
This function relies on ForEach-Object -Parallel, which was introduced in PowerShell 7.
|
||||
When running in UI mode, both -WindowObject and -ListViewControl must be provided.
|
||||
The function dynamically imports required modules ('FFU.Common' and 'FFUUI.Core') into each parallel runspace.
|
||||
It uses a concurrent queue to manage intermediate progress updates from threads to the main UI thread, preventing UI blocking and providing more granular feedback.
|
||||
#>
|
||||
function Invoke-ParallelProcessing {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[array]$ItemsToProcess,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[object]$ListViewControl = $null,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$IdentifierProperty = 'Identifier',
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$StatusProperty = 'Status',
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('WingetDownload', 'CopyBYO', 'DownloadDriverByMake')]
|
||||
[string]$TaskType,
|
||||
[Parameter()]
|
||||
[hashtable]$TaskArguments = @{},
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$CompletedStatusText = "Completed",
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ErrorStatusPrefix = "Error: ",
|
||||
[Parameter(Mandatory = $false)]
|
||||
[object]$WindowObject = $null,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$MainThreadLogPath = $null,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$ThrottleLimit = 5
|
||||
)
|
||||
# Check if running in UI mode by verifying the types of the passed objects
|
||||
$isUiMode = ($null -ne $WindowObject -and $WindowObject -is [System.Windows.Window] -and $null -ne $ListViewControl -and $ListViewControl -is [System.Windows.Controls.ListView])
|
||||
|
||||
if ($isUiMode) {
|
||||
WriteLog "Invoke-ParallelProcessing started for $($ItemsToProcess.Count) items in ListView '$($ListViewControl.Name)'."
|
||||
}
|
||||
else {
|
||||
WriteLog "Invoke-ParallelProcessing started for $($ItemsToProcess.Count) items (non-UI mode)."
|
||||
}
|
||||
$resultsCollection = [System.Collections.Generic.List[object]]::new()
|
||||
$jobs = @()
|
||||
$totalItems = $ItemsToProcess.Count
|
||||
$processedCount = 0
|
||||
$completedIdentifiers = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
# Create a thread-safe queue for intermediate progress updates
|
||||
$progressQueue = New-Object System.Collections.Concurrent.ConcurrentQueue[hashtable]
|
||||
|
||||
# Define common paths locally within this function's scope
|
||||
$coreModulePath = $MyInvocation.MyCommand.Module.Path
|
||||
$coreModuleDirectory = Split-Path -Path $coreModulePath -Parent
|
||||
$ffuDevelopmentRoot = Split-Path -Path $coreModuleDirectory -Parent
|
||||
|
||||
# Paths to the module DIRECTORIES needed by the parallel threads
|
||||
$commonModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "FFU.Common"
|
||||
$uiCoreModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "FFUUI.Core"
|
||||
|
||||
# Use the explicitly passed MainThreadLogPath for the parallel jobs.
|
||||
# If not provided (e.g., older calls or direct module use without this param), it might be null.
|
||||
# The parallel job's Set-CommonCoreLogPath will handle null/empty paths by warning.
|
||||
$currentLogFilePathForJob = $MainThreadLogPath
|
||||
|
||||
$jobScopeVariables = $TaskArguments.Clone()
|
||||
$jobScopeVariables['_commonModulePath'] = $commonModulePathForJob
|
||||
$jobScopeVariables['_uiCoreModulePath'] = $uiCoreModulePathForJob
|
||||
$jobScopeVariables['_currentLogFilePathForJob'] = $currentLogFilePathForJob
|
||||
$jobScopeVariables['_progressQueue'] = $progressQueue
|
||||
|
||||
# Initial UI update needs to happen *before* starting the jobs
|
||||
# Update all items to a static "Processing..." status
|
||||
if ($isUiMode) {
|
||||
# Use the new $isUiMode flag
|
||||
foreach ($item in $ItemsToProcess) {
|
||||
$identifierValue = $item.$IdentifierProperty
|
||||
$initialStaticStatus = "Queued..."
|
||||
try {
|
||||
# Update the UI on the main thread to show the item is being queued for processing
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
|
||||
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $identifierValue -StatusProperty $StatusProperty -StatusValue $initialStaticStatus
|
||||
})
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error setting initial status for item '$identifierValue': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Queue items and start jobs using the pipeline and $using:
|
||||
try {
|
||||
# $jobScopeVariables and $TaskType are local here
|
||||
# Inside the -Parallel scriptblock, we access them with $using:
|
||||
$jobs = $ItemsToProcess | ForEach-Object -Parallel {
|
||||
$currentItem = $_
|
||||
$localJobArgs = $using:jobScopeVariables
|
||||
$localTaskType = $using:TaskType
|
||||
$localProgressQueue = $localJobArgs['_progressQueue']
|
||||
|
||||
# Initialize result hashtable
|
||||
$taskResult = $null
|
||||
$resultIdentifier = $null
|
||||
$resultStatus = "Error: Task type '$localTaskType' not recognized"
|
||||
$resultCode = 1 # Default to error
|
||||
|
||||
try {
|
||||
# Import modules needed for the task
|
||||
Import-Module $localJobArgs['_commonModulePath'] -Force
|
||||
Import-Module $localJobArgs['_uiCoreModulePath'] -Force
|
||||
|
||||
# Set the log path for this parallel thread
|
||||
Set-CommonCoreLogPath -Path $localJobArgs['_currentLogFilePathForJob']
|
||||
|
||||
# Execute the appropriate background task based on $localTaskType
|
||||
switch ($localTaskType) {
|
||||
'WingetDownload' {
|
||||
# Pass the progress queue and SkipWin32Json to the task function
|
||||
$wingetTaskArgs = @{
|
||||
ApplicationItemData = $currentItem
|
||||
AppListJsonPath = $localJobArgs['AppListJsonPath']
|
||||
AppsPath = $localJobArgs['AppsPath']
|
||||
OrchestrationPath = $localJobArgs['OrchestrationPath']
|
||||
ProgressQueue = $localProgressQueue
|
||||
WindowsArch = $localJobArgs['WindowsArch']
|
||||
SkipWin32Json = [bool]$localJobArgs['SkipWin32Json']
|
||||
}
|
||||
$taskResult = Start-WingetAppDownloadTask @wingetTaskArgs
|
||||
if ($null -ne $taskResult) {
|
||||
$resultIdentifier = $taskResult.Id
|
||||
$resultStatus = $taskResult.Status
|
||||
$resultCode = $taskResult.ResultCode
|
||||
}
|
||||
else {
|
||||
$resultIdentifier = $currentItem.Id # Fallback
|
||||
$resultStatus = "Error: WingetDownload task returned null"
|
||||
$resultCode = 1
|
||||
WriteLog $resultStatus
|
||||
}
|
||||
}
|
||||
'CopyBYO' {
|
||||
# Pass the progress queue to the task function
|
||||
$taskResult = Start-CopyBYOApplicationTask -ApplicationItemData $currentItem `
|
||||
-AppsPath $localJobArgs['AppsPath'] `
|
||||
-ProgressQueue $localProgressQueue
|
||||
if ($null -ne $taskResult) {
|
||||
$resultIdentifier = $taskResult.Name
|
||||
$resultStatus = $taskResult.Status
|
||||
$resultCode = if ($taskResult.Success) { 0 } else { 1 }
|
||||
}
|
||||
else {
|
||||
$resultIdentifier = $currentItem.Name # Fallback
|
||||
$resultStatus = "Error: CopyBYO task returned null"
|
||||
$resultCode = 1
|
||||
WriteLog $resultStatus
|
||||
}
|
||||
}
|
||||
'DownloadDriverByMake' {
|
||||
$make = $currentItem.Make
|
||||
# Ensure $resultIdentifier is set before the switch, using the main IdentifierProperty
|
||||
# This is crucial if a Make is unsupported or a task fails to return a result.
|
||||
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
|
||||
|
||||
switch ($make) {
|
||||
'Microsoft' {
|
||||
$taskResult = Save-MicrosoftDriversTask -DriverItemData $currentItem `
|
||||
-DriversFolder $localJobArgs['DriversFolder'] `
|
||||
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||
-Headers $localJobArgs['Headers'] `
|
||||
-UserAgent $localJobArgs['UserAgent'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim'] `
|
||||
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
|
||||
}
|
||||
'Dell' {
|
||||
$taskResult = Save-DellDriversTask -DriverItemData $currentItem `
|
||||
-DriversFolder $localJobArgs['DriversFolder'] `
|
||||
-WindowsArch $localJobArgs['WindowsArch'] `
|
||||
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim'] `
|
||||
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
|
||||
}
|
||||
'HP' {
|
||||
$taskResult = Save-HPDriversTask -DriverItemData $currentItem `
|
||||
-DriversFolder $localJobArgs['DriversFolder'] `
|
||||
-WindowsArch $localJobArgs['WindowsArch'] `
|
||||
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||
-WindowsVersion $localJobArgs['WindowsVersion'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim'] `
|
||||
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
|
||||
}
|
||||
'Lenovo' {
|
||||
$taskResult = Save-LenovoDriversTask -DriverItemData $currentItem `
|
||||
-DriversFolder $localJobArgs['DriversFolder'] `
|
||||
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||
-Headers $localJobArgs['Headers'] `
|
||||
-UserAgent $localJobArgs['UserAgent'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim'] `
|
||||
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
|
||||
}
|
||||
default {
|
||||
$unsupportedMakeMessage = "Error: Unsupported Make '$make' for driver download."
|
||||
WriteLog $unsupportedMakeMessage
|
||||
$resultStatus = $unsupportedMakeMessage
|
||||
$resultCode = 1
|
||||
# $resultIdentifier is already set from $currentItem.$($using:IdentifierProperty)
|
||||
$localProgressQueue.Enqueue(@{ Identifier = $resultIdentifier; Status = $resultStatus })
|
||||
# $taskResult remains null, handled below
|
||||
}
|
||||
}
|
||||
|
||||
# Consolidate result handling for 'DownloadDriverByMake'
|
||||
if ($null -ne $taskResult) {
|
||||
# $resultIdentifier is already $currentItem.$($using:IdentifierProperty)
|
||||
# We use the task's returned Model/Identifier for logging/status if needed,
|
||||
# but the primary identifier for UI updates should be consistent.
|
||||
$taskSpecificIdentifier = $null
|
||||
if ($taskResult.PSObject.Properties.Name -contains 'Model') { $taskSpecificIdentifier = $taskResult.Model }
|
||||
elseif ($taskResult.PSObject.Properties.Name -contains 'Identifier') { $taskSpecificIdentifier = $taskResult.Identifier }
|
||||
|
||||
$resultStatus = $taskResult.Status
|
||||
# Simplified success check. All driver tasks should now return a 'Success' property.
|
||||
if ($taskResult.PSObject.Properties.Name -contains 'Success') {
|
||||
$resultCode = if ($taskResult.Success) { 0 } else { 1 }
|
||||
}
|
||||
else {
|
||||
# Fallback for any task that *still* doesn't return 'Success'. This is now the exceptional case.
|
||||
WriteLog "Warning: Task for '$taskSpecificIdentifier' did not return a 'Success' property. Inferring from status: '$($taskResult.Status)'"
|
||||
if ($taskResult.Status -like 'Completed*' -or $taskResult.Status -like 'Already downloaded*' -or $taskResult.Status -like 'Compression successful*') {
|
||||
$resultCode = 0 # Treat as success
|
||||
}
|
||||
else {
|
||||
$resultCode = 1 # Treat as error
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($make -in ('Microsoft', 'Dell', 'HP', 'Lenovo')) {
|
||||
# This means a specific Make case was hit, but $taskResult was unexpectedly null
|
||||
$nullTaskResultMessage = "Error: Task for Make '$make' returned null."
|
||||
WriteLog $nullTaskResultMessage
|
||||
$resultStatus = $nullTaskResultMessage
|
||||
$resultCode = 1
|
||||
}
|
||||
}
|
||||
Default {
|
||||
# This handles unknown $localTaskType values
|
||||
$resultStatus = "Error: Task type '$localTaskType' not recognized"
|
||||
$resultCode = 1
|
||||
if ($currentItem -is [pscustomobject] -and $currentItem.PSObject.Properties.Name -match $using:IdentifierProperty) {
|
||||
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
|
||||
}
|
||||
else {
|
||||
$resultIdentifier = "UnknownItem"
|
||||
}
|
||||
WriteLog "Error in parallel job: Unknown TaskType '$localTaskType' provided for item '$resultIdentifier'."
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$resultStatus = "Error: $($_.Exception.Message)"
|
||||
$resultCode = 1
|
||||
# Try to get an identifier
|
||||
if ($currentItem -is [pscustomobject] -and $currentItem.PSObject.Properties.Name -match $using:IdentifierProperty) {
|
||||
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
|
||||
}
|
||||
else {
|
||||
$resultIdentifier = "UnknownItemOnError"
|
||||
}
|
||||
WriteLog "Exception during parallel task '$localTaskType' for item '$resultIdentifier': $($_.Exception.ToString())"
|
||||
$localProgressQueue.Enqueue(@{ Identifier = $resultIdentifier; Status = $resultStatus })
|
||||
}
|
||||
|
||||
$driverPathValue = $null
|
||||
if ($null -ne $taskResult -and $taskResult.PSObject.Properties.Name -contains 'DriverPath') {
|
||||
$driverPathValue = $taskResult.DriverPath
|
||||
}
|
||||
|
||||
# Return a consistent hashtable structure (final result)
|
||||
return @{
|
||||
Identifier = $resultIdentifier
|
||||
Status = $resultStatus
|
||||
ResultCode = $resultCode
|
||||
DriverPath = $driverPathValue
|
||||
}
|
||||
|
||||
} -ThrottleLimit $ThrottleLimit -AsJob
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error initiating ForEach-Object -Parallel: $($_.Exception.Message)"
|
||||
# Update all items to show a general startup error
|
||||
$errorStatus = "$ErrorStatusPrefix Failed to start processing"
|
||||
foreach ($item in $ItemsToProcess) {
|
||||
$identifier = $item.$IdentifierProperty
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { # Use $WindowObject
|
||||
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $identifier -StatusProperty $StatusProperty -StatusValue $errorStatus # Pass $WindowObject
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Check if any jobs failed to start immediately (e.g., module loading issues within the job)
|
||||
$failedJobs = $jobs | Where-Object { $_.State -eq 'Failed' -and $_.JobStateInfo.Reason }
|
||||
foreach ($failedJob in $failedJobs) {
|
||||
WriteLog "Job $($failedJob.Id) failed to start or failed early: $($failedJob.JobStateInfo.Reason)"
|
||||
# We don't easily know which item failed here without more complex mapping
|
||||
# Update overall status maybe?
|
||||
$processedCount++
|
||||
}
|
||||
# Filter out jobs that failed immediately
|
||||
$jobs = $jobs | Where-Object { $_.State -ne 'Failed' }
|
||||
|
||||
# Process job results and intermediate status updates without blocking the UI thread
|
||||
while ($jobs.Count -gt 0 -or -not $progressQueue.IsEmpty) {
|
||||
# Continue while jobs are running OR queue has messages
|
||||
|
||||
# 1. Process intermediate status updates from the queue
|
||||
$statusUpdate = $null
|
||||
while ($progressQueue.TryDequeue([ref]$statusUpdate)) {
|
||||
if ($null -ne $statusUpdate) {
|
||||
WriteLog "Dequeued progress update: $($statusUpdate | ConvertTo-Json -Compress)"
|
||||
$intermediateIdentifier = $statusUpdate.Identifier
|
||||
# If this item has already been marked as complete, skip this stale intermediate update
|
||||
if ($completedIdentifiers.Contains($intermediateIdentifier)) {
|
||||
WriteLog "Skipping stale intermediate status for already completed item: $intermediateIdentifier"
|
||||
continue
|
||||
}
|
||||
$intermediateStatus = $statusUpdate.Status
|
||||
if ($isUiMode) {
|
||||
# Update the UI with the intermediate status
|
||||
try {
|
||||
WriteLog "Dispatching INTERMEDIATE status for '$intermediateIdentifier': '$intermediateStatus'"
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
|
||||
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $intermediateIdentifier -StatusProperty $StatusProperty -StatusValue $intermediateStatus
|
||||
})
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error setting intermediate status for item '$intermediateIdentifier': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Log intermediate status if not in UI mode
|
||||
WriteLog "Intermediate Status for '$intermediateIdentifier': $intermediateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Check for completed jobs
|
||||
$completedJobs = $jobs | Where-Object { $_.State -in 'Completed', 'Failed', 'Stopped' }
|
||||
|
||||
if ($completedJobs) {
|
||||
foreach ($completedJob in $completedJobs) {
|
||||
$jobHandled = $false
|
||||
if ($completedJob.State -eq 'Failed') {
|
||||
$jobHandled = $true
|
||||
$finalIdentifier = "UnknownJob"
|
||||
WriteLog "Job $($completedJob.Id) failed: $($completedJob.Error)"
|
||||
$finalStatus = "$ErrorStatusPrefix Job Failed"
|
||||
$finalResultCode = 1
|
||||
$processedCount++
|
||||
|
||||
# --- DISPATCH FOR FAILED JOB ---
|
||||
$completedIdentifiers.Add($finalIdentifier) | Out-Null
|
||||
if ($isUiMode) {
|
||||
try {
|
||||
WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'"
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus })
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" })
|
||||
}
|
||||
catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" }
|
||||
}
|
||||
else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" }
|
||||
}
|
||||
elseif ($completedJob.HasMoreData) {
|
||||
$jobHandled = $true
|
||||
$jobResults = $completedJob | Receive-Job
|
||||
foreach ($result in $jobResults) {
|
||||
WriteLog "Received FINAL job result: $($result | ConvertTo-Json -Compress -Depth 3)"
|
||||
if ($null -ne $result -and $result -is [hashtable] -and $result.ContainsKey('Identifier')) {
|
||||
$finalIdentifier = $result.Identifier
|
||||
$status = $result.Status
|
||||
$finalResultCode = $result.ResultCode
|
||||
$finalStatus = if ($finalResultCode -eq 0) { $status } else { "$($ErrorStatusPrefix)$($status)" }
|
||||
$processedCount++
|
||||
}
|
||||
else {
|
||||
$finalIdentifier = "UnknownResult"
|
||||
WriteLog "Warning: Received unexpected final job result format: $($result | Out-String)"
|
||||
$finalStatus = "$ErrorStatusPrefix Invalid Result Format"
|
||||
$finalResultCode = 1
|
||||
$processedCount++
|
||||
}
|
||||
if ($null -ne $result) { $resultsCollection.Add($result) }
|
||||
|
||||
# --- DISPATCH PER RESULT ---
|
||||
$completedIdentifiers.Add($finalIdentifier) | Out-Null
|
||||
if ($isUiMode) {
|
||||
try {
|
||||
WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'"
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus })
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" })
|
||||
}
|
||||
catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" }
|
||||
}
|
||||
else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $jobHandled) {
|
||||
# Catches 'Completed' with no data
|
||||
$finalIdentifier = "UnknownJob"
|
||||
WriteLog "Job $($completedJob.Id) completed with state '$($completedJob.State)' but had no data."
|
||||
$finalStatus = "$ErrorStatusPrefix No Result Data"
|
||||
$finalResultCode = 1
|
||||
$processedCount++
|
||||
|
||||
# --- DISPATCH FOR NO-DATA JOB ---
|
||||
$completedIdentifiers.Add($finalIdentifier) | Out-Null
|
||||
if ($isUiMode) {
|
||||
try {
|
||||
WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'"
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus })
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" })
|
||||
}
|
||||
catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" }
|
||||
}
|
||||
else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" }
|
||||
}
|
||||
|
||||
# Remove the completed/failed job from the list and clean it up
|
||||
$jobs = $jobs | Where-Object { $_.Id -ne $completedJob.Id }
|
||||
Remove-Job -Job $completedJob -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# 3. Allow UI events to process and sleep briefly
|
||||
if ($isUiMode) {
|
||||
# Only sleep if jobs are still running AND the queue is empty (to avoid delaying UI updates)
|
||||
if ($jobs.Count -gt 0 -and $progressQueue.IsEmpty) {
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action] { }) | Out-Null
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
elseif (-not $progressQueue.IsEmpty) {
|
||||
# If queue has messages, process them immediately without sleeping
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action] { }) | Out-Null
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Non-UI mode, just sleep if jobs are running
|
||||
if ($jobs.Count -gt 0) {
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# Final cleanup of any remaining jobs (shouldn't be necessary with this loop logic, but good practice)
|
||||
if ($jobs.Count -gt 0) {
|
||||
WriteLog "Cleaning up $($jobs.Count) remaining jobs after loop exit."
|
||||
Remove-Job -Job $jobs -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($isUiMode) {
|
||||
WriteLog "Invoke-ParallelProcessing finished for ListView '$($ListViewControl.Name)'."
|
||||
# Final overall progress update
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
|
||||
Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processing complete. Processed $processedCount of $totalItems." -ProgressBarName "progressBar" -StatusLabelName "txtStatus"
|
||||
})
|
||||
}
|
||||
else {
|
||||
WriteLog "Invoke-ParallelProcessing finished (non-UI mode). Processed $processedCount of $totalItems."
|
||||
}
|
||||
|
||||
# Return all collected final results from jobs
|
||||
return $resultsCollection
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-ParallelProcessing
|
||||
@@ -0,0 +1,137 @@
|
||||
#
|
||||
# Module manifest for module 'FFU.Common'
|
||||
#
|
||||
# Generated by: Richard Balsley
|
||||
#
|
||||
# Generated on: 6/11/2025
|
||||
#
|
||||
|
||||
@{
|
||||
|
||||
# Script module or binary module file associated with this manifest.
|
||||
RootModule = 'FFU.Common.Core.psm1'
|
||||
|
||||
# Version number of this module.
|
||||
ModuleVersion = '0.0.1'
|
||||
|
||||
# Supported PSEditions
|
||||
# CompatiblePSEditions = @()
|
||||
|
||||
# ID used to uniquely identify this module
|
||||
GUID = '7dac2b8f-e65a-4997-961e-7a5ef5161901'
|
||||
|
||||
# Author of this module
|
||||
Author = 'Richard Balsley'
|
||||
|
||||
# Company or vendor of this module
|
||||
CompanyName = 'Unknown'
|
||||
|
||||
# Copyright statement for this module
|
||||
Copyright = '(c) Richard Balsley. All rights reserved.'
|
||||
|
||||
# Description of the functionality provided by this module
|
||||
Description = 'Common functions shared between FFU Builder UI and the BuildFFUVM.ps1 build script.'
|
||||
|
||||
# Minimum version of the PowerShell engine required by this module
|
||||
# PowerShellVersion = ''
|
||||
|
||||
# Name of the PowerShell host required by this module
|
||||
# PowerShellHostName = ''
|
||||
|
||||
# Minimum version of the PowerShell host required by this module
|
||||
# PowerShellHostVersion = ''
|
||||
|
||||
# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
|
||||
# DotNetFrameworkVersion = ''
|
||||
|
||||
# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
|
||||
# ClrVersion = ''
|
||||
|
||||
# Processor architecture (None, X86, Amd64) required by this module
|
||||
# ProcessorArchitecture = ''
|
||||
|
||||
# Modules that must be imported into the global environment prior to importing this module
|
||||
# RequiredModules = @()
|
||||
|
||||
# Assemblies that must be loaded prior to importing this module
|
||||
# RequiredAssemblies = @()
|
||||
|
||||
# Script files (.ps1) that are run in the caller's environment prior to importing this module.
|
||||
# ScriptsToProcess = @()
|
||||
|
||||
# Type files (.ps1xml) to be loaded when importing this module
|
||||
# TypesToProcess = @()
|
||||
|
||||
# Format files (.ps1xml) to be loaded when importing this module
|
||||
# FormatsToProcess = @()
|
||||
|
||||
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
|
||||
NestedModules = @('FFU.Common.Drivers.psm1',
|
||||
'FFU.Common.Drivers.Microsoft.psm1',
|
||||
'FFU.Common.Drivers.Dell.psm1',
|
||||
'FFU.Common.Winget.psm1',
|
||||
'FFU.Common.Parallel.psm1',
|
||||
'FFU.Common.Cleanup.psm1')
|
||||
|
||||
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
|
||||
FunctionsToExport = '*'
|
||||
|
||||
# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
|
||||
CmdletsToExport = '*'
|
||||
|
||||
# Variables to export from this module
|
||||
VariablesToExport = '*'
|
||||
|
||||
# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
|
||||
AliasesToExport = '*'
|
||||
|
||||
# DSC resources to export from this module
|
||||
# DscResourcesToExport = @()
|
||||
|
||||
# List of all modules packaged with this module
|
||||
# ModuleList = @()
|
||||
|
||||
# List of all files packaged with this module
|
||||
# FileList = @()
|
||||
|
||||
# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell.
|
||||
PrivateData = @{
|
||||
|
||||
PSData = @{
|
||||
|
||||
# Tags applied to this module. These help with module discovery in online galleries.
|
||||
# Tags = @()
|
||||
|
||||
# A URL to the license for this module.
|
||||
# LicenseUri = ''
|
||||
|
||||
# A URL to the main website for this project.
|
||||
# ProjectUri = ''
|
||||
|
||||
# A URL to an icon representing this module.
|
||||
# IconUri = ''
|
||||
|
||||
# ReleaseNotes of this module
|
||||
# ReleaseNotes = ''
|
||||
|
||||
# Prerelease string of this module
|
||||
# Prerelease = ''
|
||||
|
||||
# Flag to indicate whether the module requires explicit user acceptance for install/update/save
|
||||
# RequireLicenseAcceptance = $false
|
||||
|
||||
# External dependent modules of this module
|
||||
# ExternalModuleDependencies = @()
|
||||
|
||||
} # End of PSData hashtable
|
||||
|
||||
} # End of PrivateData hashtable
|
||||
|
||||
# HelpInfo URI of this module
|
||||
# HelpInfoURI = ''
|
||||
|
||||
# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
|
||||
# DefaultCommandPrefix = ''
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Manages the UI business logic for the "Applications" tab, including "Bring Your Own (BYO) Applications" and "Apps Script Variables".
|
||||
.DESCRIPTION
|
||||
This module contains all the functions that power the "Applications" tab in the BuildFFUVM_UI. It handles user interactions for managing custom application lists (BYO Apps), such as adding, removing, reordering, and saving/loading the list from a JSON file (UserAppList.json). It also includes the logic for copying the application source files to the designated staging directory in parallel. Additionally, it manages the UI for creating and removing key-value pairs for the AppsScriptVariables.json file, which allows for custom parameterization of user-provided scripts.
|
||||
#>
|
||||
|
||||
# Function to update the enabled state of BYO Apps action buttons based on selection
|
||||
function Update-BYOAppsActionButtonsState {
|
||||
param(
|
||||
[psobject]$State
|
||||
)
|
||||
$listView = $State.Controls.lstApplications
|
||||
$removeButton = $State.Controls.btnRemoveSelectedBYOApps
|
||||
$editButton = $State.Controls.btnEditApplication
|
||||
|
||||
if ($listView -and $removeButton -and $editButton) {
|
||||
# Count selected items
|
||||
$selectedItems = @($listView.Items | Where-Object { $_.IsSelected })
|
||||
$selectedCount = $selectedItems.Count
|
||||
|
||||
# Enable the remove button if any item is selected
|
||||
$removeButton.IsEnabled = ($selectedCount -gt 0)
|
||||
|
||||
# Enable the edit button only if exactly one item is selected
|
||||
$editButton.IsEnabled = ($selectedCount -eq 1)
|
||||
}
|
||||
}
|
||||
|
||||
# Function to remove all selected BYO applications
|
||||
function Remove-SelectedBYOApplications {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$listView = $State.Controls.lstApplications
|
||||
$itemsToRemove = @($listView.Items | Where-Object { $_.IsSelected })
|
||||
|
||||
if ($itemsToRemove.Count -eq 0) {
|
||||
# This should not happen if the button is correctly disabled, but as a safeguard:
|
||||
[System.Windows.MessageBox]::Show("No applications are selected for removal.", "Remove Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||
return
|
||||
}
|
||||
|
||||
# Check if the item being edited is among those being removed
|
||||
if ($null -ne $State.Data.editingBYOApplication -and $itemsToRemove.Contains($State.Data.editingBYOApplication)) {
|
||||
# Reset the edit state
|
||||
$State.Data.editingBYOApplication = $null
|
||||
$State.Controls.btnAddApplication.Content = "Add Application"
|
||||
# Clear the form fields
|
||||
$State.Controls.txtAppName.Clear()
|
||||
$State.Controls.txtAppCommandLine.Clear()
|
||||
$State.Controls.txtAppArguments.Clear()
|
||||
$State.Controls.txtAppSource.Clear()
|
||||
$State.Controls.txtAppAdditionalExitCodes.Clear()
|
||||
$State.Controls.chkIgnoreExitCodes.IsChecked = $false
|
||||
}
|
||||
|
||||
foreach ($item in $itemsToRemove) {
|
||||
$listView.Items.Remove($item)
|
||||
}
|
||||
|
||||
# Re-calculate priorities for the remaining items
|
||||
Update-ListViewPriorities -ListView $listView
|
||||
|
||||
# Update button states (Copy and Remove)
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
|
||||
# Update the header checkbox state
|
||||
$headerChk = $State.Controls.chkSelectAllBYOApps
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
|
||||
}
|
||||
|
||||
# Ask user if they want to save the changes
|
||||
$result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to UserAppList.json now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
|
||||
|
||||
if ($result -eq 'Yes') {
|
||||
$userAppListPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json'
|
||||
Save-BYOApplicationList -Path $userAppListPath -State $State
|
||||
}
|
||||
}
|
||||
|
||||
# Function to update the enabled state of the Copy Apps button
|
||||
function Update-CopyButtonState {
|
||||
param(
|
||||
[psobject]$State
|
||||
)
|
||||
$listView = $State.Controls.lstApplications
|
||||
$copyButton = $State.Controls.btnCopyBYOApps
|
||||
if ($listView -and $copyButton) {
|
||||
$hasSource = $false
|
||||
foreach ($item in $listView.Items) {
|
||||
if ($null -ne $item -and $item.PSObject.Properties['Source'] -and -not [string]::IsNullOrWhiteSpace($item.Source)) {
|
||||
$hasSource = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
$copyButton.IsEnabled = $hasSource
|
||||
}
|
||||
}
|
||||
|
||||
# Function to remove application and reorder priorities
|
||||
function Remove-Application {
|
||||
param(
|
||||
$priority,
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$listView = $State.Controls.lstApplications
|
||||
# Remove the item with the specified priority
|
||||
$itemToRemove = $listView.Items | Where-Object { $_.Priority -eq $priority } | Select-Object -First 1
|
||||
if ($itemToRemove) {
|
||||
$listView.Items.Remove($itemToRemove)
|
||||
# Reorder priorities for remaining items
|
||||
Update-ListViewPriorities -ListView $listView
|
||||
# Update the Copy Apps button state
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
}
|
||||
}
|
||||
|
||||
# Function to add a new BYO application from the UI
|
||||
function Add-BYOApplication {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$name = $State.Controls.txtAppName.Text
|
||||
$commandLine = $State.Controls.txtAppCommandLine.Text
|
||||
$arguments = $State.Controls.txtAppArguments.Text
|
||||
$source = $State.Controls.txtAppSource.Text
|
||||
$additionalExitCodes = $State.Controls.txtAppAdditionalExitCodes.Text
|
||||
$ignoreNonZeroExitCodes = $State.Controls.chkIgnoreExitCodes.IsChecked
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($commandLine)) {
|
||||
[System.Windows.MessageBox]::Show("Please fill in all fields (Name and Command Line)", "Missing Information", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
return
|
||||
}
|
||||
$listView = $State.Controls.lstApplications
|
||||
|
||||
# Check if we are in edit mode
|
||||
if ($null -ne $State.Data.editingBYOApplication) {
|
||||
$itemToUpdate = $State.Data.editingBYOApplication
|
||||
|
||||
# Check for duplicate names, excluding the item being edited
|
||||
$existingApp = $listView.Items | Where-Object { $_.Name -eq $name -and $_ -ne $itemToUpdate }
|
||||
if ($existingApp) {
|
||||
[System.Windows.MessageBox]::Show("An application with the name '$name' already exists.", "Duplicate Name", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
return
|
||||
}
|
||||
|
||||
# Update the properties of the existing object
|
||||
$itemToUpdate.Name = $name
|
||||
$itemToUpdate.CommandLine = $commandLine
|
||||
$itemToUpdate.Arguments = $arguments
|
||||
$itemToUpdate.Source = $source
|
||||
$itemToUpdate.AdditionalExitCodes = $additionalExitCodes
|
||||
$itemToUpdate.IgnoreNonZeroExitCodes = $ignoreNonZeroExitCodes
|
||||
$itemToUpdate.IgnoreExitCodes = if ($ignoreNonZeroExitCodes) { "Yes" } else { "No" }
|
||||
|
||||
# Refresh the ListView to show the changes
|
||||
$listView.Items.Refresh()
|
||||
|
||||
# Reset state
|
||||
$State.Data.editingBYOApplication = $null
|
||||
$State.Controls.btnAddApplication.Content = "Add Application"
|
||||
}
|
||||
else {
|
||||
# This is a new application
|
||||
# Check for duplicate names
|
||||
$existingApp = $listView.Items | Where-Object { $_.Name -eq $name }
|
||||
if ($existingApp) {
|
||||
[System.Windows.MessageBox]::Show("An application with the name '$name' already exists.", "Duplicate Name", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
return
|
||||
}
|
||||
$priority = 1
|
||||
if ($listView.Items.Count -gt 0) {
|
||||
$priority = ($listView.Items | Measure-Object -Property Priority -Maximum).Maximum + 1
|
||||
}
|
||||
$application = [PSCustomObject]@{
|
||||
IsSelected = $false
|
||||
Priority = $priority
|
||||
Name = $name
|
||||
CommandLine = $commandLine
|
||||
Arguments = $arguments
|
||||
Source = $source
|
||||
AdditionalExitCodes = $additionalExitCodes
|
||||
IgnoreNonZeroExitCodes = $ignoreNonZeroExitCodes
|
||||
IgnoreExitCodes = if ($ignoreNonZeroExitCodes) { "Yes" } else { "No" }
|
||||
CopyStatus = ""
|
||||
}
|
||||
$listView.Items.Add($application)
|
||||
}
|
||||
|
||||
# Clear form and update button states for both add and update operations
|
||||
$State.Controls.txtAppName.Text = ""
|
||||
$State.Controls.txtAppCommandLine.Text = ""
|
||||
$State.Controls.txtAppArguments.Text = ""
|
||||
$State.Controls.txtAppSource.Text = ""
|
||||
$State.Controls.txtAppAdditionalExitCodes.Text = ""
|
||||
$State.Controls.chkIgnoreExitCodes.IsChecked = $false
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
}
|
||||
|
||||
# Function to populate the form for editing a BYO application
|
||||
function Start-EditBYOApplication {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$listView = $State.Controls.lstApplications
|
||||
$itemToEdit = @($listView.Items | Where-Object { $_.IsSelected }) | Select-Object -First 1
|
||||
|
||||
if ($null -eq $itemToEdit) {
|
||||
[System.Windows.MessageBox]::Show("No application selected or multiple applications selected.", "Edit Error", "OK", "Warning")
|
||||
return
|
||||
}
|
||||
|
||||
# Store the item being edited in the state
|
||||
$State.Data.editingBYOApplication = $itemToEdit
|
||||
|
||||
# Populate the form fields
|
||||
$State.Controls.txtAppName.Text = $itemToEdit.Name
|
||||
$State.Controls.txtAppCommandLine.Text = $itemToEdit.CommandLine
|
||||
$State.Controls.txtAppArguments.Text = $itemToEdit.Arguments
|
||||
$State.Controls.txtAppSource.Text = $itemToEdit.Source
|
||||
$State.Controls.txtAppAdditionalExitCodes.Text = $itemToEdit.AdditionalExitCodes
|
||||
$State.Controls.chkIgnoreExitCodes.IsChecked = $itemToEdit.IgnoreNonZeroExitCodes
|
||||
|
||||
# Change the Add button to Update
|
||||
$State.Controls.btnAddApplication.Content = "Update App"
|
||||
}
|
||||
|
||||
# Function to add a new Apps Script Variable from the UI
|
||||
function Add-AppsScriptVariable {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$key = $State.Controls.txtAppsScriptKey.Text.Trim()
|
||||
$value = $State.Controls.txtAppsScriptValue.Text.Trim()
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($key)) {
|
||||
[System.Windows.MessageBox]::Show("Apps Script Variable Key cannot be empty.", "Input Error", "OK", "Warning")
|
||||
return
|
||||
}
|
||||
# Check for duplicate keys
|
||||
$existingKey = $State.Controls.lstAppsScriptVariables.Items | Where-Object { $_.Key -eq $key }
|
||||
if ($existingKey) {
|
||||
[System.Windows.MessageBox]::Show("An Apps Script Variable with the key '$key' already exists.", "Duplicate Key", "OK", "Warning")
|
||||
return
|
||||
}
|
||||
|
||||
$newItem = [PSCustomObject]@{
|
||||
IsSelected = $false # Add IsSelected property
|
||||
Key = $key
|
||||
Value = $value
|
||||
}
|
||||
$State.Data.appsScriptVariablesDataList.Add($newItem)
|
||||
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
$State.Controls.txtAppsScriptKey.Clear()
|
||||
$State.Controls.txtAppsScriptValue.Clear()
|
||||
# Update the header checkbox state
|
||||
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAppsScriptVariables -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
|
||||
}
|
||||
}
|
||||
|
||||
# Function to remove selected Apps Script Variables from the list
|
||||
function Remove-SelectedAppsScriptVariable {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$itemsToRemove = @($State.Data.appsScriptVariablesDataList | Where-Object { $_.IsSelected })
|
||||
if ($itemsToRemove.Count -eq 0) {
|
||||
[System.Windows.MessageBox]::Show("Please select one or more Apps Script Variables to remove.", "Selection Error", "OK", "Warning")
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($itemToRemove in $itemsToRemove) {
|
||||
$State.Data.appsScriptVariablesDataList.Remove($itemToRemove)
|
||||
}
|
||||
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
|
||||
# Update the header checkbox state
|
||||
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAppsScriptVariables -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
|
||||
}
|
||||
}
|
||||
|
||||
# Function to save BYO applications to JSON
|
||||
function Save-BYOApplicationList {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$listView = $State.Controls.lstApplications
|
||||
if (-not $listView -or $listView.Items.Count -eq 0) {
|
||||
[System.Windows.MessageBox]::Show("No applications to save.", "Save Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
# Ensure items are sorted by current priority before saving
|
||||
# Exclude UI-only properties (CopyStatus, IgnoreExitCodes) and ensure Priority is an integer
|
||||
$propertiesToSave = 'Priority', 'Name', 'CommandLine', 'Arguments', 'Source', 'AdditionalExitCodes', 'IgnoreNonZeroExitCodes'
|
||||
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source, AdditionalExitCodes, IgnoreNonZeroExitCodes
|
||||
|
||||
$applications | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Force -Encoding UTF8
|
||||
[System.Windows.MessageBox]::Show("Applications saved successfully to `"$Path`".", "Save Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||
}
|
||||
catch {
|
||||
[System.Windows.MessageBox]::Show("Failed to save applications: $_", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
|
||||
}
|
||||
}
|
||||
|
||||
# Function to load BYO applications from JSON
|
||||
function Import-BYOApplicationList {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
[System.Windows.MessageBox]::Show("Application list file not found at `"$Path`".", "Import Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$applications = Get-Content -Path $Path -Raw | ConvertFrom-Json
|
||||
$listView = $State.Controls.lstApplications
|
||||
$listView.Items.Clear()
|
||||
|
||||
# Add items and sort by priority from the file
|
||||
$sortedApps = $applications | Sort-Object Priority
|
||||
foreach ($app in $sortedApps) {
|
||||
$ignoreNonZero = if ($app.PSObject.Properties['IgnoreNonZeroExitCodes']) { $app.IgnoreNonZeroExitCodes } else { $false }
|
||||
$appObject = [PSCustomObject]@{
|
||||
IsSelected = $false
|
||||
Priority = $app.Priority
|
||||
Name = $app.Name
|
||||
CommandLine = $app.CommandLine
|
||||
Arguments = if ($app.PSObject.Properties['Arguments']) { $app.Arguments } else { "" }
|
||||
Source = $app.Source
|
||||
AdditionalExitCodes = if ($app.PSObject.Properties['AdditionalExitCodes']) { $app.AdditionalExitCodes } else { "" }
|
||||
IgnoreNonZeroExitCodes = $ignoreNonZero
|
||||
IgnoreExitCodes = if ($ignoreNonZero) { "Yes" } else { "No" }
|
||||
CopyStatus = ""
|
||||
}
|
||||
$listView.Items.Add($appObject)
|
||||
}
|
||||
|
||||
# Reorder priorities sequentially after loading
|
||||
Update-ListViewPriorities -ListView $listView
|
||||
# Update the Copy Apps button state
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
[System.Windows.MessageBox]::Show("Applications imported successfully from `"$Path`".", "Import Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||
}
|
||||
catch {
|
||||
[System.Windows.MessageBox]::Show("Failed to import applications: $_", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
|
||||
}
|
||||
}
|
||||
|
||||
# Function to invoke the parallel copy process for BYO apps
|
||||
function Invoke-CopyBYOApps {
|
||||
param(
|
||||
[psobject]$State,
|
||||
[System.Windows.Controls.Button]$Button
|
||||
)
|
||||
|
||||
$localAppsPath = $State.Controls.txtApplicationPath.Text
|
||||
$userAppListPath = Join-Path -Path $localAppsPath -ChildPath 'UserAppList.json'
|
||||
$listView = $State.Controls.lstApplications
|
||||
|
||||
try {
|
||||
# Ensure items are sorted by current priority before saving
|
||||
# Exclude CopyStatus when saving and ensure Priority is an integer; include AdditionalExitCodes and IgnoreNonZeroExitCodes for parity with Save-BYOApplicationList
|
||||
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source, AdditionalExitCodes, IgnoreNonZeroExitCodes
|
||||
$applications | ConvertTo-Json -Depth 5 | Set-Content -Path $userAppListPath -Force -Encoding UTF8
|
||||
WriteLog "Successfully updated UserAppList.json with all applications from the UI."
|
||||
}
|
||||
catch {
|
||||
$errorMessage = "Failed to update UserAppList.json: $_"
|
||||
WriteLog $errorMessage
|
||||
[System.Windows.MessageBox]::Show($errorMessage, "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
|
||||
return
|
||||
}
|
||||
|
||||
$allAppsWithSource = $State.Controls.lstApplications.Items | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Source) }
|
||||
if (-not $allAppsWithSource) {
|
||||
[System.Windows.MessageBox]::Show("No applications with a source path were found to copy.", "Copy BYO Apps", "OK", "Information")
|
||||
return
|
||||
}
|
||||
|
||||
$win32BasePath = Join-Path -Path $localAppsPath -ChildPath "Win32"
|
||||
|
||||
$appsToProcess = [System.Collections.Generic.List[object]]::new()
|
||||
$appsThatExist = [System.Collections.Generic.List[string]]::new()
|
||||
$appsToConfirm = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
foreach ($app in $allAppsWithSource) {
|
||||
$destinationPath = Join-Path -Path $win32BasePath -ChildPath $app.Name
|
||||
if (Test-Path -Path $destinationPath -PathType Container) {
|
||||
$appsThatExist.Add($app.Name)
|
||||
$appsToConfirm.Add($app)
|
||||
}
|
||||
else {
|
||||
$appsToProcess.Add($app)
|
||||
}
|
||||
}
|
||||
|
||||
if ($appsThatExist.Count -gt 0) {
|
||||
$message = "The following application folders already exist in the destination and will be overwritten:`n`n$($appsThatExist -join "`n")`n`nDo you want to proceed with copying and overwriting them?"
|
||||
$result = [System.Windows.MessageBox]::Show($message, "Confirm Overwrite", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Warning)
|
||||
|
||||
if ($result -eq 'Yes') {
|
||||
$appsToProcess.AddRange($appsToConfirm)
|
||||
}
|
||||
}
|
||||
|
||||
if ($appsToProcess.Count -eq 0) {
|
||||
# This message can be suppressed if you prefer no notification when the user clicks "No"
|
||||
# [System.Windows.MessageBox]::Show("No applications selected for copying.", "Copy BYO Apps", "OK", "Information")
|
||||
return
|
||||
}
|
||||
|
||||
$Button.IsEnabled = $false
|
||||
$State.Controls.pbOverallProgress.Visibility = 'Visible'
|
||||
$State.Controls.pbOverallProgress.Value = 0
|
||||
$State.Controls.txtStatus.Text = "Starting BYO app copy..."
|
||||
|
||||
# Create hashtable for task-specific arguments
|
||||
$taskArguments = @{
|
||||
AppsPath = $localAppsPath
|
||||
}
|
||||
|
||||
# Select only necessary properties before passing
|
||||
$itemsToProcess = $appsToProcess | Select-Object Priority, Name, CommandLine, Arguments, Source
|
||||
|
||||
# Invoke the centralized parallel processing function
|
||||
# Pass task type and task-specific arguments
|
||||
Invoke-ParallelProcessing -ItemsToProcess $itemsToProcess `
|
||||
-ListViewControl $State.Controls.lstApplications `
|
||||
-IdentifierProperty 'Name' `
|
||||
-StatusProperty 'CopyStatus' `
|
||||
-TaskType 'CopyBYO' `
|
||||
-TaskArguments $taskArguments `
|
||||
-CompletedStatusText "Copied" `
|
||||
-ErrorStatusPrefix "Error: " `
|
||||
-WindowObject $State.Window `
|
||||
-MainThreadLogPath $State.LogFilePath `
|
||||
-ThrottleLimit $State.Controls.txtThreads.Text
|
||||
|
||||
# Final status update (handled by Invoke-ParallelProcessing)
|
||||
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||
$Button.IsEnabled = $true
|
||||
}
|
||||
|
||||
# Function to copy a single BYO application (Modified for ForEach-Object -Parallel)
|
||||
function Start-CopyBYOApplicationTask {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[PSCustomObject]$ApplicationItemData, # Pass data, not the UI object
|
||||
[Parameter(Mandatory)]
|
||||
[string]$AppsPath, # Pass necessary path
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue # Add queue parameter
|
||||
# REMOVED: UI-related parameters
|
||||
)
|
||||
|
||||
$priority = $ApplicationItemData.Priority
|
||||
$appName = $ApplicationItemData.Name
|
||||
$commandLine = $ApplicationItemData.CommandLine
|
||||
$arguments = $ApplicationItemData.Arguments
|
||||
$sourcePath = $ApplicationItemData.Source
|
||||
$status = "Starting..." # Initial local status
|
||||
$success = $false
|
||||
|
||||
# Initial status update
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($AppsPath)) {
|
||||
$status = "Error: Apps Path not set"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
|
||||
WriteLog "Copy error for $($appName): Apps Path not set."
|
||||
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($sourcePath)) {
|
||||
$status = "No source specified"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
|
||||
# This isn't an error, just nothing to do. Consider it success.
|
||||
$success = $true
|
||||
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $sourcePath -PathType Container)) {
|
||||
$status = "Source path not found"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
|
||||
WriteLog "Copy error for $($appName): Source path '$sourcePath' not found."
|
||||
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
|
||||
}
|
||||
|
||||
$win32BasePath = Join-Path -Path $AppsPath -ChildPath "Win32"
|
||||
$destinationPath = Join-Path -Path $win32BasePath -ChildPath $appName
|
||||
|
||||
try {
|
||||
# Ensure base directory exists
|
||||
if (-not (Test-Path -Path $win32BasePath -PathType Container)) {
|
||||
New-Item -Path $win32BasePath -ItemType Directory -Force | Out-Null
|
||||
WriteLog "Created directory: $win32BasePath"
|
||||
}
|
||||
|
||||
# If destination exists, remove it to ensure a clean copy and prevent nesting.
|
||||
if (Test-Path -Path $destinationPath -PathType Container) {
|
||||
WriteLog "Removing existing destination folder: $destinationPath"
|
||||
Remove-Item -Path $destinationPath -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
|
||||
# Perform the copy
|
||||
$status = "Copying..."
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
|
||||
WriteLog "Copying '$sourcePath' to '$destinationPath'..."
|
||||
Copy-Item -Path $sourcePath -Destination $destinationPath -Recurse -Force -ErrorAction Stop
|
||||
$status = "Copied successfully"
|
||||
$success = $true
|
||||
WriteLog "Successfully copied '$appName' to '$destinationPath'."
|
||||
|
||||
}
|
||||
catch {
|
||||
$errorMessage = $_.Exception.Message
|
||||
$status = "Error: $($errorMessage)"
|
||||
WriteLog "Copy error for $($appName): $($errorMessage)"
|
||||
$success = $false
|
||||
# Enqueue error status
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
|
||||
}
|
||||
|
||||
# Return the final status
|
||||
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -0,0 +1,371 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Provides functions for discovering, downloading, and processing Dell device drivers.
|
||||
.DESCRIPTION
|
||||
This module contains the logic specific to handling Dell drivers for the FFU Builder UI. It includes functions to parse Dell's large XML driver catalog to retrieve a list of supported models (Get-DellDriversModelList). It also provides a parallel-capable task function (Save-DellDriversTask) that finds, downloads, extracts, and optionally compresses all the latest driver packages for a specified Dell model and operating system.
|
||||
#>
|
||||
|
||||
# Function to get the list of Dell models from the catalog using XML streaming
|
||||
function Get-DellDriversModelList {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$WindowsRelease,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Make
|
||||
)
|
||||
|
||||
# Client pathway (<=11) uses CatalogIndexPC to build full Brand Model (SystemID) strings.
|
||||
if ($WindowsRelease -le 11) {
|
||||
$dellModels = Get-DellClientModels -CatalogIndexXmlPath (Get-DellCatalogIndex -DriversFolder $DriversFolder)
|
||||
$final = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
foreach ($m in $dellModels) {
|
||||
$final.Add([pscustomobject]@{
|
||||
Make = $Make
|
||||
Model = $m.ModelDisplay
|
||||
Brand = $m.Brand
|
||||
ModelNumber = $m.ModelNumber
|
||||
SystemId = $m.SystemId
|
||||
CabRelativePath = $m.CabRelativePath
|
||||
CabUrl = $m.CabUrl
|
||||
})
|
||||
}
|
||||
return $final
|
||||
}
|
||||
|
||||
# Server pathway (unchanged – still uses Catalog.cab)
|
||||
$dellDriversFolder = Join-Path -Path $DriversFolder -ChildPath "Dell"
|
||||
$catalogBaseName = "Catalog"
|
||||
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
|
||||
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
|
||||
$catalogUrl = "https://downloads.dell.com/catalog/Catalog.cab"
|
||||
|
||||
if (-not (Test-Path -Path $dellDriversFolder)) {
|
||||
New-Item -Path $dellDriversFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
$download = $true
|
||||
if (Test-Path -Path $dellCatalogXML) {
|
||||
if (((Get-Date) - (Get-Item $dellCatalogXML).CreationTime).TotalDays -lt 7) {
|
||||
$download = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($download) {
|
||||
if (Test-Path $dellCabFile) { Remove-Item $dellCabFile -Force -ErrorAction SilentlyContinue }
|
||||
if (Test-Path $dellCatalogXML) { Remove-Item $dellCatalogXML -Force -ErrorAction SilentlyContinue }
|
||||
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $dellCabFile
|
||||
Invoke-Process -FilePath Expand.exe -ArgumentList """$dellCabFile"" ""$dellCatalogXML""" | Out-Null
|
||||
Remove-Item $dellCabFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if (-not (Test-Path $dellCatalogXML)) { throw "Dell server catalog XML missing: $dellCatalogXML" }
|
||||
|
||||
$settings = New-Object System.Xml.XmlReaderSettings
|
||||
$settings.IgnoreWhitespace = $true
|
||||
$settings.IgnoreComments = $true
|
||||
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
|
||||
$inDriver = $false
|
||||
$inModel = $false
|
||||
$depthModel = -1
|
||||
$modelsHash = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
try {
|
||||
while ($reader.Read()) {
|
||||
switch ($reader.NodeType) {
|
||||
([System.Xml.XmlNodeType]::Element) {
|
||||
switch ($reader.Name) {
|
||||
'SoftwareComponent' { $inDriver = $false }
|
||||
'ComponentType' { if ($reader.GetAttribute('value') -eq 'DRVR') { $inDriver = $true } }
|
||||
'Model' { if ($inDriver) { $inModel = $true; $depthModel = $reader.Depth } }
|
||||
}
|
||||
}
|
||||
([System.Xml.XmlNodeType]::CDATA) {
|
||||
if ($inDriver -and $inModel) {
|
||||
$val = $reader.Value.Trim()
|
||||
if ($val) { $modelsHash.Add($val) | Out-Null }
|
||||
$inModel = $false
|
||||
}
|
||||
}
|
||||
([System.Xml.XmlNodeType]::EndElement) {
|
||||
if ($reader.Name -eq 'SoftwareComponent') { $inDriver = $false; $inModel = $false }
|
||||
elseif ($reader.Name -eq 'Model' -and $reader.Depth -eq $depthModel) { $inModel = $false; $depthModel = -1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
|
||||
$out = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
foreach ($nm in ($modelsHash | Sort-Object)) {
|
||||
$out.Add([pscustomobject]@{ Make = $Make; Model = $nm })
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
# Function to download and extract drivers for a specific Dell model (Modified for ForEach-Object -Parallel)
|
||||
function Save-DellDriversTask {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[pscustomobject]$DriverItemData,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WindowsArch,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$WindowsRelease,
|
||||
[Parameter()]
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null,
|
||||
[Parameter()]
|
||||
[bool]$CompressToWim = $false,
|
||||
[Parameter()]
|
||||
[bool]$PreserveSourceOnCompress = $false
|
||||
)
|
||||
|
||||
$modelDisplay = $DriverItemData.Model
|
||||
$make = 'Dell'
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Checking...' }
|
||||
|
||||
$sanitizedModelName = ConvertTo-SafeName -Name $modelDisplay
|
||||
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $make
|
||||
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedModelName
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath $sanitizedModelName
|
||||
|
||||
# Helper: safe folder removal
|
||||
function Remove-SafeFolder {
|
||||
param([string]$Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return }
|
||||
# Never allow deleting the entire Dell root folder accidentally
|
||||
$dellRoot = (Resolve-Path $makeDriversPath).ProviderPath
|
||||
$target = (Resolve-Path $Path -ErrorAction SilentlyContinue)?.ProviderPath
|
||||
if ($null -eq $target) { return }
|
||||
if ($target -eq $dellRoot) { return }
|
||||
if (-not ($target.StartsWith($dellRoot, [System.StringComparison]::OrdinalIgnoreCase))) { return }
|
||||
Remove-Item -Path $target -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
try {
|
||||
# Existing drivers short‑circuit
|
||||
$existing = Test-ExistingDriver -Make $make -Model $sanitizedModelName -DriversFolder $DriversFolder -Identifier $modelDisplay -ProgressQueue $ProgressQueue
|
||||
if ($existing) {
|
||||
if (-not $existing.PSObject.Properties['Model']) {
|
||||
$existing | Add-Member -MemberType NoteProperty -Name 'Model' -Value $modelDisplay
|
||||
}
|
||||
if ($CompressToWim -and $existing.Status -eq 'Already downloaded') {
|
||||
$wimPath = Join-Path $makeDriversPath "$sanitizedModelName.wim"
|
||||
$wimRelativePath = Join-Path $make "$sanitizedModelName.wim"
|
||||
$srcPath = Join-Path $makeDriversPath $sanitizedModelName
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Compressing existing...' }
|
||||
try {
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $srcPath -DestinationWimPath $wimPath -WimName $modelDisplay -WimDescription "Drivers for $modelDisplay" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
$existing.Status = 'Compression successful'
|
||||
$existing.DriverPath = $wimRelativePath
|
||||
$existing.Success = $true
|
||||
}
|
||||
catch {
|
||||
WriteLog "Compression failed for $($modelDisplay): $($_.Exception.Message)"
|
||||
$existing.Status = 'Already downloaded (Compression failed)'
|
||||
$existing.Success = $false
|
||||
}
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $existing.Status }
|
||||
}
|
||||
return $existing
|
||||
}
|
||||
|
||||
if (-not (Test-Path $makeDriversPath)) { New-Item -Path $makeDriversPath -ItemType Directory -Force | Out-Null }
|
||||
if (-not (Test-Path $modelPath)) { New-Item -Path $modelPath -ItemType Directory -Force | Out-Null }
|
||||
|
||||
$packages = @()
|
||||
|
||||
if ($WindowsRelease -le 11) {
|
||||
$cabUrl = $DriverItemData.CabUrl
|
||||
if ([string]::IsNullOrWhiteSpace($cabUrl)) {
|
||||
WriteLog "CabUrl missing for '$modelDisplay' – resolving via CatalogIndexPC."
|
||||
$resolved = Resolve-DellCabUrlFromModel -DriversFolder $DriversFolder -ModelDisplay $modelDisplay
|
||||
if ($null -eq $resolved -or [string]::IsNullOrWhiteSpace($resolved.CabUrl)) {
|
||||
throw "Unable to resolve CabUrl for $modelDisplay from CatalogIndexPC."
|
||||
}
|
||||
$cabUrl = $resolved.CabUrl
|
||||
# Optionally persist back into the incoming object if property exists
|
||||
if ($DriverItemData.PSObject.Properties['CabUrl']) {
|
||||
$DriverItemData.CabUrl = $cabUrl
|
||||
}
|
||||
}
|
||||
|
||||
# Model-based workflow (always used for client pathway now)
|
||||
$modelCabName = [IO.Path]::GetFileName($cabUrl)
|
||||
if ([string]::IsNullOrWhiteSpace($modelCabName)) { throw "Derived model cab name empty for $modelDisplay" }
|
||||
$modelCabPath = Join-Path $makeDriversPath $modelCabName
|
||||
$modelXmlPath = Join-Path $makeDriversPath ([IO.Path]::GetFileNameWithoutExtension($modelCabName) + '.xml')
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Downloading catalog...' }
|
||||
if (Test-Path $modelCabPath) { Remove-SafeFolder $modelCabPath }
|
||||
if (Test-Path $modelXmlPath) { Remove-SafeFolder $modelXmlPath }
|
||||
|
||||
WriteLog "Downloading Dell model catalog from $cabUrl to $modelCabPath"
|
||||
Start-BitsTransferWithRetry -Source $cabUrl -Destination $modelCabPath
|
||||
Invoke-Process -FilePath Expand.exe -ArgumentList """$modelCabPath"" ""$modelXmlPath""" | Out-Null
|
||||
Remove-Item $modelCabPath -Force -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path $modelXmlPath)) { throw "Model XML not found after extraction: $modelXmlPath" }
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Selecting latest drivers...' }
|
||||
$packages = Get-DellLatestDriverPackages -ModelXmlPath $modelXmlPath -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease
|
||||
}
|
||||
else {
|
||||
# Server legacy logic unchanged (kept as before)
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Preparing server catalog...' }
|
||||
$catalogCab = Join-Path $makeDriversPath 'Catalog.cab'
|
||||
$catalogXml = Join-Path $makeDriversPath 'Catalog.xml'
|
||||
$catalogUrl = 'https://downloads.dell.com/catalog/Catalog.cab'
|
||||
$need = $true
|
||||
if (Test-Path $catalogXml) {
|
||||
if (((Get-Date) - (Get-Item $catalogXml).CreationTime).TotalDays -lt 7) { $need = $false }
|
||||
}
|
||||
if ($need) {
|
||||
if (Test-Path $catalogCab) { Remove-SafeFolder $catalogCab }
|
||||
if (Test-Path $catalogXml) { Remove-SafeFolder $catalogXml }
|
||||
WriteLog "Downloading Dell server catalog from $catalogUrl to $catalogCab"
|
||||
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $catalogCab
|
||||
Invoke-Process -FilePath Expand.exe -ArgumentList """$catalogCab"" ""$catalogXml""" | Out-Null
|
||||
Remove-Item $catalogCab -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if (-not (Test-Path $catalogXml)) { throw "Server catalog XML missing: $catalogXml" }
|
||||
|
||||
[xml]$xmlContent = Get-Content -Path $catalogXml -Raw
|
||||
$baseLocation = "https://$($xmlContent.manifest.baseLocation)/"
|
||||
$softwareComponents = $xmlContent.Manifest.SoftwareComponent | Where-Object { $_.ComponentType.value -eq 'DRVR' }
|
||||
$latestDrivers = @{}
|
||||
foreach ($component in $softwareComponents) {
|
||||
$models = $component.SupportedSystems.Brand.Model
|
||||
foreach ($m in $models) {
|
||||
if ($m.Display.'#cdata-section' -eq $modelDisplay) {
|
||||
$validOS = $component.SupportedOperatingSystems.OperatingSystem | Where-Object { $_.osArch -eq $WindowsArch }
|
||||
if (-not $validOS) { continue }
|
||||
$driverPath = $component.path
|
||||
$downloadUrl = $baseLocation + $driverPath
|
||||
$fileName = [IO.Path]::GetFileName($driverPath)
|
||||
$name = $component.Name.Display.'#cdata-section' -replace '[\\\/\:\*\?\"\<\>\| ]', '_' -replace '[\,]', '-'
|
||||
$category = $component.Category.Display.'#cdata-section' -replace '[\\\/\:\*\?\"\<\>\| ]', '_'
|
||||
$version = [version]$component.vendorVersion
|
||||
$namePrefix = ($name -split '-')[0]
|
||||
if (-not $latestDrivers[$category]) { $latestDrivers[$category] = @{} }
|
||||
if (-not $latestDrivers[$category][$namePrefix] -or $latestDrivers[$category][$namePrefix].Version -lt $version) {
|
||||
$latestDrivers[$category][$namePrefix] = [pscustomobject]@{
|
||||
Name = $name
|
||||
DownloadUrl = $downloadUrl
|
||||
DriverFileName = $fileName
|
||||
Version = $version
|
||||
Category = $category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($cat in $latestDrivers.Keys) { foreach ($drv in $latestDrivers[$cat].Values) { $packages += $drv } }
|
||||
}
|
||||
|
||||
if (-not $packages -or $packages.Count -eq 0) {
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'No drivers found for OS' }
|
||||
return [pscustomobject]@{ Model = $modelDisplay; Status = 'No drivers found for OS'; Success = $true; DriverPath = $driverRelativePath }
|
||||
}
|
||||
|
||||
$total = $packages.Count
|
||||
$idx = 0
|
||||
foreach ($pkg in $packages) {
|
||||
$idx++
|
||||
$driverName = $pkg.Name
|
||||
if ([string]::IsNullOrWhiteSpace($driverName)) { $driverName = $pkg.DriverFileName }
|
||||
$status = "$idx/$total Downloading $driverName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $status }
|
||||
|
||||
$categorySafe = ($pkg.Category -replace '[\\\/\:\*\?\"\<\>\| ]', '_')
|
||||
$downloadFolder = Join-Path $modelPath $categorySafe
|
||||
if (-not (Test-Path $downloadFolder)) { New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null }
|
||||
$driverFilePath = Join-Path $downloadFolder $pkg.DriverFileName
|
||||
$plainName = [IO.Path]::GetFileNameWithoutExtension($pkg.DriverFileName)
|
||||
if ([string]::IsNullOrWhiteSpace($plainName)) { $plainName = "_extract" }
|
||||
$extractFolder = Join-Path $downloadFolder $plainName
|
||||
|
||||
if (Test-Path $extractFolder) {
|
||||
$sz = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($sz -gt 1KB) { continue }
|
||||
}
|
||||
|
||||
if (-not (Test-Path $driverFilePath)) {
|
||||
WriteLog "$status URL: $($pkg.DownloadUrl)"
|
||||
try { Start-BitsTransferWithRetry -Source $pkg.DownloadUrl -Destination $driverFilePath }
|
||||
catch {
|
||||
$failureMessage = "Failed to download driver '$driverName' from $($pkg.DownloadUrl): $($_.Exception.Message)"
|
||||
WriteLog $failureMessage
|
||||
throw (New-Object System.Exception($failureMessage, $_.Exception))
|
||||
}
|
||||
}
|
||||
|
||||
$status = "$idx/$total Extracting $driverName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $status }
|
||||
|
||||
if (-not (Test-Path $extractFolder)) { New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null }
|
||||
|
||||
$arg1 = "/s /e=`"$extractFolder`" /l=`"$extractFolder\log.log`""
|
||||
$arg2 = "/s /drivers=`"$extractFolder`" /l=`"$extractFolder\log.log`""
|
||||
$ok = $false
|
||||
try {
|
||||
Invoke-Process -FilePath $driverFilePath -ArgumentList $arg1 | Out-Null
|
||||
$sz = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($sz -gt 1KB) { $ok = $true }
|
||||
if (-not $ok) {
|
||||
Remove-SafeFolder $extractFolder
|
||||
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
|
||||
Invoke-Process -FilePath $driverFilePath -ArgumentList $arg2 | Out-Null
|
||||
$sz = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($sz -gt 1KB) { $ok = $true }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Extraction error: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
Remove-Item $driverFilePath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
$failureMessage = "Failed to extract driver '$driverName'."
|
||||
WriteLog $failureMessage
|
||||
throw (New-Object System.Exception($failureMessage))
|
||||
}
|
||||
}
|
||||
|
||||
if ($CompressToWim) {
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Compressing...' }
|
||||
$wimPath = Join-Path $makeDriversPath "$sanitizedModelName.wim"
|
||||
try {
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $wimPath -WimName $modelDisplay -WimDescription $modelDisplay -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
$driverRelativePath = Join-Path $make "$sanitizedModelName.wim"
|
||||
$statusFinal = 'Completed & Compressed'
|
||||
}
|
||||
catch {
|
||||
WriteLog "Compression failed for $($modelDisplay): $($_.Exception.Message)"
|
||||
$statusFinal = 'Completed (Compression Failed)'
|
||||
}
|
||||
}
|
||||
else {
|
||||
$statusFinal = 'Completed'
|
||||
}
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $statusFinal }
|
||||
return [pscustomobject]@{ Model = $modelDisplay; Status = $statusFinal; Success = $true; DriverPath = $driverRelativePath }
|
||||
}
|
||||
catch {
|
||||
$errorStatus = "Error: $($_.Exception.Message)"
|
||||
WriteLog "Save-DellDriversTask error for $($modelDisplay): $($_.Exception.ToString())"
|
||||
Remove-DriverModelFolder -DriversFolder $DriversFolder -TargetFolder $modelPath -Description $modelDisplay
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $errorStatus }
|
||||
return [pscustomobject]@{ Model = $modelDisplay; Status = $errorStatus; Success = $false; DriverPath = $null }
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -0,0 +1,436 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Provides functions to retrieve HP model lists and download corresponding driver packs.
|
||||
.DESCRIPTION
|
||||
This module contains the logic specific to handling HP drivers for the FFU Builder UI. It includes functions to:
|
||||
- Download and parse the HP PlatformList.xml to generate a list of supported HP computer models.
|
||||
- For a selected model, find the most appropriate driver pack based on the specified Windows release and version, with intelligent fallback logic.
|
||||
- Download the driver pack, extract all individual driver installers, and then extract the driver files from each installer.
|
||||
- Optionally, compress the final extracted drivers into a single WIM file for easier deployment.
|
||||
These functions are designed to be called by the main UI logic, often in parallel, to efficiently manage driver acquisition.
|
||||
#>
|
||||
|
||||
# Function to get the list of HP models from the PlatformList.xml
|
||||
function Get-HPDriversModelList {
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Make # Expected to be 'HP'
|
||||
)
|
||||
|
||||
WriteLog "Getting HP driver model list..."
|
||||
$hpDriversFolder = Join-Path -Path $DriversFolder -ChildPath $Make
|
||||
$platformListUrl = 'https://hpia.hpcloud.hp.com/ref/platformList.cab'
|
||||
$platformListCab = Join-Path -Path $hpDriversFolder -ChildPath "platformList.cab"
|
||||
$platformListXml = Join-Path -Path $hpDriversFolder -ChildPath "PlatformList.xml"
|
||||
$modelList = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
try {
|
||||
# Ensure HP drivers folder exists
|
||||
if (-not (Test-Path -Path $hpDriversFolder)) {
|
||||
WriteLog "Creating HP Drivers folder: $hpDriversFolder"
|
||||
New-Item -Path $hpDriversFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
# Download PlatformList.cab if it doesn't exist or is outdated (e.g., older than 7 days)
|
||||
if (-not (Test-Path -Path $platformListCab) -or ((Get-Date) - (Get-Item $platformListCab).LastWriteTime).TotalDays -gt 7) {
|
||||
WriteLog "Downloading $platformListUrl to $platformListCab"
|
||||
# Use the private helper function for download with retry
|
||||
Start-BitsTransferWithRetry -Source $platformListUrl -Destination $platformListCab -ErrorAction Stop
|
||||
WriteLog "PlatformList.cab download complete."
|
||||
# Force extraction if downloaded
|
||||
if (Test-Path -Path $platformListXml) {
|
||||
Remove-Item -Path $platformListXml -Force
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Using existing PlatformList.cab found at $platformListCab"
|
||||
}
|
||||
|
||||
# Extract PlatformList.xml if it doesn't exist
|
||||
if (-not (Test-Path -Path $platformListXml)) {
|
||||
WriteLog "Expanding $platformListCab to $platformListXml"
|
||||
# Use the private helper function for process invocation
|
||||
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$platformListCab`"", "`"$platformListXml`"") -ErrorAction Stop | Out-Null
|
||||
WriteLog "PlatformList.xml extraction complete."
|
||||
}
|
||||
else {
|
||||
WriteLog "Using existing PlatformList.xml found at $platformListXml"
|
||||
}
|
||||
|
||||
# Parse the PlatformList.xml using XmlReader for efficiency
|
||||
WriteLog "Parsing PlatformList.xml to extract HP models..."
|
||||
$settings = New-Object System.Xml.XmlReaderSettings
|
||||
$settings.Async = $false # Ensure synchronous reading
|
||||
|
||||
$reader = [System.Xml.XmlReader]::Create($platformListXml, $settings)
|
||||
$uniqueEntries = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
while ($reader.Read()) {
|
||||
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'Platform') {
|
||||
$platformReader = $reader.ReadSubtree()
|
||||
$platformNames = [System.Collections.Generic.List[string]]::new()
|
||||
$platformSystemId = $null
|
||||
|
||||
while ($platformReader.Read()) {
|
||||
if ($platformReader.NodeType -eq [System.Xml.XmlNodeType]::Element) {
|
||||
if ($platformReader.Name -eq 'ProductName') {
|
||||
$modelName = $platformReader.ReadElementContentAsString()
|
||||
if (-not [string]::IsNullOrWhiteSpace($modelName)) {
|
||||
$platformNames.Add($modelName.Trim())
|
||||
}
|
||||
}
|
||||
elseif ($platformReader.Name -eq 'SystemID') {
|
||||
$platformSystemId = $platformReader.ReadElementContentAsString().Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
$platformReader.Close()
|
||||
|
||||
foreach ($name in $platformNames) {
|
||||
$systemIdKey = if (-not [string]::IsNullOrWhiteSpace($platformSystemId)) { $platformSystemId } else { '' }
|
||||
$compositeKey = "$name|$systemIdKey"
|
||||
if ($uniqueEntries.Add($compositeKey)) {
|
||||
$modelList.Add([PSCustomObject]@{
|
||||
Make = $Make
|
||||
Model = $name
|
||||
SystemId = $platformSystemId
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$reader.Close()
|
||||
|
||||
WriteLog "Successfully parsed $($modelList.Count) HP model and SystemID combinations from PlatformList.xml."
|
||||
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error getting HP driver model list: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Sort the list alphabetically by Model name before returning
|
||||
return $modelList | Sort-Object -Property Model, SystemId
|
||||
}
|
||||
# Function to download and extract drivers for a specific HP model (Designed for ForEach-Object -Parallel)
|
||||
function Save-HPDriversTask {
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory = $true)]
|
||||
[PSCustomObject]$DriverItemData, # Contains Make, Model
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("x64", "x86", "ARM64")]
|
||||
[string]$WindowsArch,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet(10, 11)]
|
||||
[int]$WindowsRelease,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WindowsVersion, # e.g., 22H2, 23H2, etc.
|
||||
[Parameter()] # Made optional
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
|
||||
[Parameter()]
|
||||
[bool]$CompressToWim = $false, # New parameter for compression
|
||||
[Parameter()]
|
||||
[bool]$PreserveSourceOnCompress = $false
|
||||
)
|
||||
|
||||
$displayModelName = if (-not [string]::IsNullOrWhiteSpace($DriverItemData.Model)) { $DriverItemData.Model } else { $DriverItemData.Id }
|
||||
$make = $DriverItemData.Make # Should be 'HP'
|
||||
$productName = if ($DriverItemData.PSObject.Properties['ProductName'] -and -not [string]::IsNullOrWhiteSpace($DriverItemData.ProductName)) { $DriverItemData.ProductName } else { ConvertTo-DriverBaseName -ModelString $displayModelName }
|
||||
if ([string]::IsNullOrWhiteSpace($productName)) { $productName = $displayModelName }
|
||||
$systemIdentifier = if ($DriverItemData.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($DriverItemData.SystemId)) { $DriverItemData.SystemId } else { $null }
|
||||
if ([string]::IsNullOrWhiteSpace($displayModelName)) {
|
||||
$displayModelName = if ([string]::IsNullOrWhiteSpace($systemIdentifier)) { $productName } else { Get-DriverDisplayName -BaseName $productName -Identifier $systemIdentifier }
|
||||
}
|
||||
$identifier = $displayModelName # Unique identifier for progress updates
|
||||
$sanitizedModelName = ConvertTo-SafeName -Name $identifier
|
||||
if ($sanitizedModelName -ne $identifier) { WriteLog "Sanitized model name: '$identifier' -> '$sanitizedModelName'" }
|
||||
$hpDriversBaseFolder = Join-Path -Path $DriversFolder -ChildPath $make # Changed variable name for clarity
|
||||
$platformListXml = Join-Path -Path $hpDriversBaseFolder -ChildPath "PlatformList.xml"
|
||||
$modelSpecificFolder = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName # Sanitize model name for folder path
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath $sanitizedModelName # Relative path for the driver folder
|
||||
$finalStatus = "" # Initialize final status
|
||||
$successState = $true # Assume success unless an operation fails
|
||||
|
||||
if (-not (Test-Path -Path $DriversFolder -PathType Container)) {
|
||||
WriteLog "Creating Drivers folder: $DriversFolder"
|
||||
New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
if (-not (Test-Path -Path $hpDriversBaseFolder -PathType Container)) {
|
||||
WriteLog "Creating HP drivers folder: $hpDriversBaseFolder"
|
||||
New-Item -Path $hpDriversBaseFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Checking HP drivers for $displayModelName..." }
|
||||
|
||||
try {
|
||||
# Check for existing drivers
|
||||
$existingDriver = Test-ExistingDriver -Make $make -Model $sanitizedModelName -DriversFolder $DriversFolder -Identifier $identifier -ProgressQueue $ProgressQueue
|
||||
if ($null -ne $existingDriver) {
|
||||
# The return object from Test-ExistingDriver uses 'Model' as the identifier key.
|
||||
# We need to return 'Identifier' for HP's logic.
|
||||
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Identifier' -Value $identifier -Force
|
||||
$existingDriver.PSObject.Properties.Remove('Model')
|
||||
|
||||
# Special handling for existing folders that need compression
|
||||
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
|
||||
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($sanitizedModelName).wim"
|
||||
$wimRelativePath = Join-Path -Path $make -ChildPath "$($sanitizedModelName).wim"
|
||||
$sourceFolderPath = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName
|
||||
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." }
|
||||
try {
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
$existingDriver.Status = "Compression successful"
|
||||
$existingDriver.DriverPath = $wimRelativePath
|
||||
$existingDriver.Success = $true
|
||||
WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error compressing existing drivers for $($identifier): $($_.Exception.Message)"
|
||||
$existingDriver.Status = "Already downloaded (Compression failed)"
|
||||
$existingDriver.Success = $false
|
||||
}
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $existingDriver.Status }
|
||||
}
|
||||
|
||||
return $existingDriver
|
||||
}
|
||||
|
||||
# If folder does not exist, proceed with download and extraction
|
||||
WriteLog "HP drivers for '$identifier' not found locally. Starting download process..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Downloading..." }
|
||||
|
||||
# Ensure PlatformList.xml exists (it should have been downloaded by Get-HPDriversModelList)
|
||||
if (-not (Test-Path -Path $platformListXml)) {
|
||||
# Attempt to download/extract it again if missing
|
||||
WriteLog "PlatformList.xml not found for HP task, attempting download/extract..."
|
||||
$platformListUrl = 'https://hpia.hpcloud.hp.com/ref/platformList.cab'
|
||||
$platformListCab = Join-Path -Path $hpDriversBaseFolder -ChildPath "platformList.cab"
|
||||
# Base folder already checked/created
|
||||
Start-BitsTransferWithRetry -Source $platformListUrl -Destination $platformListCab -ErrorAction Stop
|
||||
if (Test-Path -Path $platformListXml) { Remove-Item -Path $platformListXml -Force }
|
||||
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$platformListCab`"", "`"$platformListXml`"") -ErrorAction Stop | Out-Null
|
||||
WriteLog "PlatformList.xml download/extract complete for HP task."
|
||||
if (-not (Test-Path -Path $platformListXml)) {
|
||||
throw "Failed to obtain PlatformList.xml for HP driver task."
|
||||
}
|
||||
}
|
||||
|
||||
# Parse the PlatformList.xml to find the SystemID based on the ProductName
|
||||
WriteLog "Parsing $platformListXml for model '$displayModelName' (SystemID: $systemIdentifier) details..."
|
||||
[xml]$platformListContent = Get-Content -Path $platformListXml -Raw -Encoding UTF8 -ErrorAction Stop
|
||||
$platformNode = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($systemIdentifier)) {
|
||||
$platformNode = $platformListContent.ImagePal.Platform | Where-Object { $_.SystemID -eq $systemIdentifier } | Select-Object -First 1
|
||||
if ($null -eq $platformNode) {
|
||||
WriteLog "SystemID '$systemIdentifier' not found in PlatformList.xml. Falling back to ProductName search."
|
||||
}
|
||||
}
|
||||
if ($null -eq $platformNode) {
|
||||
$searchName = if (-not [string]::IsNullOrWhiteSpace($productName)) { $productName } else { $displayModelName }
|
||||
$platformNode = $platformListContent.ImagePal.Platform | Where-Object { $_.ProductName.'#text' -match "^$([regex]::Escape($searchName))$" } | Select-Object -First 1
|
||||
}
|
||||
|
||||
if ($null -eq $platformNode) {
|
||||
throw "Model '$displayModelName' (SystemID: $systemIdentifier) not found in PlatformList.xml."
|
||||
}
|
||||
|
||||
$systemID = $platformNode.SystemID
|
||||
# --- OS Node Selection with Fallback Logic ---
|
||||
$selectedOSNode = $null
|
||||
$selectedOSVersion = $null
|
||||
$selectedOSRelease = $WindowsRelease # Start with the requested release
|
||||
|
||||
# Complete list of Windows 11 feature-update versions (newest to oldest)
|
||||
$win11Versions = @(
|
||||
"24H2", "23H2", "22H2", "21H2"
|
||||
)
|
||||
|
||||
# Complete list of Windows 10 feature-update versions (newest to oldest)
|
||||
$win10Versions = @(
|
||||
"22H2", "21H2", "21H1", "20H2", "2004", "1909", "1903", "1809", "1803", "1709", "1703", "1607", "1511", "1507"
|
||||
)
|
||||
|
||||
# Helper function to find a matching OS node for a given release and version list
|
||||
function Find-MatchingOSNode {
|
||||
param(
|
||||
[int]$ReleaseToSearch,
|
||||
[array]$VersionsToSearch
|
||||
)
|
||||
$osNodesForRelease = $platformNode.OS | Where-Object {
|
||||
($ReleaseToSearch -eq 11 -and $_.IsWindows11 -contains 'true') -or
|
||||
($ReleaseToSearch -eq 10 -and ($null -eq $_.IsWindows11 -or $_.IsWindows11 -notcontains 'true'))
|
||||
}
|
||||
|
||||
if ($null -eq $osNodesForRelease) { return $null }
|
||||
|
||||
foreach ($version in $VersionsToSearch) {
|
||||
foreach ($osNode in $osNodesForRelease) {
|
||||
$releaseIDs = $osNode.OSReleaseIdFileName -replace 'H', 'h' -split ' '
|
||||
if ($releaseIDs -contains $version.ToLower()) {
|
||||
return @{ Node = $osNode; Version = $version }
|
||||
}
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# 1. Attempt Exact Match (Requested Release and Version)
|
||||
WriteLog "Attempting to find exact match for Win$($WindowsRelease) ($($WindowsVersion))..."
|
||||
$exactMatchResult = Find-MatchingOSNode -ReleaseToSearch $WindowsRelease -VersionsToSearch @($WindowsVersion)
|
||||
if ($null -ne $exactMatchResult) {
|
||||
$selectedOSNode = $exactMatchResult.Node
|
||||
$selectedOSVersion = $exactMatchResult.Version
|
||||
WriteLog "Exact match found: Win$($selectedOSRelease) ($($selectedOSVersion))."
|
||||
}
|
||||
else {
|
||||
WriteLog "Exact match not found for Win$($WindowsRelease) ($($WindowsVersion))."
|
||||
# 2. Fallback: Same Release, Other Versions (Newest First)
|
||||
WriteLog "Attempting fallback within Win$($WindowsRelease)..."
|
||||
$versionsForCurrentRelease = if ($WindowsRelease -eq 11) { $win11Versions } else { $win10Versions }
|
||||
$fallbackVersions = $versionsForCurrentRelease | Where-Object { $_ -ne $WindowsVersion }
|
||||
$fallbackResult = Find-MatchingOSNode -ReleaseToSearch $WindowsRelease -VersionsToSearch $fallbackVersions
|
||||
if ($null -ne $fallbackResult) {
|
||||
$selectedOSNode = $fallbackResult.Node
|
||||
$selectedOSVersion = $fallbackResult.Version
|
||||
WriteLog "Fallback successful within Win$($selectedOSRelease). Using version: $($selectedOSVersion)."
|
||||
}
|
||||
else {
|
||||
WriteLog "Fallback within Win$($WindowsRelease) unsuccessful."
|
||||
# 3. Fallback: Other Release, Versions (Newest First)
|
||||
$otherRelease = if ($WindowsRelease -eq 11) { 10 } else { 11 }
|
||||
WriteLog "Attempting fallback to Win$($otherRelease)..."
|
||||
$versionsForOtherRelease = if ($otherRelease -eq 11) { $win11Versions } else { $win10Versions }
|
||||
$otherFallbackResult = Find-MatchingOSNode -ReleaseToSearch $otherRelease -VersionsToSearch $versionsForOtherRelease
|
||||
if ($null -ne $otherFallbackResult) {
|
||||
$selectedOSNode = $otherFallbackResult.Node
|
||||
$selectedOSVersion = $otherFallbackResult.Version
|
||||
$selectedOSRelease = $otherRelease
|
||||
WriteLog "Fallback successful to Win$($selectedOSRelease). Using version: $($selectedOSVersion)."
|
||||
}
|
||||
else {
|
||||
WriteLog "Fallback to Win$($otherRelease) also failed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $selectedOSNode) {
|
||||
$allAvailableVersions = @()
|
||||
if ($platformNode.OS) {
|
||||
foreach ($osNode in $platformNode.OS) {
|
||||
$osRel = if ($osNode.IsWindows11 -contains 'true') { 11 } else { 10 }
|
||||
$relIDs = $osNode.OSReleaseIdFileName -replace 'H', 'h' -split ' '
|
||||
foreach ($id in $relIDs) { $allAvailableVersions += "Win$($osRel) $($id)" }
|
||||
}
|
||||
}
|
||||
$availableVersionsString = ($allAvailableVersions | Select-Object -Unique) -join ', '
|
||||
if ([string]::IsNullOrWhiteSpace($availableVersionsString)) { $availableVersionsString = "None" }
|
||||
throw "Could not find any suitable OS driver pack for model '$displayModelName' matching requested or fallback versions (Win$($WindowsRelease) $WindowsVersion). Available: $availableVersionsString"
|
||||
}
|
||||
|
||||
$osReleaseIdFileName = $selectedOSNode.OSReleaseIdFileName -replace 'H', 'h'
|
||||
WriteLog "Using SystemID: $systemID and OS Info: Win$($selectedOSRelease) ($($selectedOSVersion)) for '$displayModelName'"
|
||||
$archSuffix = $WindowsArch -replace "^x", ""
|
||||
$modelRelease = "$($systemID)_$($archSuffix)_$($selectedOSRelease).0.$($selectedOSVersion.ToLower())"
|
||||
$driverCabUrl = "https://hpia.hpcloud.hp.com/ref/$systemID/$modelRelease.cab"
|
||||
$driverCabFile = Join-Path -Path $hpDriversBaseFolder -ChildPath "$modelRelease.cab" # Store in base HP folder
|
||||
$driverXmlFile = Join-Path -Path $hpDriversBaseFolder -ChildPath "$modelRelease.xml" # Store in base HP folder
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Downloading driver index..." }
|
||||
WriteLog "Downloading HP Driver cab from $driverCabUrl to $driverCabFile"
|
||||
Start-BitsTransferWithRetry -Source $driverCabUrl -Destination $driverCabFile -ErrorAction Stop
|
||||
WriteLog "Expanding HP Driver cab $driverCabFile to $driverXmlFile"
|
||||
if (Test-Path -Path $driverXmlFile) { Remove-Item -Path $driverXmlFile -Force }
|
||||
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$driverCabFile`"", "`"$driverXmlFile`"") -ErrorAction Stop | Out-Null
|
||||
|
||||
WriteLog "Parsing driver XML $driverXmlFile"
|
||||
[xml]$driverXmlContent = Get-Content -Path $driverXmlFile -Raw -Encoding UTF8 -ErrorAction Stop
|
||||
$updates = $driverXmlContent.ImagePal.Solutions.UpdateInfo | Where-Object { $_.Category -match '^Driver' }
|
||||
$totalDrivers = ($updates | Measure-Object).Count
|
||||
$downloadedCount = 0
|
||||
WriteLog "Found $totalDrivers driver updates for $displayModelName."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Found $totalDrivers drivers. Downloading..." }
|
||||
|
||||
if (-not (Test-Path -Path $modelSpecificFolder)) {
|
||||
New-Item -Path $modelSpecificFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
|
||||
foreach ($update in $updates) {
|
||||
$driverName = $update.Name -replace '[\\/:"*?<>|]', '_'
|
||||
$category = $update.Category -replace '[\\/:"*?<>|]', '_'
|
||||
$version = $update.Version -replace '[\\/:"*?<>|]', '_'
|
||||
$driverUrl = "https://$($update.URL)"
|
||||
$driverFileName = Split-Path -Path $driverUrl -Leaf
|
||||
$downloadFolder = Join-Path -Path $modelSpecificFolder -ChildPath $category
|
||||
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driverFileName
|
||||
$extractFolder = Join-Path -Path $downloadFolder -ChildPath ($driverName + "_" + $version + "_" + ($driverFileName -replace '\.exe$', ''))
|
||||
|
||||
$downloadedCount++
|
||||
$progressMsg = "$downloadedCount/$totalDrivers Downloading $driverName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $progressMsg }
|
||||
WriteLog "$progressMsg URL: $driverUrl"
|
||||
|
||||
if (Test-Path -Path $extractFolder) {
|
||||
WriteLog "Driver already extracted to $extractFolder, skipping download."
|
||||
continue
|
||||
}
|
||||
if (-not (Test-Path -Path $downloadFolder)) {
|
||||
New-Item -Path $downloadFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
WriteLog "Downloading driver to: $driverFilePath"
|
||||
Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath -ErrorAction Stop
|
||||
WriteLog "Driver downloaded: $driverFilePath"
|
||||
$progressMsg = "$downloadedCount/$totalDrivers Extracting $driverName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $progressMsg }
|
||||
WriteLog "Creating extraction folder: $extractFolder"
|
||||
New-Item -Path $extractFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
$arguments = "/s /e /f `"$extractFolder`""
|
||||
WriteLog "Extracting driver $driverFilePath with args: $arguments"
|
||||
WriteLog "Running HP Driver Extraction Command: $driverFilePath $arguments"
|
||||
Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -ErrorAction Stop | Out-Null
|
||||
# Start-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait -NoNewWindow -ErrorAction Stop | Out-Null
|
||||
WriteLog "Driver extracted to: $extractFolder"
|
||||
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
|
||||
WriteLog "Deleted driver installer: $driverFilePath"
|
||||
}
|
||||
|
||||
Remove-Item -Path $driverCabFile, $driverXmlFile -Force -ErrorAction SilentlyContinue
|
||||
WriteLog "Cleaned up driver cab and xml files for $displayModelName"
|
||||
|
||||
$finalStatus = "Completed"
|
||||
if ($CompressToWim) {
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing..." }
|
||||
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($identifier).wim"
|
||||
WriteLog "Compressing '$modelSpecificFolder' to '$wimFilePath'..."
|
||||
try {
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $modelSpecificFolder -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
WriteLog "Compression successful for '$identifier'."
|
||||
$finalStatus = "Completed & Compressed"
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath "$($identifier).wim" # Update relative path to the WIM
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error during compression for '$identifier': $($_.Exception.Message)"
|
||||
$finalStatus = "Completed (Compression Failed)"
|
||||
}
|
||||
}
|
||||
$successState = $true
|
||||
}
|
||||
catch {
|
||||
$errorMessage = "Error saving HP drivers for $($displayModelName): $($_.Exception.Message)"
|
||||
WriteLog $errorMessage
|
||||
$finalStatus = "Error: $($_.Exception.Message.Split([Environment]::NewLine)[0])"
|
||||
$successState = $false
|
||||
$driverRelativePath = $null # Ensure path is null on error
|
||||
Remove-DriverModelFolder -DriversFolder $DriversFolder -TargetFolder $modelSpecificFolder -Description $identifier
|
||||
}
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $finalStatus }
|
||||
return [PSCustomObject]@{ Identifier = $identifier; Status = $finalStatus; Success = $successState; DriverPath = $driverRelativePath }
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -0,0 +1,476 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Provides functions for discovering, downloading, and processing Lenovo drivers.
|
||||
.DESCRIPTION
|
||||
This module contains the logic specific to handling Lenovo drivers for the FFU Builder UI. It includes functions to query the Lenovo PSREF (Product Specification Reference) API to find and list available system models based on user search terms. It also provides the core background task for downloading all relevant driver packages for a selected model and Windows release. The download process involves parsing XML catalogs, downloading individual driver executables, silently extracting their contents, and organizing them into a structured folder. The module includes robust error handling, long path mitigation by using temporary extraction locations, and an option to compress the final driver set into a WIM archive.
|
||||
#>
|
||||
|
||||
# Function to get the list of Lenovo models using the PSREF API
|
||||
function Get-LenovoDriversModelList {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ModelSearchTerm, # User input for model/machine type
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$Headers,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$UserAgent
|
||||
)
|
||||
|
||||
# Lenovo is special - they prevent access to the PSREF API without a cookie as of July 2025.
|
||||
# This cookie must be retrieved via Javascript
|
||||
# It appears that the cookie is hard-coded. We'll see how long this lasts.
|
||||
# If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know.
|
||||
# https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models).
|
||||
|
||||
# $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
|
||||
|
||||
# Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes.
|
||||
$lenovoCookie = Get-LenovoPSREFToken
|
||||
|
||||
# Add the cookie to the headers
|
||||
$Headers["Cookie"] = $lenovoCookie
|
||||
|
||||
WriteLog "Querying Lenovo PSREF API for model/machine type: $ModelSearchTerm"
|
||||
$url = "https://psref.lenovo.com/api/search/DefinitionFilterAndSearch/Suggest?kw=$([uri]::EscapeDataString($ModelSearchTerm))"
|
||||
$models = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
try {
|
||||
$OriginalVerbosePreference = $VerbosePreference
|
||||
$VerbosePreference = 'SilentlyContinue'
|
||||
$response = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent -ErrorAction Stop
|
||||
$VerbosePreference = $OriginalVerbosePreference
|
||||
WriteLog "PSREF API query complete."
|
||||
|
||||
$jsonResponse = $response.Content | ConvertFrom-Json
|
||||
|
||||
if ($null -ne $jsonResponse.data -and $jsonResponse.data.Count -gt 0) {
|
||||
foreach ($item in $jsonResponse.data) {
|
||||
$productName = $item.ProductName
|
||||
$machineTypes = $item.MachineType -split " / " # Split if multiple machine types are listed
|
||||
|
||||
foreach ($machineTypeRaw in $machineTypes) {
|
||||
$machineType = $machineTypeRaw.Trim()
|
||||
# Only add if machine type is not empty
|
||||
if (-not [string]::IsNullOrWhiteSpace($machineType)) {
|
||||
# Create the combined display string
|
||||
$displayModel = "$productName ($machineType)"
|
||||
# Add each combination as a separate entry
|
||||
$models.Add([PSCustomObject]@{
|
||||
Make = 'Lenovo'
|
||||
Model = $displayModel
|
||||
ProductName = $productName
|
||||
MachineType = $machineType
|
||||
})
|
||||
}
|
||||
else {
|
||||
WriteLog "Skipping entry for product '$productName' due to missing machine type."
|
||||
}
|
||||
}
|
||||
}
|
||||
WriteLog "Found $($models.Count) potential model/machine type combinations for '$ModelSearchTerm'."
|
||||
}
|
||||
else {
|
||||
WriteLog "No models found matching '$ModelSearchTerm' in Lenovo PSREF."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error querying Lenovo PSREF API: $($_.Exception.Message)"
|
||||
# Return empty list on error
|
||||
}
|
||||
return $models
|
||||
}
|
||||
# Function to download and extract drivers for a specific Lenovo model (Background Task)
|
||||
function Save-LenovoDriversTask {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[PSCustomObject]$DriverItemData, # Contains Model (ProductName) and MachineType
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$WindowsRelease,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$Headers,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$UserAgent,
|
||||
[Parameter()]
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null,
|
||||
[Parameter()]
|
||||
[bool]$CompressToWim = $false,
|
||||
[Parameter()]
|
||||
[bool]$PreserveSourceOnCompress = $false
|
||||
)
|
||||
|
||||
# The Model property from the UI already contains the combined "ProductName (MachineType)" string
|
||||
$identifier = $DriverItemData.Model
|
||||
$machineType = $DriverItemData.MachineType
|
||||
$make = "Lenovo"
|
||||
$sanitizedIdentifier = ConvertTo-SafeName -Name $identifier
|
||||
if ($sanitizedIdentifier -ne $identifier) { WriteLog "Sanitized model identifier: '$identifier' -> '$sanitizedIdentifier'" }
|
||||
$status = "Starting..."
|
||||
$success = $false
|
||||
|
||||
# Define paths
|
||||
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
|
||||
# Use the identifier (which contains the model name and machine type) and sanitize it for the path
|
||||
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedIdentifier
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath $sanitizedIdentifier # Relative path for the driver folder
|
||||
$tempDownloadPath = Join-Path -Path $makeDriversPath -ChildPath "_TEMP_$($machineType)_$($PID)" # Temp folder for catalog/package XMLs
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Checking..." }
|
||||
|
||||
try {
|
||||
# Check for existing drivers
|
||||
$existingDriver = Test-ExistingDriver -Make $make -Model $sanitizedIdentifier -DriversFolder $DriversFolder -Identifier $identifier -ProgressQueue $ProgressQueue
|
||||
if ($null -ne $existingDriver) {
|
||||
# The return object from Test-ExistingDriver uses 'Model' as the identifier key.
|
||||
# We need to return 'Identifier' for Lenovo's logic.
|
||||
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Identifier' -Value $identifier -Force
|
||||
$existingDriver.PSObject.Properties.Remove('Model')
|
||||
|
||||
# Special handling for existing folders that need compression
|
||||
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
|
||||
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($sanitizedIdentifier).wim"
|
||||
$wimRelativePath = Join-Path -Path $make -ChildPath "$($sanitizedIdentifier).wim"
|
||||
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedIdentifier
|
||||
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." }
|
||||
try {
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
$existingDriver.Status = "Compression successful"
|
||||
$existingDriver.DriverPath = $wimRelativePath
|
||||
$existingDriver.Success = $true
|
||||
WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error compressing existing drivers for $($identifier): $($_.Exception.Message)"
|
||||
$existingDriver.Status = "Already downloaded (Compression failed)"
|
||||
$existingDriver.Success = $false
|
||||
}
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $existingDriver.Status }
|
||||
}
|
||||
|
||||
return $existingDriver
|
||||
}
|
||||
|
||||
# Ensure base directories exist
|
||||
if (-not (Test-Path -Path $makeDriversPath)) { New-Item -Path $makeDriversPath -ItemType Directory -Force | Out-Null }
|
||||
if (-not (Test-Path -Path $modelPath)) { New-Item -Path $modelPath -ItemType Directory -Force | Out-Null }
|
||||
if (-not (Test-Path -Path $tempDownloadPath)) { New-Item -Path $tempDownloadPath -ItemType Directory -Force | Out-Null }
|
||||
|
||||
# 2. Construct and Download Catalog URL
|
||||
$modelRelease = $machineType + "_Win" + $WindowsRelease
|
||||
$catalogUrl = "https://download.lenovo.com/catalog/$modelRelease.xml"
|
||||
$lenovoCatalogXML = Join-Path -Path $tempDownloadPath -ChildPath "$modelRelease.xml"
|
||||
|
||||
$status = "Downloading Catalog..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||
WriteLog "Downloading Lenovo Driver catalog for '$identifier' from $catalogUrl"
|
||||
|
||||
# Check URL accessibility first
|
||||
try {
|
||||
$request = [System.Net.WebRequest]::Create($catalogUrl); $request.Method = 'HEAD'; $response = $request.GetResponse(); $response.Close()
|
||||
}
|
||||
catch { throw "Lenovo Driver catalog URL is not accessible: $catalogUrl. Error: $($_.Exception.Message)" }
|
||||
|
||||
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $lenovoCatalogXML
|
||||
WriteLog "Catalog download Complete: $lenovoCatalogXML"
|
||||
|
||||
# 3. Parse Catalog and Process Packages
|
||||
$status = "Parsing Catalog..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||
[xml]$xmlContent = Get-Content -Path $lenovoCatalogXML -Encoding UTF8
|
||||
|
||||
$packages = @($xmlContent.packages.package) # Ensure it's an array
|
||||
$totalPackages = $packages.Count
|
||||
$processedPackages = 0
|
||||
WriteLog "Found $totalPackages packages in catalog for '$identifier'."
|
||||
|
||||
foreach ($package in $packages) {
|
||||
$processedPackages++
|
||||
$category = $package.category
|
||||
$packageUrl = $package.location # URL to the package's *XML* file
|
||||
|
||||
# Skip BIOS/Firmware based on category
|
||||
if ($category -like 'BIOS*' -or $category -like 'Firmware*') {
|
||||
WriteLog "($processedPackages/$totalPackages) Skipping BIOS/Firmware package: $category"
|
||||
continue
|
||||
}
|
||||
|
||||
# Sanitize category for path
|
||||
$categoryClean = $category -replace '[\\/:"*?<>|]', '_'
|
||||
if ($categoryClean -eq 'Motherboard Devices Backplanes core chipset onboard video PCIe switches') {
|
||||
$categoryClean = 'Motherboard Devices' # Shorten long category name
|
||||
}
|
||||
|
||||
$packageName = [System.IO.Path]::GetFileName($packageUrl)
|
||||
$packageXMLPath = Join-Path -Path $tempDownloadPath -ChildPath $packageName
|
||||
$baseURL = $packageUrl -replace [regex]::Escape($packageName), "" # Base URL for the driver file
|
||||
|
||||
# Download the package XML
|
||||
WriteLog "($processedPackages/$totalPackages) Downloading package XML: $packageUrl"
|
||||
try {
|
||||
Start-BitsTransferWithRetry -Source $packageUrl -Destination $packageXMLPath
|
||||
}
|
||||
catch {
|
||||
$failureMessage = "Failed to download Lenovo package XML '$packageUrl': $($_.Exception.Message)"
|
||||
WriteLog "($processedPackages/$totalPackages) $failureMessage"
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||
throw (New-Object System.Exception($failureMessage, $_.Exception))
|
||||
}
|
||||
|
||||
# Load and parse the package XML
|
||||
[xml]$packageXmlContent = Get-Content -Path $packageXMLPath -Encoding UTF8
|
||||
$packageType = $packageXmlContent.Package.PackageType.type
|
||||
$packageTitleRaw = $packageXmlContent.Package.title.InnerText
|
||||
|
||||
# Filter out non-driver packages (Type 2 = Driver)
|
||||
if ($packageType -ne 2) {
|
||||
WriteLog "($processedPackages/$totalPackages) Skipping package '$packageTitleRaw' (Type: $packageType) - Not a driver."
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
|
||||
continue
|
||||
}
|
||||
|
||||
# Sanitize title for folder name
|
||||
$packageTitle = $packageTitleRaw -replace '[\\/:"*?<>|]', '_' -replace ' - .*', ''
|
||||
|
||||
# Extract driver file name and extract command
|
||||
$driverFileName = $null
|
||||
$extractCommand = $null
|
||||
try {
|
||||
$driverFileName = $packageXmlContent.Package.Files.Installer.File.Name
|
||||
$extractCommand = $packageXmlContent.Package.ExtractCommand
|
||||
}
|
||||
catch {
|
||||
WriteLog "($processedPackages/$totalPackages) Error parsing package XML '$packageXMLPath' for file name/command. Skipping. Error: $($_.Exception.Message)"
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
# Skip if essential info is missing
|
||||
if ([string]::IsNullOrWhiteSpace($driverFileName) -or [string]::IsNullOrWhiteSpace($extractCommand)) {
|
||||
WriteLog "($processedPackages/$totalPackages) Skipping package '$packageTitleRaw' - Missing driver file name or extract command in XML."
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||
continue
|
||||
}
|
||||
|
||||
# Construct paths
|
||||
$driverUrl = $baseURL + $driverFileName
|
||||
$categoryPath = Join-Path -Path $modelPath -ChildPath $categoryClean
|
||||
$downloadFolder = Join-Path -Path $categoryPath -ChildPath $packageTitle # Final destination subfolder
|
||||
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driverFileName
|
||||
$extractFolder = Join-Path -Path $downloadFolder -ChildPath ($driverFileName -replace '\.exe$', '') # Extract to subfolder named after exe
|
||||
# Check if already extracted
|
||||
if (Test-Path -Path $extractFolder -PathType Container) {
|
||||
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($extractSize -gt 1KB) {
|
||||
WriteLog "($processedPackages/$totalPackages) Driver '$packageTitleRaw' already extracted to '$extractFolder'. Skipping."
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure download folder exists
|
||||
if (-not (Test-Path -Path $downloadFolder)) {
|
||||
New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
# Download the driver .exe
|
||||
$status = "$processedPackages/$totalPackages Downloading $packageTitle"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||
WriteLog "($processedPackages/$totalPackages) Downloading driver: $driverUrl to $driverFilePath"
|
||||
try {
|
||||
Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath
|
||||
WriteLog "($processedPackages/$totalPackages) Driver downloaded: $driverFileName"
|
||||
}
|
||||
catch {
|
||||
$failureMessage = "Failed to download driver '$packageTitle' from $($driverUrl): $($_.Exception.Message)"
|
||||
WriteLog "($processedPackages/$totalPackages) $failureMessage"
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||
throw (New-Object System.Exception($failureMessage, $_.Exception))
|
||||
}
|
||||
|
||||
# --- Extraction Logic ---
|
||||
$status = "$processedPackages/$totalPackages Extracting $packageTitle"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||
|
||||
# Always use a temporary extraction path to avoid long path issues
|
||||
$originalExtractFolder = $extractFolder # Store the originally intended final path
|
||||
$extractionSucceeded = $false
|
||||
$tempExtractBase = $null # Initialize
|
||||
|
||||
# Create randomized number for use with temp folder name
|
||||
$randomNumber = Get-Random -Minimum 1000 -Maximum 9999
|
||||
$tempExtractBase = Join-Path $env:TEMP "LenovoDriverExtract_$randomNumber"
|
||||
$extractFolder = Join-Path $tempExtractBase ($driverFileName -replace '\.exe$', '') # Actual temp extraction folder
|
||||
WriteLog "($processedPackages/$totalPackages) Using temporary extraction path: $extractFolder"
|
||||
|
||||
# Ensure the base temp directory exists
|
||||
if (-not (Test-Path -Path $tempExtractBase)) {
|
||||
New-Item -Path $tempExtractBase -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
# Ensure the target temporary extraction folder exists
|
||||
if (-not (Test-Path -Path $extractFolder)) {
|
||||
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
# Modify the extract command to point to the temporary folder
|
||||
$modifiedExtractCommand = $extractCommand -replace '%PACKAGEPATH%', "`"$extractFolder`""
|
||||
WriteLog "$processedPackages/$totalPackages Extracting driver: $driverFilePath using command: $modifiedExtractCommand"
|
||||
|
||||
try {
|
||||
Invoke-Process -FilePath $driverFilePath -ArgumentList $modifiedExtractCommand -Wait $true | Out-Null
|
||||
WriteLog "($processedPackages/$totalPackages) Driver extracted to temporary path: $extractFolder"
|
||||
$extractionSucceeded = $true
|
||||
}
|
||||
catch {
|
||||
$failureMessage = "Failed to extract driver package '$packageTitle': $($_.Exception.Message)"
|
||||
WriteLog "($processedPackages/$totalPackages) $failureMessage"
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||
if ($tempExtractBase -and (Test-Path -Path $tempExtractBase)) {
|
||||
Remove-Item -Path $tempExtractBase -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
throw (New-Object System.Exception($failureMessage, $_.Exception))
|
||||
}
|
||||
|
||||
# --- Post-Extraction Handling (Move from Temp to Final Destination) ---
|
||||
if ($extractionSucceeded) {
|
||||
WriteLog "($processedPackages/$totalPackages) Performing post-extraction move from temp to final destination..."
|
||||
try {
|
||||
# Ensure the *original* final destination folder exists and is empty
|
||||
if (Test-Path -Path $originalExtractFolder) {
|
||||
WriteLog "($processedPackages/$totalPackages) Clearing existing final destination folder: $originalExtractFolder"
|
||||
Get-ChildItem -Path $originalExtractFolder -Recurse | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
WriteLog "($processedPackages/$totalPackages) Creating final destination folder: $originalExtractFolder"
|
||||
New-Item -Path $originalExtractFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
# Get all items (files and folders) directly inside the temp extraction folder
|
||||
$extractedItems = Get-ChildItem -Path $extractFolder -ErrorAction Stop
|
||||
|
||||
foreach ($item in $extractedItems) {
|
||||
$itemName = $item.Name
|
||||
$finalDestinationPath = $null
|
||||
|
||||
# Check if it's a directory containing 'Liteon'
|
||||
if ($item.PSIsContainer -and $itemName -like '*Liteon*') {
|
||||
# Rename Liteon folders with a random number suffix
|
||||
$randomNumber = Get-Random -Minimum 1000 -Maximum 9999
|
||||
$finalFolderName = "Liteon_$randomNumber"
|
||||
$finalDestinationPath = Join-Path -Path $originalExtractFolder -ChildPath $finalFolderName
|
||||
WriteLog "($processedPackages/$totalPackages) Moving Liteon folder '$itemName' to '$finalDestinationPath'"
|
||||
}
|
||||
else {
|
||||
# For other files/folders, move them directly
|
||||
$finalDestinationPath = Join-Path -Path $originalExtractFolder -ChildPath $itemName
|
||||
WriteLog "($processedPackages/$totalPackages) Moving item '$itemName' to '$finalDestinationPath'"
|
||||
}
|
||||
|
||||
# Perform the move
|
||||
try {
|
||||
Move-Item -Path $item.FullName -Destination $finalDestinationPath -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$failureMessage = "Failed to move extracted item '$($item.FullName)' to '$finalDestinationPath': $($_.Exception.Message)"
|
||||
WriteLog "($processedPackages/$totalPackages) $failureMessage"
|
||||
throw (New-Object System.Exception($failureMessage, $_.Exception))
|
||||
}
|
||||
} # End foreach ($item in $extractedItems)
|
||||
|
||||
if ($extractionSucceeded) {
|
||||
WriteLog "($processedPackages/$totalPackages) All driver contents moved successfully from temp to final destination."
|
||||
}
|
||||
else {
|
||||
WriteLog "($processedPackages/$totalPackages) Some driver contents failed to move. Check logs."
|
||||
}
|
||||
|
||||
}
|
||||
catch {
|
||||
WriteLog "($processedPackages/$totalPackages) Error during post-extraction move: $($_.Exception.Message). Files might remain in temp."
|
||||
$extractionSucceeded = $false # Mark as failed for cleanup logic below
|
||||
}
|
||||
finally {
|
||||
# Clean up the base temporary directory regardless of move success/failure
|
||||
if ($tempExtractBase -and (Test-Path -Path $tempExtractBase)) {
|
||||
WriteLog "($processedPackages/$totalPackages) Cleaning up temporary extraction base: $tempExtractBase"
|
||||
Remove-Item -Path $tempExtractBase -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Final Cleanup ---
|
||||
# Delete the downloaded .exe only if extraction AND move were successful
|
||||
if ($extractionSucceeded) {
|
||||
WriteLog "($processedPackages/$totalPackages) Deleting driver installation file: $driverFilePath"
|
||||
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
WriteLog "($processedPackages/$totalPackages) Keeping driver installation file due to extraction/move failure: $driverFilePath"
|
||||
}
|
||||
# Always delete the package XML
|
||||
WriteLog "($processedPackages/$totalPackages) Deleting package XML file: $packageXMLPath"
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||
if (-not $extractionSucceeded) {
|
||||
throw (New-Object System.Exception("Failed to extract driver '$packageTitle'. See log for details."))
|
||||
}
|
||||
|
||||
} # End foreach package
|
||||
|
||||
# --- Compress to WIM if requested (after all drivers processed) ---
|
||||
if ($CompressToWim) {
|
||||
$status = "Compressing..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||
$wimFileName = "$($sanitizedIdentifier).wim" # Use sanitized identifier for filename
|
||||
$destinationWimPath = Join-Path -Path $makeDriversPath -ChildPath $wimFileName
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath $wimFileName # Update relative path to the WIM file
|
||||
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
|
||||
try {
|
||||
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $identifier -WimDescription $identifier -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
if ($compressResult) {
|
||||
WriteLog "Compression successful for '$identifier'."
|
||||
$status = "Completed & Compressed"
|
||||
}
|
||||
else {
|
||||
WriteLog "Compression failed for '$identifier'. Check verbose/error output from Compress-DriverFolderToWim."
|
||||
$status = "Completed (Compression Failed)"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error during compression for '$identifier': $($_.Exception.Message)"
|
||||
$status = "Completed (Compression Error)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
$status = "Completed"
|
||||
}
|
||||
# --- End Compression ---
|
||||
|
||||
$success = $true # Mark success as download/extract was okay
|
||||
|
||||
}
|
||||
catch {
|
||||
$status = "Error: $($_.Exception.Message)"
|
||||
WriteLog "Error saving Lenovo drivers for '$identifier': $($_.Exception.ToString())"
|
||||
$success = $false
|
||||
Remove-DriverModelFolder -DriversFolder $DriversFolder -TargetFolder $modelPath -Description $identifier
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||
return [PSCustomObject]@{ Identifier = $identifier; Status = $status; Success = $success; DriverPath = $null }
|
||||
}
|
||||
finally {
|
||||
# Clean up the main catalog XML and temp folder
|
||||
WriteLog "Cleaning up temporary download folder: $tempDownloadPath"
|
||||
Remove-Item -Path $tempDownloadPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Enqueue the final status (success or error) before returning
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||
|
||||
# Return the final status
|
||||
return [PSCustomObject]@{ Identifier = $identifier; Status = $status; Success = $success; DriverPath = $driverRelativePath }
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -0,0 +1,546 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Provides functions for discovering, downloading, and processing Microsoft Surface device drivers.
|
||||
.DESCRIPTION
|
||||
This module contains the logic specific to handling Microsoft Surface drivers for the FFU UI. It includes a function to scrape the official Microsoft support website to build a list of available Surface models and their driver download pages. It also provides a robust, parallel-capable function to download the correct driver package (MSI or ZIP) based on the selected Windows release, extract its contents, and optionally compress them into a WIM archive. The download process includes logic to handle MSI installer mutexes to prevent conflicts during parallel execution.
|
||||
#>
|
||||
|
||||
# Function to get the list of Microsoft Surface models
|
||||
function Get-MicrosoftDriversModelList {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[hashtable]$Headers, # Pass necessary headers
|
||||
[string]$UserAgent, # Pass UserAgent
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder
|
||||
)
|
||||
|
||||
$url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120"
|
||||
$models = @()
|
||||
|
||||
# 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 Save-MicrosoftDriversTask {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[PSCustomObject]$DriverItemData, # Pass data, not the UI object
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$WindowsRelease,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$Headers, # Pass necessary headers
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$UserAgent, # Pass UserAgent
|
||||
[Parameter()] # Made optional
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
|
||||
[Parameter()]
|
||||
[bool]$CompressToWim = $false, # New parameter for compression
|
||||
[Parameter()]
|
||||
[bool]$PreserveSourceOnCompress = $false
|
||||
# REMOVED: UI-related parameters
|
||||
)
|
||||
|
||||
$modelName = $DriverItemData.Model
|
||||
$modelLink = $DriverItemData.Link
|
||||
$make = $DriverItemData.Make
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath $modelName # Relative path for the driver folder
|
||||
$status = "Getting download link..." # Initial local status
|
||||
$success = $false
|
||||
$sanitizedModelName = ConvertTo-SafeName -Name $modelName
|
||||
if ($sanitizedModelName -ne $modelName) { WriteLog "Sanitized model name: '$modelName' -> '$sanitizedModelName'" }
|
||||
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $make
|
||||
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedModelName
|
||||
|
||||
# Initial status update
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." }
|
||||
|
||||
try {
|
||||
# Check for existing drivers
|
||||
$existingDriver = Test-ExistingDriver -Make $make -Model $modelName -DriversFolder $DriversFolder -Identifier $modelName -ProgressQueue $ProgressQueue
|
||||
if ($null -ne $existingDriver) {
|
||||
# Add the 'Model' property to the return object for consistency if it's not there
|
||||
if (-not $existingDriver.PSObject.Properties['Model']) {
|
||||
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Model' -Value $modelName
|
||||
}
|
||||
|
||||
# Special handling for existing folders that need compression
|
||||
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
|
||||
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $make
|
||||
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($modelName).wim"
|
||||
$wimRelativePath = Join-Path -Path $make -ChildPath "$($modelName).wim"
|
||||
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
|
||||
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Compressing existing..." }
|
||||
try {
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $modelName -WimDescription "Drivers for $modelName" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
$existingDriver.Status = "Compression successful"
|
||||
$existingDriver.DriverPath = $wimRelativePath
|
||||
$existingDriver.Success = $true
|
||||
WriteLog "Successfully compressed existing drivers for $modelName to $wimFilePath."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error compressing existing drivers for $($modelName): $($_.Exception.Message)"
|
||||
$existingDriver.Status = "Already downloaded (Compression failed)"
|
||||
$existingDriver.Success = $false
|
||||
}
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $existingDriver.Status }
|
||||
}
|
||||
|
||||
return $existingDriver
|
||||
}
|
||||
|
||||
### GET THE DOWNLOAD LINK
|
||||
$status = "Getting download link..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
|
||||
# Initialize Win10/Win11 link variables
|
||||
$win10Link = $null
|
||||
$win10FileName = $null
|
||||
$win11Link = $null
|
||||
$win11FileName = $null
|
||||
|
||||
# Prefer cached Download Center details (Source C) to avoid unnecessary internet calls and cache rewrites
|
||||
$useCachedDownloadCenterDetails = $false
|
||||
try {
|
||||
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
|
||||
$cachedDetails = @($cache.DownloadCenterDetails | Where-Object { $_.Link -eq $modelLink } | Select-Object -First 1)
|
||||
if ($cachedDetails.Count -gt 0 -and $cachedDetails[0].Files -and $cachedDetails[0].Files.Count -gt 0) {
|
||||
$useCachedDownloadCenterDetails = $true
|
||||
WriteLog "Surface cache: Using cached Download Center details for $modelName from $modelLink"
|
||||
|
||||
foreach ($downloadFile in @($cachedDetails[0].Files)) {
|
||||
if ($null -eq $downloadFile) { continue }
|
||||
$currentFileName = $downloadFile.Name
|
||||
$fileUrl = $downloadFile.Url
|
||||
if ([string]::IsNullOrWhiteSpace($currentFileName) -or [string]::IsNullOrWhiteSpace($fileUrl)) { continue }
|
||||
|
||||
if ($currentFileName -match "Win10") {
|
||||
$win10Link = $fileUrl
|
||||
$win10FileName = $currentFileName
|
||||
WriteLog "Found Win10 link (cached): $win10FileName"
|
||||
}
|
||||
elseif ($currentFileName -match "Win11") {
|
||||
$win11Link = $fileUrl
|
||||
$win11FileName = $currentFileName
|
||||
WriteLog "Found Win11 link (cached): $win11FileName"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Surface cache: Failed loading cached Download Center details for '$modelName'. Error: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Cache miss: download and parse the model's Download Center page (Source C), then backfill the cache
|
||||
if (-not $useCachedDownloadCenterDetails) {
|
||||
WriteLog "Getting download page content for $modelName from $modelLink"
|
||||
$OriginalVerbosePreference = $VerbosePreference
|
||||
$VerbosePreference = 'SilentlyContinue'
|
||||
# Use passed-in UserAgent and Headers
|
||||
$downloadPageContent = Invoke-WebRequest -Uri $modelLink -UseBasicParsing -Headers $Headers -UserAgent $UserAgent
|
||||
$VerbosePreference = $OriginalVerbosePreference
|
||||
WriteLog "Complete"
|
||||
|
||||
$status = "Parsing download page..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
WriteLog "Parsing download page for file"
|
||||
$scriptPattern = '<script>window.__DLCDetails__={(.*?)}<\/script>'
|
||||
$scriptMatch = [regex]::Match($downloadPageContent.Content, $scriptPattern)
|
||||
|
||||
if ($scriptMatch.Success) {
|
||||
$scriptContent = $scriptMatch.Groups[1].Value
|
||||
# $downloadFilePattern = '"name":"(.*?)",.*?"url":"(.*?)"'
|
||||
$downloadFilePattern = '"name":"([^"]+\.(?:msi|zip))",[^}]*?"url":"(.*?)"'
|
||||
$downloadFileMatches = [regex]::Matches($scriptContent, $downloadFilePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
|
||||
# Iterate through all matches to find potential Win10 and Win11 links
|
||||
foreach ($downloadFile in $downloadFileMatches) {
|
||||
$currentFileName = $downloadFile.Groups[1].Value
|
||||
$fileUrl = $downloadFile.Groups[2].Value
|
||||
|
||||
if ($currentFileName -match "Win10") {
|
||||
$win10Link = $fileUrl
|
||||
$win10FileName = $currentFileName
|
||||
WriteLog "Found Win10 link: $win10FileName"
|
||||
}
|
||||
elseif ($currentFileName -match "Win11") {
|
||||
$win11Link = $fileUrl
|
||||
$win11FileName = $currentFileName
|
||||
WriteLog "Found Win11 link: $win11FileName"
|
||||
}
|
||||
}
|
||||
|
||||
# Update local cache with Download Center file details (Source C) for this model.
|
||||
# This runs during download (not during Get Models) so it won't slow the listview population.
|
||||
try {
|
||||
$filesForCache = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
if ($win10Link -and $win10FileName) {
|
||||
$filesForCache.Add([pscustomobject]@{ Name = $win10FileName; Url = $win10Link })
|
||||
}
|
||||
if ($win11Link -and $win11FileName) {
|
||||
$filesForCache.Add([pscustomobject]@{ Name = $win11FileName; Url = $win11Link })
|
||||
}
|
||||
|
||||
if ($filesForCache.Count -gt 0) {
|
||||
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
|
||||
$detailsEntry = [pscustomobject][ordered]@{
|
||||
Model = $modelName
|
||||
Link = $modelLink
|
||||
Files = @($filesForCache)
|
||||
}
|
||||
|
||||
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
foreach ($item in @($cache.DownloadCenterDetails)) {
|
||||
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $modelLink) {
|
||||
$newDetails.Add($item)
|
||||
}
|
||||
}
|
||||
$newDetails.Add($detailsEntry)
|
||||
$cache.DownloadCenterDetails = @($newDetails)
|
||||
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Surface cache: Failed updating Download Center details cache for '$modelName'. Error: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$useCachedDownloadCenterDetails = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($useCachedDownloadCenterDetails) {
|
||||
# Decision logic to select the appropriate download link
|
||||
$downloadLink = $null
|
||||
$fileName = $null
|
||||
$downloadedVersion = $null # Track which version we are actually downloading
|
||||
|
||||
if ($WindowsRelease -eq 10 -and $win10Link) {
|
||||
$downloadLink = $win10Link
|
||||
$fileName = $win10FileName
|
||||
$downloadedVersion = 10
|
||||
WriteLog "Exact match found for Win10."
|
||||
}
|
||||
elseif ($WindowsRelease -eq 11 -and $win11Link) {
|
||||
$downloadLink = $win11Link
|
||||
$fileName = $win11FileName
|
||||
$downloadedVersion = 11
|
||||
WriteLog "Exact match found for Win11."
|
||||
}
|
||||
elseif (-not $win10Link -and $win11Link) {
|
||||
# Only Win11 available, regardless of $WindowsRelease
|
||||
$downloadLink = $win11Link
|
||||
$fileName = $win11FileName
|
||||
$downloadedVersion = 11
|
||||
WriteLog "Exact match for Win$($WindowsRelease) not found. Falling back to available Win11 driver."
|
||||
}
|
||||
elseif ($win10Link -and -not $win11Link) {
|
||||
# Only Win10 available, regardless of $WindowsRelease
|
||||
$downloadLink = $win10Link
|
||||
$fileName = $win10FileName
|
||||
$downloadedVersion = 10
|
||||
WriteLog "Exact match for Win$($WindowsRelease) not found. Falling back to available Win10 driver."
|
||||
}
|
||||
# If both Win10 and Win11 links exist, but neither matches $WindowsRelease, $downloadLink remains $null.
|
||||
|
||||
### DOWNLOAD AND EXTRACT
|
||||
if ($downloadLink) {
|
||||
WriteLog "Selected Download Link for $modelName (Actual: Windows $downloadedVersion): $downloadLink"
|
||||
$status = "Downloading Win$downloadedVersion $fileName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
|
||||
# Create directories
|
||||
if (-not (Test-Path -Path $DriversFolder)) {
|
||||
WriteLog "Creating Drivers folder: $DriversFolder"
|
||||
New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
if (-Not (Test-Path -Path $modelPath)) {
|
||||
WriteLog "Creating model folder: $modelPath"
|
||||
New-Item -Path $modelPath -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
else {
|
||||
WriteLog "Model folder already exists: $modelPath"
|
||||
}
|
||||
|
||||
### DOWNLOAD
|
||||
$filePath = Join-Path -Path $makeDriversPath -ChildPath ($fileName)
|
||||
WriteLog "Downloading $modelName driver file to $filePath"
|
||||
# Use Start-BitsTransferWithRetry
|
||||
Start-BitsTransferWithRetry -Source $downloadLink -Destination $filePath
|
||||
WriteLog "Download complete"
|
||||
|
||||
$fileExtension = [System.IO.Path]::GetExtension($filePath).ToLower()
|
||||
|
||||
### EXTRACT
|
||||
if ($fileExtension -eq ".msi") {
|
||||
$status = "Waiting for MSI lock..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
|
||||
# Use a named mutex to ensure only one MSI extraction happens at a time across all parallel tasks
|
||||
$msiMutexName = "Global\FFUDevelopmentMSIExtractionMutex"
|
||||
$msiMutex = New-Object System.Threading.Mutex($false, $msiMutexName)
|
||||
|
||||
try {
|
||||
WriteLog "Waiting to acquire global MSI extraction lock for '$modelName'..."
|
||||
$msiMutex.WaitOne() | Out-Null
|
||||
WriteLog "Acquired global MSI extraction lock for '$modelName'."
|
||||
|
||||
# Loop indefinitely to wait for system mutex and handle MSIExec exit codes
|
||||
while ($true) {
|
||||
$mutexClear = $false
|
||||
|
||||
# 1. Check System-level MSI Mutex
|
||||
try {
|
||||
$sysMutex = [System.Threading.Mutex]::OpenExisting("Global\_MSIExecute")
|
||||
$sysMutex.Dispose()
|
||||
$status = "Waiting for MSIExec..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
WriteLog "Another MSIExec installer is running (System Mutex Held). Waiting 5 seconds before rechecking for $modelName..."
|
||||
Start-Sleep -Seconds 5
|
||||
continue # Go back to start of while loop to re-check mutex
|
||||
}
|
||||
catch [System.Threading.WaitHandleCannotBeOpenedException] {
|
||||
# Mutex is clear, proceed to extraction attempt
|
||||
WriteLog "System MSI mutex clear. Proceeding with MSI extraction attempt for $modelName."
|
||||
$status = "Extracting Win$downloadedVersion $fileName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
$mutexClear = $true
|
||||
}
|
||||
catch {
|
||||
# Handle other potential errors when checking the mutex
|
||||
WriteLog "Warning: Error checking system MSI mutex for $($modelName): $_. Proceeding with caution."
|
||||
$status = "Extracting Win$downloadedVersion $fileName (Mutex Error)"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
$mutexClear = $true # Proceed despite mutex error
|
||||
}
|
||||
|
||||
# 2. Attempt Extraction (only if mutex was clear)
|
||||
if ($mutexClear) {
|
||||
WriteLog "Extracting MSI file to $modelPath"
|
||||
$arguments = "/a `"$($filePath)`" /qn TARGETDIR=`"$($modelPath)`""
|
||||
try {
|
||||
# Use Invoke-Process. It will throw an error for any non-zero exit code.
|
||||
Invoke-Process -FilePath "msiexec.exe" -ArgumentList $arguments -Wait $true -ErrorAction Stop | Out-Null
|
||||
|
||||
# If Invoke-Process succeeded (didn't throw), extraction is complete.
|
||||
WriteLog "Extraction complete for $modelName (Exit Code 0)."
|
||||
|
||||
# Verification Step: Ensure the target folder is not empty.
|
||||
$itemsInDest = Get-ChildItem -Path $modelPath -Recurse
|
||||
if ($itemsInDest.Count -eq 0) {
|
||||
WriteLog "VERIFICATION FAILED: MSI extraction for '$modelName' produced an empty folder. Retrying..."
|
||||
$status = "Retrying (Empty Folder)"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
Start-Sleep -Seconds 5
|
||||
continue # Retry the whole process
|
||||
}
|
||||
|
||||
WriteLog "VERIFICATION PASSED: Target folder for '$modelName' is not empty."
|
||||
break # Success, exit the while loop
|
||||
}
|
||||
catch {
|
||||
# Catch errors thrown by Invoke-Process
|
||||
$errorMessage = $_.Exception.Message
|
||||
if ($errorMessage -match 'Process exited with code 1618') {
|
||||
# Specific handling for MSIExec busy error (1618)
|
||||
WriteLog "MSIExec collision detected (Exit Code 1618) for $modelName. Retrying after wait..."
|
||||
$status = "Waiting (MSI Collision)..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
Start-Sleep -Seconds 5 # Wait before retrying
|
||||
continue # Go back to start of while loop to re-check mutex/retry
|
||||
}
|
||||
else {
|
||||
# Handle other errors from Invoke-Process (e.g., file not found, permissions, other exit codes)
|
||||
WriteLog "Error during MSI extraction process for $($modelName): $errorMessage"
|
||||
throw # Re-throw the original exception to be caught by the outer try/catch
|
||||
}
|
||||
}
|
||||
} # End if ($mutexClear)
|
||||
} # End while ($true)
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $msiMutex) {
|
||||
$msiMutex.ReleaseMutex()
|
||||
$msiMutex.Dispose()
|
||||
WriteLog "Released global MSI extraction lock for '$modelName'."
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($fileExtension -eq ".zip") {
|
||||
$status = "Extracting Win$downloadedVersion $fileName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
WriteLog "Extracting ZIP file to $modelPath"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Expand-Archive -Path $filePath -DestinationPath $modelPath -Force
|
||||
$ProgressPreference = 'Continue'
|
||||
WriteLog "Extraction complete"
|
||||
}
|
||||
else {
|
||||
WriteLog "Unsupported file type: $fileExtension"
|
||||
throw "Unsupported file type: $fileExtension"
|
||||
}
|
||||
# Remove downloaded file
|
||||
$status = "Cleaning up..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
WriteLog "Removing $filePath"
|
||||
Remove-Item -Path $filePath -Force
|
||||
WriteLog "Cleanup complete." # Changed log message slightly
|
||||
|
||||
# --- Compress to WIM if requested ---
|
||||
if ($CompressToWim) {
|
||||
$status = "Compressing..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
$wimFileName = "$($modelName).wim"
|
||||
# Corrected WIM path: WIM file should be next to the model folder, not inside it.
|
||||
$destinationWimPath = Join-Path -Path $makeDriversPath -ChildPath $wimFileName
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath $wimFileName # Update relative path to the WIM file
|
||||
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
|
||||
try {
|
||||
# Use the function from the imported common module
|
||||
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
if ($compressResult) {
|
||||
WriteLog "Compression successful for '$modelName'."
|
||||
$status = "Completed & Compressed"
|
||||
}
|
||||
else {
|
||||
WriteLog "Compression failed for '$modelName'. Check verbose/error output from Compress-DriverFolderToWim."
|
||||
$status = "Completed (Compression Failed)"
|
||||
# Don't mark overall success as false, download/extract succeeded
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error during compression for '$modelName': $($_.Exception.Message)"
|
||||
$status = "Completed (Compression Error)"
|
||||
# Don't mark overall success as false
|
||||
}
|
||||
}
|
||||
else {
|
||||
$status = "Completed" # Final status if not compressing
|
||||
}
|
||||
# --- End Compression ---
|
||||
|
||||
$success = $true # Mark success as download/extract was okay
|
||||
} # End if/elseif for .msi/.zip
|
||||
else {
|
||||
WriteLog "No suitable download link found for Windows $WindowsRelease (or fallback) for model $modelName."
|
||||
$status = "Error: No Win$($WindowsRelease)/Fallback link"
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Failed to parse the download page for the driver file for model $modelName."
|
||||
$status = "Error: Parse failed"
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
|
||||
WriteLog "Error saving Microsoft drivers for $($modelName): $($_.Exception.Message)"
|
||||
$success = $false
|
||||
Remove-DriverModelFolder -DriversFolder $DriversFolder -TargetFolder $modelPath -Description $modelName
|
||||
# Enqueue the error status before returning
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
# Ensure return object is created even on error
|
||||
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; DriverPath = $null }
|
||||
}
|
||||
|
||||
# Enqueue the final status (success or error) before returning
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
|
||||
# Return the final status (this is still used by Receive-Job for final confirmation)
|
||||
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; DriverPath = $driverRelativePath }
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -0,0 +1,746 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Initializes the user interface for the BuildFFUVM_UI application.
|
||||
|
||||
.DESCRIPTION
|
||||
This script module contains functions responsible for initializing the WPF user interface.
|
||||
It handles several key tasks:
|
||||
- Caching references to all UI controls for efficient access.
|
||||
- Populating UI elements like combo boxes with data (e.g., Hyper-V switches).
|
||||
- Setting default values for all controls based on configuration or predefined settings.
|
||||
- Dynamically creating and configuring complex UI components, such as sortable/selectable GridView columns and feature selection grids.
|
||||
|
||||
This module is critical for setting up the initial state of the application window when it first loads.
|
||||
#>
|
||||
|
||||
function Initialize-UIControls {
|
||||
param([PSCustomObject]$State)
|
||||
WriteLog "Initializing UI control references..."
|
||||
$window = $State.Window
|
||||
# Find all controls ONCE and store them in the state object
|
||||
$State.Controls.cmbWindowsRelease = $window.FindName('cmbWindowsRelease')
|
||||
$State.Controls.cmbWindowsVersion = $window.FindName('cmbWindowsVersion')
|
||||
$State.Controls.txtISOPath = $window.FindName('txtISOPath')
|
||||
$State.Controls.btnBrowseISO = $window.FindName('btnBrowseISO')
|
||||
$State.Controls.cmbWindowsArch = $window.FindName('cmbWindowsArch')
|
||||
$State.Controls.cmbWindowsLang = $window.FindName('cmbWindowsLang')
|
||||
$State.Controls.WindowsLangStackPanel = $window.FindName('WindowsLangStackPanel')
|
||||
$State.Controls.cmbWindowsSKU = $window.FindName('cmbWindowsSKU')
|
||||
$State.Controls.cmbMediaType = $window.FindName('cmbMediaType')
|
||||
$State.Controls.MediaTypeStackPanel = $window.FindName('MediaTypeStackPanel')
|
||||
$State.Controls.featuresPanel = $window.FindName('stackFeaturesContainer')
|
||||
$State.Controls.chkDownloadDrivers = $window.FindName('chkDownloadDrivers')
|
||||
$State.Controls.cmbMake = $window.FindName('cmbMake')
|
||||
$State.Controls.spMakeSection = $window.FindName('spMakeSection')
|
||||
$State.Controls.btnGetModels = $window.FindName('btnGetModels')
|
||||
$State.Controls.spModelFilterSection = $window.FindName('spModelFilterSection')
|
||||
$State.Controls.txtModelFilter = $window.FindName('txtModelFilter')
|
||||
$State.Controls.lstDriverModels = $window.FindName('lstDriverModels')
|
||||
$State.Controls.spDriverActionButtons = $window.FindName('spDriverActionButtons')
|
||||
$State.Controls.btnSaveDriversJson = $window.FindName('btnSaveDriversJson')
|
||||
$State.Controls.btnImportDriversJson = $window.FindName('btnImportDriversJson')
|
||||
$State.Controls.btnDownloadSelectedDrivers = $window.FindName('btnDownloadSelectedDrivers')
|
||||
$State.Controls.btnClearDriverList = $window.FindName('btnClearDriverList')
|
||||
$State.Controls.chkInstallOffice = $window.FindName('chkInstallOffice')
|
||||
$State.Controls.chkInstallApps = $window.FindName('chkInstallApps')
|
||||
$State.Controls.OfficePathStackPanel = $window.FindName('OfficePathStackPanel')
|
||||
$State.Controls.OfficePathGrid = $window.FindName('OfficePathGrid')
|
||||
$State.Controls.CopyOfficeConfigXMLStackPanel = $window.FindName('CopyOfficeConfigXMLStackPanel')
|
||||
$State.Controls.OfficeConfigurationXMLFileStackPanel = $window.FindName('OfficeConfigurationXMLFileStackPanel')
|
||||
$State.Controls.OfficeConfigurationXMLFileGrid = $window.FindName('OfficeConfigurationXMLFileGrid')
|
||||
$State.Controls.chkCopyOfficeConfigXML = $window.FindName('chkCopyOfficeConfigXML')
|
||||
$State.Controls.chkLatestCU = $window.FindName('chkUpdateLatestCU')
|
||||
$State.Controls.chkPreviewCU = $window.FindName('chkUpdatePreviewCU')
|
||||
$State.Controls.btnCheckUSBDrives = $window.FindName('btnCheckUSBDrives')
|
||||
$State.Controls.lstUSBDrives = $window.FindName('lstUSBDrives')
|
||||
$State.Controls.chkBuildUSBDriveEnable = $window.FindName('chkBuildUSBDriveEnable')
|
||||
$State.Controls.usbSection = $window.FindName('usbDriveSection')
|
||||
$State.Controls.chkSelectSpecificUSBDrives = $window.FindName('chkSelectSpecificUSBDrives')
|
||||
$State.Controls.usbSelectionPanel = $window.FindName('usbDriveSelectionPanel')
|
||||
$State.Controls.chkAllowExternalHardDiskMedia = $window.FindName('chkAllowExternalHardDiskMedia')
|
||||
$State.Controls.chkPromptExternalHardDiskMedia = $window.FindName('chkPromptExternalHardDiskMedia')
|
||||
$State.Controls.chkCopyAdditionalFFUFiles = $window.FindName('chkCopyAdditionalFFUFiles')
|
||||
$State.Controls.additionalFFUPanel = $window.FindName('additionalFFUPanel')
|
||||
$State.Controls.lstAdditionalFFUs = $window.FindName('lstAdditionalFFUs')
|
||||
$State.Controls.btnRefreshAdditionalFFUs = $window.FindName('btnRefreshAdditionalFFUs')
|
||||
$State.Controls.chkInstallWingetApps = $window.FindName('chkInstallWingetApps')
|
||||
$State.Controls.wingetPanel = $window.FindName('wingetPanel')
|
||||
$State.Controls.btnCheckWingetModule = $window.FindName('btnCheckWingetModule')
|
||||
$State.Controls.txtWingetVersion = $window.FindName('txtWingetVersion')
|
||||
$State.Controls.txtWingetModuleVersion = $window.FindName('txtWingetModuleVersion')
|
||||
$State.Controls.applicationPathPanel = $window.FindName('applicationPathPanel')
|
||||
$State.Controls.appListJsonPathPanel = $window.FindName('appListJsonPathPanel')
|
||||
$State.Controls.btnBrowseApplicationPath = $window.FindName('btnBrowseApplicationPath')
|
||||
$State.Controls.btnBrowseAppListJsonPath = $window.FindName('btnBrowseAppListJsonPath')
|
||||
$State.Controls.chkBringYourOwnApps = $window.FindName('chkBringYourOwnApps')
|
||||
$State.Controls.byoApplicationPanel = $window.FindName('byoApplicationPanel')
|
||||
$State.Controls.wingetSearchPanel = $window.FindName('wingetSearchPanel')
|
||||
$State.Controls.txtWingetSearch = $window.FindName('txtWingetSearch')
|
||||
$State.Controls.btnWingetSearch = $window.FindName('btnWingetSearch')
|
||||
$State.Controls.lstWingetResults = $window.FindName('lstWingetResults')
|
||||
$State.Controls.btnSaveWingetList = $window.FindName('btnSaveWingetList')
|
||||
$State.Controls.btnImportWingetList = $window.FindName('btnImportWingetList')
|
||||
$State.Controls.btnClearWingetList = $window.FindName('btnClearWingetList')
|
||||
$State.Controls.btnDownloadSelected = $window.FindName('btnDownloadSelected')
|
||||
$State.Controls.btnBrowseAppSource = $window.FindName('btnBrowseAppSource')
|
||||
$State.Controls.btnBrowseFFUDevPath = $window.FindName('btnBrowseFFUDevPath')
|
||||
$State.Controls.btnBrowseFFUCaptureLocation = $window.FindName('btnBrowseFFUCaptureLocation')
|
||||
$State.Controls.btnBrowseOfficePath = $window.FindName('btnBrowseOfficePath')
|
||||
$State.Controls.btnBrowseDriversFolder = $window.FindName('btnBrowseDriversFolder')
|
||||
$State.Controls.btnBrowsePEDriversFolder = $window.FindName('btnBrowsePEDriversFolder')
|
||||
$State.Controls.txtAppName = $window.FindName('txtAppName')
|
||||
$State.Controls.txtAppCommandLine = $window.FindName('txtAppCommandLine')
|
||||
$State.Controls.txtAppArguments = $window.FindName('txtAppArguments')
|
||||
$State.Controls.txtAppSource = $window.FindName('txtAppSource')
|
||||
$State.Controls.txtAppAdditionalExitCodes = $window.FindName('txtAppAdditionalExitCodes')
|
||||
$State.Controls.chkIgnoreExitCodes = $window.FindName('chkIgnoreExitCodes')
|
||||
$State.Controls.btnAddApplication = $window.FindName('btnAddApplication')
|
||||
$State.Controls.btnSaveBYOApplications = $window.FindName('btnSaveBYOApplications')
|
||||
$State.Controls.btnLoadBYOApplications = $window.FindName('btnLoadBYOApplications')
|
||||
$State.Controls.btnEditApplication = $window.FindName('btnEditApplication')
|
||||
$State.Controls.btnClearBYOApplications = $window.FindName('btnClearBYOApplications')
|
||||
$State.Controls.btnRemoveSelectedBYOApps = $window.FindName('btnRemoveSelectedBYOApps')
|
||||
$State.Controls.btnCopyBYOApps = $window.FindName('btnCopyBYOApps')
|
||||
$State.Controls.lstApplications = $window.FindName('lstApplications')
|
||||
$State.Controls.btnMoveTop = $window.FindName('btnMoveTop')
|
||||
$State.Controls.btnMoveUp = $window.FindName('btnMoveUp')
|
||||
$State.Controls.btnMoveDown = $window.FindName('btnMoveDown')
|
||||
$State.Controls.btnMoveBottom = $window.FindName('btnMoveBottom')
|
||||
$State.Controls.txtStatus = $window.FindName('txtStatus')
|
||||
$State.Controls.pbOverallProgress = $window.FindName('progressBar')
|
||||
$State.Controls.txtOverallStatus = $window.FindName('txtStatus')
|
||||
$State.Controls.cmbVMSwitchName = $window.FindName('cmbVMSwitchName')
|
||||
$State.Controls.txtVMHostIPAddress = $window.FindName('txtVMHostIPAddress')
|
||||
$State.Controls.txtCustomVMSwitchName = $window.FindName('txtCustomVMSwitchName')
|
||||
$State.Controls.txtFFUDevPath = $window.FindName('txtFFUDevPath')
|
||||
$State.Controls.txtCustomFFUNameTemplate = $window.FindName('txtCustomFFUNameTemplate')
|
||||
$State.Controls.txtFFUCaptureLocation = $window.FindName('txtFFUCaptureLocation')
|
||||
$State.Controls.txtShareName = $window.FindName('txtShareName')
|
||||
$State.Controls.txtUsername = $window.FindName('txtUsername')
|
||||
$State.Controls.txtThreads = $window.FindName('txtThreads')
|
||||
$State.Controls.cmbBitsPriority = $window.FindName('cmbBitsPriority')
|
||||
$State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives')
|
||||
$State.Controls.chkCompactOS = $window.FindName('chkCompactOS')
|
||||
$State.Controls.chkOptimize = $window.FindName('chkOptimize')
|
||||
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
|
||||
$State.Controls.chkCreateCaptureMedia = $window.FindName('chkCreateCaptureMedia')
|
||||
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
|
||||
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
|
||||
$State.Controls.chkVerbose = $window.FindName('chkVerbose')
|
||||
$State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot')
|
||||
$State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend')
|
||||
$State.Controls.chkCopyPPKG = $window.FindName('chkCopyPPKG')
|
||||
$State.Controls.chkCleanupAppsISO = $window.FindName('chkCleanupAppsISO')
|
||||
$State.Controls.chkCleanupCaptureISO = $window.FindName('chkCleanupCaptureISO')
|
||||
$State.Controls.chkCleanupDeployISO = $window.FindName('chkCleanupDeployISO')
|
||||
$State.Controls.chkCleanupDrivers = $window.FindName('chkCleanupDrivers')
|
||||
$State.Controls.chkRemoveFFU = $window.FindName('chkRemoveFFU')
|
||||
$State.Controls.txtDiskSize = $window.FindName('txtDiskSize')
|
||||
$State.Controls.txtMemory = $window.FindName('txtMemory')
|
||||
$State.Controls.txtProcessors = $window.FindName('txtProcessors')
|
||||
$State.Controls.txtVMLocation = $window.FindName('txtVMLocation')
|
||||
$State.Controls.txtVMNamePrefix = $window.FindName('txtVMNamePrefix')
|
||||
$State.Controls.cmbLogicalSectorSize = $window.FindName('cmbLogicalSectorSize')
|
||||
$State.Controls.txtProductKey = $window.FindName('txtProductKey')
|
||||
$State.Controls.txtOfficePath = $window.FindName('txtOfficePath')
|
||||
$State.Controls.txtOfficeConfigXMLFilePath = $window.FindName('txtOfficeConfigXMLFilePath')
|
||||
$State.Controls.btnBrowseOfficeConfigXMLFile = $window.FindName('btnBrowseOfficeConfigXMLFile')
|
||||
$State.Controls.txtDriversFolder = $window.FindName('txtDriversFolder')
|
||||
$State.Controls.txtPEDriversFolder = $window.FindName('txtPEDriversFolder')
|
||||
$State.Controls.chkCopyPEDrivers = $window.FindName('chkCopyPEDrivers')
|
||||
$State.Controls.chkUseDriversAsPEDrivers = $window.FindName('chkUseDriversAsPEDrivers')
|
||||
$State.Controls.chkUpdateLatestCU = $window.FindName('chkUpdateLatestCU')
|
||||
$State.Controls.chkUpdateLatestNet = $window.FindName('chkUpdateLatestNet')
|
||||
$State.Controls.chkUpdateLatestDefender = $window.FindName('chkUpdateLatestDefender')
|
||||
$State.Controls.chkUpdateEdge = $window.FindName('chkUpdateEdge')
|
||||
$State.Controls.chkUpdateOneDrive = $window.FindName('chkUpdateOneDrive')
|
||||
$State.Controls.chkUpdateLatestMSRT = $window.FindName('chkUpdateLatestMSRT')
|
||||
$State.Controls.chkUpdatePreviewCU = $window.FindName('chkUpdatePreviewCU')
|
||||
$State.Controls.txtApplicationPath = $window.FindName('txtApplicationPath')
|
||||
$State.Controls.txtAppListJsonPath = $window.FindName('txtAppListJsonPath')
|
||||
$State.Controls.chkInstallDrivers = $window.FindName('chkInstallDrivers')
|
||||
$State.Controls.chkCopyDrivers = $window.FindName('chkCopyDrivers')
|
||||
$State.Controls.chkCompressDriversToWIM = $window.FindName('chkCompressDriversToWIM')
|
||||
$State.Controls.chkRemoveApps = $window.FindName('chkRemoveApps')
|
||||
$State.Controls.chkRemoveUpdates = $window.FindName('chkRemoveUpdates')
|
||||
$State.Controls.chkUpdateLatestMicrocode = $window.FindName('chkUpdateLatestMicrocode')
|
||||
$State.Controls.chkDefineAppsScriptVariables = $window.FindName('chkDefineAppsScriptVariables')
|
||||
$State.Controls.appsScriptVariablesPanel = $window.FindName('appsScriptVariablesPanel')
|
||||
$State.Controls.txtAppsScriptKey = $window.FindName('txtAppsScriptKey')
|
||||
$State.Controls.txtAppsScriptValue = $window.FindName('txtAppsScriptValue')
|
||||
$State.Controls.btnAddAppsScriptVariable = $window.FindName('btnAddAppsScriptVariable')
|
||||
$State.Controls.lstAppsScriptVariables = $window.FindName('lstAppsScriptVariables')
|
||||
$State.Controls.btnRemoveSelectedAppsScriptVariables = $window.FindName('btnRemoveSelectedAppsScriptVariables')
|
||||
$State.Controls.btnClearAppsScriptVariables = $window.FindName('btnClearAppsScriptVariables')
|
||||
$State.Controls.txtDriversJsonPath = $window.FindName('txtDriversJsonPath')
|
||||
$State.Controls.btnBrowseDriversJsonPath = $window.FindName('btnBrowseDriversJsonPath')
|
||||
$State.Controls.chkUpdateADK = $window.FindName('chkUpdateADK')
|
||||
$State.Controls.btnLoadConfig = $window.FindName('btnLoadConfig')
|
||||
$State.Controls.btnRestoreDefaults = $window.FindName('btnRestoreDefaults')
|
||||
$State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig')
|
||||
|
||||
# Monitor Tab
|
||||
$State.Controls.MainTabControl = $window.FindName('MainTabControl')
|
||||
$State.Controls.MonitorTab = $window.FindName('MonitorTab')
|
||||
$State.Controls.lstLogOutput = $window.FindName('lstLogOutput')
|
||||
|
||||
# Initialize and bind the log data collection
|
||||
$State.Data.logData = New-Object System.Collections.ObjectModel.ObservableCollection[string]
|
||||
$State.Controls.lstLogOutput.ItemsSource = $State.Data.logData
|
||||
}
|
||||
|
||||
function Initialize-VMSwitchData {
|
||||
param([PSCustomObject]$State)
|
||||
|
||||
WriteLog "Initializing VM Switch data..."
|
||||
|
||||
# Hyper-V Settings: Populate VM Switch ComboBox
|
||||
$vmSwitchData = Get-VMSwitchData
|
||||
$State.Data.vmSwitchMap = $vmSwitchData.SwitchMap
|
||||
$State.Controls.cmbVMSwitchName.Items.Clear()
|
||||
foreach ($switchName in $vmSwitchData.SwitchNames) {
|
||||
$State.Controls.cmbVMSwitchName.Items.Add($switchName) | Out-Null
|
||||
}
|
||||
$State.Controls.cmbVMSwitchName.Items.Add('Other') | Out-Null
|
||||
if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) {
|
||||
$State.Controls.cmbVMSwitchName.SelectedIndex = 0
|
||||
$firstSwitch = $State.Controls.cmbVMSwitchName.SelectedItem
|
||||
if ($null -ne $firstSwitch -and $State.Data.vmSwitchMap.ContainsKey($firstSwitch)) {
|
||||
$State.Controls.txtVMHostIPAddress.Text = $State.Data.vmSwitchMap[$firstSwitch]
|
||||
}
|
||||
else {
|
||||
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default if IP not found or key null
|
||||
}
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
|
||||
}
|
||||
else {
|
||||
$State.Controls.cmbVMSwitchName.SelectedItem = 'Other'
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
|
||||
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default
|
||||
}
|
||||
}
|
||||
|
||||
function Initialize-UIDefaults {
|
||||
param([PSCustomObject]$State)
|
||||
WriteLog "Initializing UI defaults..."
|
||||
|
||||
# Get default values from helper functions
|
||||
$State.Defaults.windowsSettingsDefaults = Get-WindowsSettingsDefaults
|
||||
$State.Defaults.generalDefaults = Get-GeneralDefaults -FFUDevelopmentPath $State.FFUDevelopmentPath
|
||||
|
||||
# Build tab defaults from General Defaults
|
||||
$State.Controls.txtFFUDevPath.Text = $State.FFUDevelopmentPath
|
||||
$State.Controls.txtCustomFFUNameTemplate.Text = $State.Defaults.generalDefaults.CustomFFUNameTemplate
|
||||
$State.Controls.txtFFUCaptureLocation.Text = $State.Defaults.generalDefaults.FFUCaptureLocation
|
||||
$State.Controls.txtShareName.Text = $State.Defaults.generalDefaults.ShareName
|
||||
$State.Controls.txtUsername.Text = $State.Defaults.generalDefaults.Username
|
||||
$State.Controls.txtThreads.Text = $State.Defaults.generalDefaults.Threads
|
||||
$State.Controls.cmbBitsPriority.SelectedItem = $State.Defaults.generalDefaults.BitsPriority
|
||||
$State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives
|
||||
$State.Controls.chkBuildUSBDriveEnable.IsChecked = $State.Defaults.generalDefaults.BuildUSBDriveEnable
|
||||
$State.Controls.chkCompactOS.IsChecked = $State.Defaults.generalDefaults.CompactOS
|
||||
$State.Controls.chkUpdateADK.IsChecked = $State.Defaults.generalDefaults.UpdateADK
|
||||
$State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize
|
||||
$State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching
|
||||
$State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend
|
||||
$State.Controls.chkCreateCaptureMedia.IsChecked = $State.Defaults.generalDefaults.CreateCaptureMedia
|
||||
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
|
||||
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
|
||||
$State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia
|
||||
$State.Controls.chkSelectSpecificUSBDrives.IsChecked = $State.Defaults.generalDefaults.SelectSpecificUSBDrives
|
||||
$State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot
|
||||
$State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend
|
||||
$State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG
|
||||
$State.Controls.chkCleanupAppsISO.IsChecked = $State.Defaults.generalDefaults.CleanupAppsISO
|
||||
$State.Controls.chkCleanupCaptureISO.IsChecked = $State.Defaults.generalDefaults.CleanupCaptureISO
|
||||
$State.Controls.chkCleanupDeployISO.IsChecked = $State.Defaults.generalDefaults.CleanupDeployISO
|
||||
$State.Controls.chkCleanupDrivers.IsChecked = $State.Defaults.generalDefaults.CleanupDrivers
|
||||
$State.Controls.chkRemoveFFU.IsChecked = $State.Defaults.generalDefaults.RemoveFFU
|
||||
$State.Controls.chkRemoveApps.IsChecked = $State.Defaults.generalDefaults.RemoveApps
|
||||
$State.Controls.chkRemoveUpdates.IsChecked = $State.Defaults.generalDefaults.RemoveUpdates
|
||||
$State.Controls.chkVerbose.IsChecked = $State.Defaults.generalDefaults.Verbose
|
||||
$State.Controls.usbSection.Visibility = if ($State.Controls.chkBuildUSBDriveEnable.IsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
$State.Controls.usbSelectionPanel.Visibility = if ($State.Controls.chkSelectSpecificUSBDrives.IsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
$State.Controls.chkSelectSpecificUSBDrives.IsEnabled = $State.Controls.chkBuildUSBDriveEnable.IsChecked
|
||||
$State.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $State.Controls.chkAllowExternalHardDiskMedia.IsChecked
|
||||
$State.Controls.chkCopyAdditionalFFUFiles.IsChecked = $State.Defaults.generalDefaults.CopyAdditionalFFUFiles
|
||||
$State.Controls.additionalFFUPanel.Visibility = if ($State.Controls.chkCopyAdditionalFFUFiles.IsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
Update-BitsPrioritySetting -State $State
|
||||
|
||||
# Hyper-V Settings defaults from General Defaults
|
||||
Initialize-VMSwitchData -State $State
|
||||
$State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB
|
||||
$State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB
|
||||
$State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors
|
||||
$State.Controls.txtVMLocation.Text = $State.Defaults.generalDefaults.VMLocation
|
||||
$State.Controls.txtVMNamePrefix.Text = $State.Defaults.generalDefaults.VMNamePrefix
|
||||
$State.Controls.cmbLogicalSectorSize.SelectedItem = ($State.Controls.cmbLogicalSectorSize.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.LogicalSectorSize.ToString() })
|
||||
|
||||
# Populate Windows Release, Version, and SKU comboboxes
|
||||
Get-WindowsSettingsCombos -isoPath $State.Defaults.windowsSettingsDefaults.DefaultISOPath -State $State
|
||||
|
||||
# Windows Settings tab defaults
|
||||
$State.Controls.cmbWindowsLang.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedLanguages
|
||||
$State.Controls.cmbWindowsLang.SelectedItem = $State.Defaults.windowsSettingsDefaults.DefaultWindowsLang
|
||||
$State.Controls.cmbMediaType.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedMediaTypes
|
||||
$State.Controls.cmbMediaType.SelectedItem = $State.Defaults.windowsSettingsDefaults.DefaultMediaType
|
||||
$State.Controls.txtProductKey.Text = $State.Defaults.windowsSettingsDefaults.DefaultProductKey
|
||||
|
||||
# Updates tab defaults from General Defaults
|
||||
$State.Controls.chkUpdateLatestCU.IsChecked = $State.Defaults.generalDefaults.UpdateLatestCU
|
||||
$State.Controls.chkUpdateLatestNet.IsChecked = $State.Defaults.generalDefaults.UpdateLatestNet
|
||||
$State.Controls.chkUpdateLatestDefender.IsChecked = $State.Defaults.generalDefaults.UpdateLatestDefender
|
||||
$State.Controls.chkUpdateEdge.IsChecked = $State.Defaults.generalDefaults.UpdateEdge
|
||||
$State.Controls.chkUpdateOneDrive.IsChecked = $State.Defaults.generalDefaults.UpdateOneDrive
|
||||
$State.Controls.chkUpdateLatestMSRT.IsChecked = $State.Defaults.generalDefaults.UpdateLatestMSRT
|
||||
$State.Controls.chkUpdateLatestMicrocode.IsChecked = $State.Defaults.generalDefaults.UpdateLatestMicrocode
|
||||
$State.Controls.chkUpdatePreviewCU.IsChecked = $State.Defaults.generalDefaults.UpdatePreviewCU
|
||||
# Set initial state for CU checkbox interplay
|
||||
$State.Controls.chkPreviewCU.IsEnabled = -not $State.Controls.chkLatestCU.IsChecked
|
||||
$State.Controls.chkLatestCU.IsEnabled = -not $State.Controls.chkPreviewCU.IsChecked
|
||||
|
||||
# Applications tab defaults from General Defaults
|
||||
$State.Controls.chkInstallApps.IsChecked = $State.Defaults.generalDefaults.InstallApps
|
||||
$State.Controls.txtApplicationPath.Text = $State.Defaults.generalDefaults.ApplicationPath
|
||||
$State.Controls.txtAppListJsonPath.Text = $State.Defaults.generalDefaults.AppListJsonPath
|
||||
$State.Controls.chkInstallWingetApps.IsChecked = $State.Defaults.generalDefaults.InstallWingetApps
|
||||
$State.Controls.chkBringYourOwnApps.IsChecked = $State.Defaults.generalDefaults.BringYourOwnApps
|
||||
|
||||
# M365 Apps/Office tab defaults from General Defaults
|
||||
$State.Controls.chkInstallOffice.IsChecked = $State.Defaults.generalDefaults.InstallOffice
|
||||
$State.Controls.txtOfficePath.Text = $State.Defaults.generalDefaults.OfficePath
|
||||
$State.Controls.chkCopyOfficeConfigXML.IsChecked = $State.Defaults.generalDefaults.CopyOfficeConfigXML
|
||||
$State.Controls.txtOfficeConfigXMLFilePath.Text = $State.Defaults.generalDefaults.OfficeConfigXMLFilePath
|
||||
|
||||
# Drivers tab defaults from General Defaults
|
||||
$State.Controls.txtDriversFolder.Text = $State.Defaults.generalDefaults.DriversFolder
|
||||
$State.Controls.txtPEDriversFolder.Text = $State.Defaults.generalDefaults.PEDriversFolder
|
||||
$State.Controls.txtDriversJsonPath.Text = $State.Defaults.generalDefaults.DriversJsonPath
|
||||
$State.Controls.chkDownloadDrivers.IsChecked = $State.Defaults.generalDefaults.DownloadDrivers
|
||||
$State.Controls.chkInstallDrivers.IsChecked = $State.Defaults.generalDefaults.InstallDrivers
|
||||
$State.Controls.chkCopyDrivers.IsChecked = $State.Defaults.generalDefaults.CopyDrivers
|
||||
$State.Controls.chkCopyPEDrivers.IsChecked = $State.Defaults.generalDefaults.CopyPEDrivers
|
||||
$State.Controls.chkUseDriversAsPEDrivers.IsChecked = $State.Defaults.generalDefaults.UseDriversAsPEDrivers
|
||||
$State.Controls.chkCompressDriversToWIM.IsChecked = $State.Defaults.generalDefaults.CompressDownloadedDriversToWim
|
||||
|
||||
# Drivers tab UI logic
|
||||
$makeList = @('Microsoft', 'Dell', 'HP', 'Lenovo')
|
||||
if ($null -ne $State.Controls.cmbMake) {
|
||||
# Clear existing items to prevent duplication on re-initialization (e.g., after Restore Defaults)
|
||||
$State.Controls.cmbMake.Items.Clear()
|
||||
foreach ($m in $makeList) {
|
||||
[void]$State.Controls.cmbMake.Items.Add($m)
|
||||
}
|
||||
if ($State.Controls.cmbMake.Items.Count -gt 0) {
|
||||
$State.Controls.cmbMake.SelectedIndex = 0
|
||||
}
|
||||
}
|
||||
Update-DriverDownloadPanelVisibility -State $State
|
||||
|
||||
# Set initial state for driver checkbox interplay
|
||||
Update-DriverCheckboxStates -State $State
|
||||
|
||||
# Set initial state for InstallApps checkbox based on updates
|
||||
Update-InstallAppsState -State $State
|
||||
|
||||
# Set initial state for Office panel visibility
|
||||
Update-OfficePanelVisibility -State $State
|
||||
|
||||
# Set initial state for Application panel visibility
|
||||
Update-ApplicationPanelVisibility -State $State
|
||||
|
||||
# Set initial state for BYO Apps copy button
|
||||
Update-CopyButtonState -State $State
|
||||
}
|
||||
|
||||
function Initialize-DynamicUIElements {
|
||||
param([PSCustomObject]$State)
|
||||
WriteLog "Initializing dynamic UI elements (Grids, Columns)..."
|
||||
|
||||
# Driver Models ListView setup
|
||||
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||
$itemStyleDriverModels = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||
$itemStyleDriverModels.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||
$State.Controls.lstDriverModels.ItemContainerStyle = $itemStyleDriverModels
|
||||
|
||||
$driverModelsGridView = New-Object System.Windows.Controls.GridView
|
||||
$State.Controls.lstDriverModels.View = $driverModelsGridView # Assign GridView to ListView first
|
||||
|
||||
# Add the selectable column using the new function
|
||||
Add-SelectableGridViewColumn -ListView $State.Controls.lstDriverModels -State $State -HeaderCheckBoxKeyName "chkSelectAllDriverModels" -ColumnWidth 70
|
||||
|
||||
# Add other sortable columns with left-aligned headers
|
||||
Add-SortableColumn -gridView $driverModelsGridView -header "Make" -binding "Make" -width 100 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $driverModelsGridView -header "Model" -binding "Model" -width 200 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $driverModelsGridView -header "Status" -binding "DownloadStatus" -width 150 -headerHorizontalAlignment Left
|
||||
$State.Controls.lstDriverModels.AddHandler(
|
||||
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
|
||||
[System.Windows.RoutedEventHandler] {
|
||||
param($eventSource, $e) # $eventSource is the ListView control
|
||||
$header = $e.OriginalSource
|
||||
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
|
||||
# Retrieve the main UI state object from the window's Tag property
|
||||
$listViewControl = $eventSource
|
||||
$window = [System.Windows.Window]::GetWindow($listViewControl)
|
||||
$uiStateFromWindowTag = $window.Tag
|
||||
|
||||
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Winget Search ListView setup
|
||||
$wingetGridView = New-Object System.Windows.Controls.GridView
|
||||
$State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first
|
||||
|
||||
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||
$itemStyleWingetResults = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||
$itemStyleWingetResults.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||
$State.Controls.lstWingetResults.ItemContainerStyle = $itemStyleWingetResults
|
||||
|
||||
# Add the selectable column using the new function
|
||||
Add-SelectableGridViewColumn -ListView $State.Controls.lstWingetResults -State $State -HeaderCheckBoxKeyName "chkSelectAllWingetResults" -ColumnWidth 60
|
||||
|
||||
# Add other sortable columns with left-aligned headers
|
||||
Add-SortableColumn -gridView $wingetGridView -header "Name" -binding "Name" -width 200 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $wingetGridView -header "Id" -binding "Id" -width 200 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $wingetGridView -header "Version" -binding "Version" -width 100 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $wingetGridView -header "Source" -binding "Source" -width 100 -headerHorizontalAlignment Left
|
||||
|
||||
# --- START: Add Architecture Column ---
|
||||
$archColumn = New-Object System.Windows.Controls.GridViewColumn
|
||||
$archHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$archHeader.Tag = "Architecture" # For sorting
|
||||
$archHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
|
||||
# Create header content with correct padding to match other columns
|
||||
$commonPaddingForHeader = New-Object System.Windows.Thickness(5, 2, 5, 2)
|
||||
$headerTextElementFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
|
||||
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Architecture")
|
||||
$headerTextBlockPadding = New-Object System.Windows.Thickness($commonPaddingForHeader.Left, $commonPaddingForHeader.Top, $commonPaddingForHeader.Right, $commonPaddingForHeader.Bottom)
|
||||
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $headerTextBlockPadding)
|
||||
$headerTextElementFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||
|
||||
$headerDataTemplate = New-Object System.Windows.DataTemplate
|
||||
$headerDataTemplate.VisualTree = $headerTextElementFactory
|
||||
$archHeader.ContentTemplate = $headerDataTemplate
|
||||
|
||||
$archColumn.Header = $archHeader
|
||||
$archColumn.Width = 120
|
||||
|
||||
# Create the CellTemplate with a ComboBox
|
||||
$archCellTemplate = New-Object System.Windows.DataTemplate
|
||||
$comboBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.ComboBox])
|
||||
|
||||
# The ItemsSource for the ComboBox
|
||||
$availableArchitectures = @('x86', 'x64', 'arm64', 'x86 x64', 'NA')
|
||||
$comboBoxFactory.SetValue([System.Windows.Controls.ItemsControl]::ItemsSourceProperty, $availableArchitectures)
|
||||
|
||||
# Bind the text property to the 'Architecture' property of the data item.
|
||||
# This ensures the initial value is displayed correctly.
|
||||
$binding = New-Object System.Windows.Data.Binding("Architecture")
|
||||
$binding.Mode = [System.Windows.Data.BindingMode]::TwoWay
|
||||
$comboBoxFactory.SetBinding([System.Windows.Controls.ComboBox]::TextProperty, $binding)
|
||||
|
||||
# Create a style to disable the ComboBox for 'msstore' source
|
||||
$comboBoxStyle = New-Object System.Windows.Style
|
||||
$comboBoxStyle.TargetType = [System.Windows.Controls.ComboBox]
|
||||
|
||||
$dataTrigger = New-Object System.Windows.DataTrigger
|
||||
$dataTrigger.Binding = New-Object System.Windows.Data.Binding("Source")
|
||||
$dataTrigger.Value = "msstore"
|
||||
$dataTrigger.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ComboBox]::IsEnabledProperty, $false)))
|
||||
|
||||
$comboBoxStyle.Triggers.Add($dataTrigger)
|
||||
$comboBoxFactory.SetValue([System.Windows.FrameworkElement]::StyleProperty, $comboBoxStyle)
|
||||
|
||||
$archCellTemplate.VisualTree = $comboBoxFactory
|
||||
$archColumn.CellTemplate = $archCellTemplate
|
||||
$wingetGridView.Columns.Add($archColumn)
|
||||
# --- END: Add Architecture Column ---
|
||||
|
||||
# --- START: Add Additional Exit Codes Column ---
|
||||
$exitCodesColumn = New-Object System.Windows.Controls.GridViewColumn
|
||||
$exitCodesHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$exitCodesHeader.Tag = "AdditionalExitCodes"
|
||||
$exitCodesHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
|
||||
$exitHeaderTextFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
|
||||
$exitHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Additional Exit Codes")
|
||||
$exitHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, (New-Object System.Windows.Thickness(5, 2, 5, 2)))
|
||||
$exitHeaderTextFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||
|
||||
$exitHeaderTemplate = New-Object System.Windows.DataTemplate
|
||||
$exitHeaderTemplate.VisualTree = $exitHeaderTextFactory
|
||||
$exitCodesHeader.ContentTemplate = $exitHeaderTemplate
|
||||
|
||||
$exitCodesColumn.Header = $exitCodesHeader
|
||||
$exitCodesColumn.Width = 140
|
||||
|
||||
$exitCodesCellTemplate = New-Object System.Windows.DataTemplate
|
||||
$exitCodesTextBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBox])
|
||||
$exitBinding = New-Object System.Windows.Data.Binding("AdditionalExitCodes")
|
||||
$exitBinding.Mode = [System.Windows.Data.BindingMode]::TwoWay
|
||||
$exitCodesTextBoxFactory.SetBinding([System.Windows.Controls.TextBox]::TextProperty, $exitBinding)
|
||||
$exitCodesCellTemplate.VisualTree = $exitCodesTextBoxFactory
|
||||
$exitCodesColumn.CellTemplate = $exitCodesCellTemplate
|
||||
$wingetGridView.Columns.Add($exitCodesColumn)
|
||||
# --- END: Add Additional Exit Codes Column ---
|
||||
|
||||
# --- START: Add Ignore Non-Zero Exit Codes Column ---
|
||||
$ignoreColumn = New-Object System.Windows.Controls.GridViewColumn
|
||||
$ignoreHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$ignoreHeader.Tag = "IgnoreNonZeroExitCodes"
|
||||
$ignoreHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
|
||||
$ignoreHeaderTextFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
|
||||
$ignoreHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Ignore Exit Codes")
|
||||
$ignoreHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, (New-Object System.Windows.Thickness(5, 2, 5, 2)))
|
||||
$ignoreHeaderTextFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||
|
||||
$ignoreHeaderTemplate = New-Object System.Windows.DataTemplate
|
||||
$ignoreHeaderTemplate.VisualTree = $ignoreHeaderTextFactory
|
||||
$ignoreHeader.ContentTemplate = $ignoreHeaderTemplate
|
||||
|
||||
$ignoreColumn.Header = $ignoreHeader
|
||||
$ignoreColumn.Width = 140
|
||||
|
||||
$ignoreCellTemplate = New-Object System.Windows.DataTemplate
|
||||
|
||||
# Center the checkbox in the cell
|
||||
$ignoreCellGridFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Grid])
|
||||
$ignoreCellGridFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)
|
||||
$ignoreCellGridFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Stretch)
|
||||
|
||||
$ignoreCheckFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox])
|
||||
$ignoreCheckFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
|
||||
$ignoreCheckFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||
|
||||
$ignoreBinding = New-Object System.Windows.Data.Binding("IgnoreNonZeroExitCodes")
|
||||
$ignoreBinding.Mode = [System.Windows.Data.BindingMode]::TwoWay
|
||||
$ignoreCheckFactory.SetBinding([System.Windows.Controls.Primitives.ToggleButton]::IsCheckedProperty, $ignoreBinding)
|
||||
|
||||
# Build the visual tree: Grid -> CheckBox
|
||||
$ignoreCellGridFactory.AppendChild($ignoreCheckFactory)
|
||||
$ignoreCellTemplate.VisualTree = $ignoreCellGridFactory
|
||||
|
||||
$ignoreColumn.CellTemplate = $ignoreCellTemplate
|
||||
$wingetGridView.Columns.Add($ignoreColumn)
|
||||
# --- END: Add Ignore Non-Zero Exit Codes Column ---
|
||||
|
||||
Add-SortableColumn -gridView $wingetGridView -header "Download Status" -binding "DownloadStatus" -width 150 -headerHorizontalAlignment Left
|
||||
$State.Controls.lstWingetResults.AddHandler(
|
||||
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
|
||||
[System.Windows.RoutedEventHandler] {
|
||||
param($eventSource, $e) # $eventSource is the ListView control
|
||||
$header = $e.OriginalSource
|
||||
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
|
||||
# Retrieve the main UI state object from the window's Tag property
|
||||
$listViewControl = $eventSource
|
||||
$window = [System.Windows.Window]::GetWindow($listViewControl)
|
||||
$uiStateFromWindowTag = $window.Tag
|
||||
|
||||
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# BYO Applications ListView setup
|
||||
$byoAppsGridView = New-Object System.Windows.Controls.GridView
|
||||
$State.Controls.lstApplications.View = $byoAppsGridView
|
||||
|
||||
# Set ListViewItem style to stretch content horizontally
|
||||
$itemStyleBYOApps = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||
$itemStyleBYOApps.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||
$State.Controls.lstApplications.ItemContainerStyle = $itemStyleBYOApps
|
||||
|
||||
# Add the selectable column
|
||||
Add-SelectableGridViewColumn -ListView $State.Controls.lstApplications -State $State -HeaderCheckBoxKeyName "chkSelectAllBYOApps" -ColumnWidth 60
|
||||
|
||||
# Add other sortable columns
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Priority" -binding "Priority" -width 60 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Name" -binding "Name" -width 150 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Command Line" -binding "CommandLine" -width 200 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Arguments" -binding "Arguments" -width 200 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Source" -binding "Source" -width 150 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Exit Codes" -binding "AdditionalExitCodes" -width 100 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left
|
||||
|
||||
# Apps Script Variables ListView setup
|
||||
# Bind ItemsSource to the data list
|
||||
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
|
||||
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||
$itemStyleAppsScriptVars = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||
$itemStyleAppsScriptVars.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||
$State.Controls.lstAppsScriptVariables.ItemContainerStyle = $itemStyleAppsScriptVars
|
||||
|
||||
# The GridView for lstAppsScriptVariables is defined in XAML. We need to get it and add the column.
|
||||
if ($State.Controls.lstAppsScriptVariables.View -is [System.Windows.Controls.GridView]) {
|
||||
Add-SelectableGridViewColumn -ListView $State.Controls.lstAppsScriptVariables -State $State -HeaderCheckBoxKeyName "chkSelectAllAppsScriptVariables" -ColumnWidth 60
|
||||
|
||||
# Make Key and Value columns sortable
|
||||
$appsScriptVarsGridView = $State.Controls.lstAppsScriptVariables.View
|
||||
|
||||
# Key Column (should be at index 1 after selectable column is inserted at 0)
|
||||
if ($appsScriptVarsGridView.Columns.Count -gt 1) {
|
||||
$keyColumn = $appsScriptVarsGridView.Columns[1]
|
||||
$keyHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$keyHeader.Content = "Key"
|
||||
$keyHeader.Tag = "Key" # Property to sort by
|
||||
$keyHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
$keyColumn.Header = $keyHeader
|
||||
}
|
||||
|
||||
# Value Column (should be at index 2)
|
||||
if ($appsScriptVarsGridView.Columns.Count -gt 2) {
|
||||
$valueColumn = $appsScriptVarsGridView.Columns[2]
|
||||
$valueHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$valueHeader.Content = "Value"
|
||||
$valueHeader.Tag = "Value" # Property to sort by
|
||||
$valueHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
$valueColumn.Header = $valueHeader
|
||||
}
|
||||
|
||||
# Add Click event handler for sorting
|
||||
$State.Controls.lstAppsScriptVariables.AddHandler(
|
||||
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
|
||||
[System.Windows.RoutedEventHandler] {
|
||||
param($eventSource, $e) # $eventSource is the ListView control
|
||||
$header = $e.OriginalSource
|
||||
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
|
||||
# Retrieve the main UI state object from the window's Tag property
|
||||
$listViewControl = $eventSource
|
||||
$window = [System.Windows.Window]::GetWindow($listViewControl)
|
||||
$uiStateFromWindowTag = $window.Tag
|
||||
|
||||
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
|
||||
}
|
||||
|
||||
# Build dynamic multi-column checkboxes for optional features
|
||||
if ($State.Controls.featuresPanel -and $State.Defaults.windowsSettingsDefaults) {
|
||||
BuildFeaturesGrid -parent $State.Controls.featuresPanel -allowedFeatures $State.Defaults.windowsSettingsDefaults.AllowedFeatures -State $State
|
||||
}
|
||||
else {
|
||||
WriteLog "Initialize-DynamicUIElements: Could not build features grid. Panel or defaults missing."
|
||||
}
|
||||
|
||||
# USB Drives ListView setup
|
||||
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||
$itemStyleUSBDrives = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||
$itemStyleUSBDrives.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||
$State.Controls.lstUSBDrives.ItemContainerStyle = $itemStyleUSBDrives
|
||||
|
||||
if ($State.Controls.lstUSBDrives.View -is [System.Windows.Controls.GridView]) {
|
||||
# Add the selectable column using the shared function
|
||||
Add-SelectableGridViewColumn -ListView $State.Controls.lstUSBDrives -State $State -HeaderCheckBoxKeyName "chkSelectAllUSBDrivesHeader" -ColumnWidth 70
|
||||
|
||||
# Make other columns sortable
|
||||
$usbDrivesGridView = $State.Controls.lstUSBDrives.View
|
||||
|
||||
# Model Column (index 0 in XAML, now 1)
|
||||
if ($usbDrivesGridView.Columns.Count -gt 1) {
|
||||
$modelColumn = $usbDrivesGridView.Columns[1]
|
||||
$modelHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$modelHeader.Content = "Model"
|
||||
$modelHeader.Tag = "Model" # Property to sort by
|
||||
$modelHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
$modelColumn.Header = $modelHeader
|
||||
}
|
||||
|
||||
# Unique ID Column (index 1 in XAML, now 2)
|
||||
if ($usbDrivesGridView.Columns.Count -gt 2) {
|
||||
$uniqueIdColumn = $usbDrivesGridView.Columns[2]
|
||||
$uniqueIdHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$uniqueIdHeader.Content = "Unique ID"
|
||||
$uniqueIdHeader.Tag = "UniqueId" # Property to sort by
|
||||
$uniqueIdHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
$uniqueIdColumn.Header = $uniqueIdHeader
|
||||
}
|
||||
|
||||
# Size Column (index 2 in XAML, now 3)
|
||||
if ($usbDrivesGridView.Columns.Count -gt 3) {
|
||||
$sizeColumn = $usbDrivesGridView.Columns[3]
|
||||
$sizeHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$sizeHeader.Content = "Size (GB)"
|
||||
$sizeHeader.Tag = "Size" # Property to sort by
|
||||
$sizeHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
$sizeColumn.Header = $sizeHeader
|
||||
}
|
||||
|
||||
# Add Click event handler for sorting
|
||||
$State.Controls.lstUSBDrives.AddHandler(
|
||||
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
|
||||
[System.Windows.RoutedEventHandler] {
|
||||
param($eventSource, $e) # $eventSource is the ListView control
|
||||
$header = $e.OriginalSource
|
||||
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
|
||||
# Retrieve the main UI state object from the window's Tag property
|
||||
$listViewControl = $eventSource
|
||||
$window = [System.Windows.Window]::GetWindow($listViewControl)
|
||||
$uiStateFromWindowTag = $window.Tag
|
||||
|
||||
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
|
||||
}
|
||||
|
||||
# Additional FFUs ListView setup
|
||||
$itemStyleAdditionalFFUs = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||
$itemStyleAdditionalFFUs.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||
$State.Controls.lstAdditionalFFUs.ItemContainerStyle = $itemStyleAdditionalFFUs
|
||||
|
||||
if ($State.Controls.lstAdditionalFFUs.View -is [System.Windows.Controls.GridView]) {
|
||||
Add-SelectableGridViewColumn -ListView $State.Controls.lstAdditionalFFUs -State $State -HeaderCheckBoxKeyName "chkSelectAllAdditionalFFUs" -ColumnWidth 70
|
||||
|
||||
$additionalFFUsGridView = $State.Controls.lstAdditionalFFUs.View
|
||||
|
||||
if ($additionalFFUsGridView.Columns.Count -gt 1) {
|
||||
$nameColumn = $additionalFFUsGridView.Columns[1]
|
||||
$nameHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$nameHeader.Content = "FFU Name"
|
||||
$nameHeader.Tag = "Name"
|
||||
$nameHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
$nameColumn.Header = $nameHeader
|
||||
}
|
||||
if ($additionalFFUsGridView.Columns.Count -gt 2) {
|
||||
$lastModColumn = $additionalFFUsGridView.Columns[2]
|
||||
$lastModHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$lastModHeader.Content = "Last Modified"
|
||||
$lastModHeader.Tag = "LastModified"
|
||||
$lastModHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
$lastModColumn.Header = $lastModHeader
|
||||
}
|
||||
|
||||
$State.Controls.lstAdditionalFFUs.AddHandler(
|
||||
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
|
||||
[System.Windows.RoutedEventHandler] {
|
||||
param($eventSource, $e)
|
||||
$header = $e.OriginalSource
|
||||
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
|
||||
$listViewControl = $eventSource
|
||||
$window = [System.Windows.Window]::GetWindow($listViewControl)
|
||||
$uiStateFromWindowTag = $window.Tag
|
||||
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -0,0 +1,999 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Provides a collection of shared helper functions for manipulating WPF UI controls, handling asynchronous updates, and managing common UI interactions.
|
||||
.DESCRIPTION
|
||||
This module contains a variety of reusable functions designed to support the FFU Builder UI. It includes utilities for managing ListView controls, such as sorting, reordering items, and handling 'Select All' functionality. It also provides thread-safe mechanisms for updating the UI from background tasks, wrappers for modern and classic file/folder dialogs, and generic functions for clearing UI content. These shared functions help to reduce code duplication and ensure consistent behavior across different parts of the application.
|
||||
#>
|
||||
|
||||
# Function to update priorities sequentially in a ListView
|
||||
function Update-ListViewPriorities {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView
|
||||
)
|
||||
|
||||
$currentPriority = 1
|
||||
foreach ($item in $ListView.Items) {
|
||||
if ($null -ne $item -and $item.PSObject.Properties['Priority']) {
|
||||
$item.Priority = $currentPriority
|
||||
$currentPriority++
|
||||
}
|
||||
}
|
||||
$ListView.Items.Refresh()
|
||||
}
|
||||
|
||||
# Function to move selected item to the top
|
||||
function Move-ListViewItemTop {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView
|
||||
)
|
||||
|
||||
$selectedItem = $ListView.SelectedItem
|
||||
if ($null -eq $selectedItem) { return }
|
||||
|
||||
$currentIndex = $ListView.Items.IndexOf($selectedItem)
|
||||
if ($currentIndex -gt 0) {
|
||||
$ListView.Items.RemoveAt($currentIndex)
|
||||
$ListView.Items.Insert(0, $selectedItem)
|
||||
$ListView.SelectedItem = $selectedItem
|
||||
Update-ListViewPriorities -ListView $ListView
|
||||
}
|
||||
}
|
||||
|
||||
# Function to move selected item up one position
|
||||
function Move-ListViewItemUp {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView
|
||||
)
|
||||
|
||||
$selectedItem = $ListView.SelectedItem
|
||||
if ($null -eq $selectedItem) { return }
|
||||
|
||||
$currentIndex = $ListView.Items.IndexOf($selectedItem)
|
||||
if ($currentIndex -gt 0) {
|
||||
$ListView.Items.RemoveAt($currentIndex)
|
||||
$ListView.Items.Insert($currentIndex - 1, $selectedItem)
|
||||
$ListView.SelectedItem = $selectedItem
|
||||
Update-ListViewPriorities -ListView $ListView
|
||||
}
|
||||
}
|
||||
|
||||
# Function to move selected item down one position
|
||||
function Move-ListViewItemDown {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView
|
||||
)
|
||||
|
||||
$selectedItem = $ListView.SelectedItem
|
||||
if ($null -eq $selectedItem) { return }
|
||||
|
||||
$currentIndex = $ListView.Items.IndexOf($selectedItem)
|
||||
if ($currentIndex -lt ($ListView.Items.Count - 1)) {
|
||||
$ListView.Items.RemoveAt($currentIndex)
|
||||
$ListView.Items.Insert($currentIndex + 1, $selectedItem)
|
||||
$ListView.SelectedItem = $selectedItem
|
||||
Update-ListViewPriorities -ListView $ListView
|
||||
}
|
||||
}
|
||||
|
||||
# Function to move selected item to the bottom
|
||||
function Move-ListViewItemBottom {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView
|
||||
)
|
||||
|
||||
$selectedItem = $ListView.SelectedItem
|
||||
if ($null -eq $selectedItem) { return }
|
||||
|
||||
$currentIndex = $ListView.Items.IndexOf($selectedItem)
|
||||
if ($currentIndex -lt ($ListView.Items.Count - 1)) {
|
||||
$ListView.Items.RemoveAt($currentIndex)
|
||||
$ListView.Items.Add($selectedItem)
|
||||
$ListView.SelectedItem = $selectedItem
|
||||
Update-ListViewPriorities -ListView $ListView
|
||||
}
|
||||
}
|
||||
|
||||
# Function to update status of a specific item in a ListView
|
||||
function Update-ListViewItemStatus {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object]$WindowObject,
|
||||
[Parameter(Mandatory)]
|
||||
[object]$ListView,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$IdentifierProperty,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$IdentifierValue,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$StatusProperty,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$StatusValue
|
||||
)
|
||||
|
||||
# Ensure we are in UI mode and objects are of correct WPF types
|
||||
if ($WindowObject -is [System.Windows.Window] -and $ListView -is [System.Windows.Controls.ListView]) {
|
||||
# Directly update UI elements as this function is now called on the UI thread
|
||||
try {
|
||||
# Determine which collection to search: ItemsSource (preferred) or Items.
|
||||
$collectionToSearch = $null
|
||||
if ($null -ne $ListView.ItemsSource) {
|
||||
$collectionToSearch = $ListView.ItemsSource
|
||||
}
|
||||
else {
|
||||
$collectionToSearch = $ListView.Items
|
||||
}
|
||||
|
||||
$itemToUpdate = $collectionToSearch | Where-Object { $_.$IdentifierProperty -eq $IdentifierValue } | Select-Object -First 1
|
||||
if ($null -ne $itemToUpdate) {
|
||||
$itemToUpdate.$StatusProperty = $StatusValue
|
||||
$ListView.Items.Refresh() # Refresh the view to show the change
|
||||
}
|
||||
else {
|
||||
# Log if item not found (for debugging)
|
||||
WriteLog "Update-ListViewItemStatus: Item with $IdentifierProperty '$IdentifierValue' not found in ListView."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Update-ListViewItemStatus: Error updating ListView: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Log if called in non-UI mode or with incorrect types (should not happen if Invoke-ParallelProcessing $isUiMode is correct)
|
||||
WriteLog "Update-ListViewItemStatus: Skipped UI update for $IdentifierValue due to non-UI mode or incorrect object types."
|
||||
}
|
||||
}
|
||||
|
||||
# Function to update overall progress bar and status text label
|
||||
function Update-OverallProgress {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object]$WindowObject,
|
||||
[Parameter(Mandatory)]
|
||||
[int]$CompletedCount,
|
||||
[Parameter(Mandatory)]
|
||||
[int]$TotalCount,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$StatusText,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$ProgressBarName,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$StatusLabelName
|
||||
)
|
||||
|
||||
# Ensure we are in UI mode and WindowObject is of correct WPF type
|
||||
if ($WindowObject -is [System.Windows.Window]) {
|
||||
# Directly update UI elements as this function is now called on the UI thread
|
||||
try {
|
||||
# Find controls by name using the $WindowObject
|
||||
$pb = $WindowObject.FindName($ProgressBarName)
|
||||
$lbl = $WindowObject.FindName($StatusLabelName)
|
||||
|
||||
if ($null -eq $pb) {
|
||||
WriteLog "Update-OverallProgress: ProgressBar '$ProgressBarName' not found."
|
||||
return
|
||||
}
|
||||
if ($null -eq $lbl) {
|
||||
WriteLog "Update-OverallProgress: StatusLabel '$StatusLabelName' not found."
|
||||
return
|
||||
}
|
||||
|
||||
# Update the progress bar
|
||||
if ($TotalCount -gt 0) {
|
||||
$percentComplete = ($CompletedCount / $TotalCount) * 100
|
||||
$pb.Value = $percentComplete
|
||||
}
|
||||
else {
|
||||
$pb.Value = 0
|
||||
}
|
||||
|
||||
# Update the status label
|
||||
$lbl.Text = $StatusText
|
||||
|
||||
}
|
||||
catch {
|
||||
WriteLog "Update-OverallProgress: Error updating progress: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Log if called in non-UI mode or with incorrect types
|
||||
WriteLog "Update-OverallProgress: Skipped UI update ($StatusText) due to non-UI mode or incorrect WindowObject type."
|
||||
}
|
||||
}
|
||||
|
||||
# Helper function to enqueue progress updates to the UI thread
|
||||
function Invoke-ProgressUpdate {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Identifier,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Status
|
||||
)
|
||||
$ProgressQueue.Enqueue(@{ Identifier = $Identifier; Status = $Status })
|
||||
}
|
||||
|
||||
function Update-BitsPrioritySetting {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[pscustomobject]$State
|
||||
)
|
||||
|
||||
$combo = $State.Controls.cmbBitsPriority
|
||||
if ($null -eq $combo) {
|
||||
WriteLog "BITS priority control not available; skipping priority update."
|
||||
return
|
||||
}
|
||||
|
||||
$selectedPriority = $combo.SelectedItem
|
||||
if ([string]::IsNullOrWhiteSpace($selectedPriority)) {
|
||||
$selectedPriority = 'Normal'
|
||||
}
|
||||
|
||||
try {
|
||||
Set-BitsTransferPriority -Priority $selectedPriority
|
||||
WriteLog "BITS transfer priority set to $selectedPriority."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to set BITS transfer priority: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Add a function to create a sortable list view
|
||||
function Add-SortableColumn {
|
||||
param(
|
||||
[System.Windows.Controls.GridView]$gridView,
|
||||
[string]$header,
|
||||
[string]$binding,
|
||||
[int]$width = 'Auto',
|
||||
[bool]$isCheckbox = $false,
|
||||
[System.Windows.HorizontalAlignment]$headerHorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
|
||||
)
|
||||
|
||||
$column = New-Object System.Windows.Controls.GridViewColumn
|
||||
$commonPadding = New-Object System.Windows.Thickness(5, 2, 5, 2)
|
||||
|
||||
$headerControl = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$headerControl.Tag = $binding # Used for sorting
|
||||
|
||||
if ($isCheckbox) {
|
||||
# Cell template for a column of checkboxes
|
||||
$cellTemplate = New-Object System.Windows.DataTemplate
|
||||
$gridFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Grid])
|
||||
|
||||
$checkBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox])
|
||||
$checkBoxFactory.SetBinding([System.Windows.Controls.CheckBox]::IsCheckedProperty, (New-Object System.Windows.Data.Binding("IsSelected")))
|
||||
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
|
||||
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||
|
||||
$checkBoxFactory.AddHandler([System.Windows.Controls.CheckBox]::ClickEvent, [System.Windows.RoutedEventHandler] {
|
||||
param($eventSourceLocal, $eventArgsLocal)
|
||||
})
|
||||
$gridFactory.AppendChild($checkBoxFactory)
|
||||
$cellTemplate.VisualTree = $gridFactory
|
||||
$column.CellTemplate = $cellTemplate
|
||||
}
|
||||
else {
|
||||
# For regular text columns
|
||||
$headerControl.HorizontalContentAlignment = $headerHorizontalAlignment
|
||||
$headerControl.Content = $header
|
||||
|
||||
$headerTextElementFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
|
||||
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, $header)
|
||||
$headerTextBlockPadding = New-Object System.Windows.Thickness($commonPadding.Left, $commonPadding.Top, $commonPadding.Right, $commonPadding.Bottom)
|
||||
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $headerTextBlockPadding)
|
||||
$headerTextElementFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||
|
||||
$headerDataTemplate = New-Object System.Windows.DataTemplate
|
||||
$headerDataTemplate.VisualTree = $headerTextElementFactory
|
||||
$headerControl.ContentTemplate = $headerDataTemplate
|
||||
|
||||
$cellTemplate = New-Object System.Windows.DataTemplate
|
||||
$textBlockFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
|
||||
$textBlockFactory.SetBinding([System.Windows.Controls.TextBlock]::TextProperty, (New-Object System.Windows.Data.Binding($binding)))
|
||||
# Adjust left padding to 0 for cell text to align with header text
|
||||
$cellTextBlockPadding = New-Object System.Windows.Thickness(0, $commonPadding.Top, $commonPadding.Right, $commonPadding.Bottom)
|
||||
$textBlockFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $cellTextBlockPadding)
|
||||
$textBlockFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Left)
|
||||
$textBlockFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||
|
||||
$cellTemplate.VisualTree = $textBlockFactory
|
||||
$column.CellTemplate = $cellTemplate
|
||||
}
|
||||
|
||||
$column.Header = $headerControl
|
||||
|
||||
if ($width -ne 'Auto') {
|
||||
$column.Width = $width
|
||||
}
|
||||
|
||||
$gridView.Columns.Add($column)
|
||||
}
|
||||
|
||||
# Function to add a selectable GridViewColumn with a "Select All" header CheckBox
|
||||
function Add-SelectableGridViewColumn {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView,
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$HeaderCheckBoxKeyName,
|
||||
[Parameter(Mandatory)]
|
||||
[double]$ColumnWidth,
|
||||
[string]$IsSelectedPropertyName = "IsSelected"
|
||||
)
|
||||
|
||||
# Ensure the ListView has a GridView
|
||||
if ($null -eq $ListView.View -or -not ($ListView.View -is [System.Windows.Controls.GridView])) {
|
||||
WriteLog "Add-SelectableGridViewColumn: ListView '$($ListView.Name)' does not have a GridView or View is null. Cannot add column."
|
||||
return
|
||||
}
|
||||
$gridView = $ListView.View
|
||||
|
||||
# Create the "Select All" CheckBox for the header
|
||||
$headerCheckBox = New-Object System.Windows.Controls.CheckBox
|
||||
$headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center
|
||||
|
||||
# MODIFICATION: Store the actual ListView object in the header's Tag
|
||||
$headerTagObject = [PSCustomObject]@{
|
||||
PropertyName = $IsSelectedPropertyName
|
||||
ListViewControl = $ListView
|
||||
}
|
||||
$headerCheckBox.Tag = $headerTagObject
|
||||
|
||||
$headerCheckBox.Add_Checked({
|
||||
param($senderCheckBoxLocal, $eventArgsCheckedLocal)
|
||||
$tagData = $senderCheckBoxLocal.Tag
|
||||
$localPropertyName = $tagData.PropertyName
|
||||
$actualListView = $tagData.ListViewControl
|
||||
|
||||
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items }
|
||||
if ($null -ne $collectionToUpdate) {
|
||||
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $true }
|
||||
$actualListView.Items.Refresh()
|
||||
}
|
||||
})
|
||||
|
||||
$headerCheckBox.Add_Unchecked({
|
||||
param($senderCheckBoxLocal, $eventArgsUncheckedLocal)
|
||||
if ($senderCheckBoxLocal.IsChecked -eq $false) {
|
||||
$tagData = $senderCheckBoxLocal.Tag
|
||||
$localPropertyName = $tagData.PropertyName
|
||||
$actualListView = $tagData.ListViewControl
|
||||
|
||||
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items }
|
||||
if ($null -ne $collectionToUpdate) {
|
||||
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $false }
|
||||
$actualListView.Items.Refresh()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
$State.Controls[$HeaderCheckBoxKeyName] = $headerCheckBox
|
||||
WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in State.Controls with key '$HeaderCheckBoxKeyName'."
|
||||
|
||||
$selectableColumn = New-Object System.Windows.Controls.GridViewColumn
|
||||
$selectableColumn.Header = $headerCheckBox
|
||||
$selectableColumn.Width = $ColumnWidth
|
||||
|
||||
$cellTemplate = New-Object System.Windows.DataTemplate
|
||||
$borderFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Border])
|
||||
$borderFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)
|
||||
$borderFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Stretch)
|
||||
|
||||
$checkBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox])
|
||||
$checkBoxFactory.SetBinding([System.Windows.Controls.CheckBox]::IsCheckedProperty, (New-Object System.Windows.Data.Binding($IsSelectedPropertyName)))
|
||||
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
|
||||
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||
|
||||
# MODIFICATION: Store the actual ListView object in the item checkbox's Tag
|
||||
$tagObject = [PSCustomObject]@{
|
||||
HeaderCheckboxKeyName = $HeaderCheckBoxKeyName
|
||||
ListViewControl = $ListView
|
||||
}
|
||||
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::TagProperty, $tagObject)
|
||||
|
||||
$checkBoxFactory.AddHandler([System.Windows.Controls.CheckBox]::ClickEvent, [System.Windows.RoutedEventHandler] {
|
||||
param($eventSourceLocal, $eventArgsLocal)
|
||||
$itemCheckBox = $eventSourceLocal -as [System.Windows.Controls.CheckBox]
|
||||
$tagData = $itemCheckBox.Tag
|
||||
|
||||
$headerCheckboxKeyFromTag = $tagData.HeaderCheckboxKeyName
|
||||
$targetListView = $tagData.ListViewControl
|
||||
|
||||
# Get the state from the window tag
|
||||
$window = [System.Windows.Window]::GetWindow($targetListView)
|
||||
if ($null -eq $window -or $null -eq $window.Tag) {
|
||||
WriteLog "Add-SelectableGridViewColumn: ERROR - Could not get window or state from window tag."
|
||||
return
|
||||
}
|
||||
$localState = $window.Tag
|
||||
|
||||
WriteLog "Add-SelectableGridViewColumn: Item Click. ListView: '$($targetListView.Name)', HeaderChkKey: '$headerCheckboxKeyFromTag'"
|
||||
|
||||
$headerChk = $localState.Controls[$headerCheckboxKeyFromTag]
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $targetListView -HeaderCheckBox $headerChk
|
||||
}
|
||||
else {
|
||||
WriteLog "Add-SelectableGridViewColumn: Error - Could not retrieve header checkbox from state with key '$headerCheckboxKeyFromTag'."
|
||||
}
|
||||
})
|
||||
|
||||
$borderFactory.AppendChild($checkBoxFactory)
|
||||
$cellTemplate.VisualTree = $borderFactory
|
||||
$selectableColumn.CellTemplate = $cellTemplate
|
||||
|
||||
$gridView.Columns.Insert(0, $selectableColumn)
|
||||
WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'."
|
||||
}
|
||||
|
||||
# Function to update the IsChecked state of a "Select All" header CheckBox
|
||||
function Update-SelectAllHeaderCheckBoxState {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView,
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.CheckBox]$HeaderCheckBox
|
||||
)
|
||||
|
||||
$collectionToInspect = $null
|
||||
if ($null -ne $ListView.ItemsSource) {
|
||||
$collectionToInspect = @($ListView.ItemsSource)
|
||||
}
|
||||
elseif ($ListView.HasItems) {
|
||||
# Check if Items collection has items and ItemsSource is null
|
||||
$collectionToInspect = @($ListView.Items)
|
||||
}
|
||||
|
||||
# If no items to inspect (either ItemsSource was null and Items was empty, or ItemsSource was empty)
|
||||
if ($null -eq $collectionToInspect -or $collectionToInspect.Count -eq 0) {
|
||||
$HeaderCheckBox.IsChecked = $false
|
||||
return
|
||||
}
|
||||
|
||||
$selectedCount = ($collectionToInspect | Where-Object { $_.IsSelected }).Count
|
||||
WriteLog "Update-SelectAllHeaderCheckBoxState: Selected count is $selectedCount for ListView '$($ListView.Name)'."
|
||||
$totalItemCount = $collectionToInspect.Count # Get the total count from the collection being inspected
|
||||
WriteLog "Update-SelectAllHeaderCheckBoxState: Total item count is $totalItemCount for ListView '$($ListView.Name)'."
|
||||
|
||||
if ($totalItemCount -eq 0) {
|
||||
# Handle empty list case specifically
|
||||
$HeaderCheckBox.IsChecked = $false
|
||||
}
|
||||
elseif ($selectedCount -eq $totalItemCount) {
|
||||
$HeaderCheckBox.IsChecked = $true
|
||||
}
|
||||
elseif ($selectedCount -eq 0) {
|
||||
$HeaderCheckBox.IsChecked = $false
|
||||
}
|
||||
else {
|
||||
# Indeterminate state
|
||||
$HeaderCheckBox.IsChecked = $null
|
||||
}
|
||||
}
|
||||
|
||||
# Function to toggle the IsSelected state of the currently selected ListView item
|
||||
function Invoke-ListViewItemToggle {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView,
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$HeaderCheckBoxKeyName
|
||||
)
|
||||
|
||||
$selectedItem = $ListView.SelectedItem
|
||||
if ($null -eq $selectedItem) { return }
|
||||
|
||||
# Store the current index to restore focus later
|
||||
$currentIndex = $ListView.SelectedIndex
|
||||
|
||||
# Toggle the IsSelected property
|
||||
$selectedItem.IsSelected = -not $selectedItem.IsSelected
|
||||
$ListView.Items.Refresh()
|
||||
|
||||
# Update the 'Select All' header checkbox state
|
||||
$headerChk = $State.Controls[$HeaderCheckBoxKeyName]
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $ListView -HeaderCheckBox $headerChk
|
||||
}
|
||||
|
||||
# Restore selection and focus to the item that was just toggled
|
||||
if ($currentIndex -ge 0 -and $ListView.Items.Count -gt $currentIndex) {
|
||||
$ListView.SelectedIndex = $currentIndex
|
||||
|
||||
# Ensure the UI is updated before trying to find the container
|
||||
$ListView.UpdateLayout()
|
||||
|
||||
$listViewItem = $ListView.ItemContainerGenerator.ContainerFromIndex($currentIndex)
|
||||
if ($null -ne $listViewItem) {
|
||||
$listViewItem.Focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Function to sort ListView items
|
||||
function Invoke-ListViewSort {
|
||||
param(
|
||||
[System.Windows.Controls.ListView]$listView,
|
||||
[string]$property,
|
||||
[PSCustomObject]$State
|
||||
)
|
||||
|
||||
# Preserve any active CollectionView filter so sorting does not reset a filtered driver model list
|
||||
$existingFilter = $null
|
||||
$existingCollectionView = $null
|
||||
if ($null -ne $listView.ItemsSource) {
|
||||
$existingCollectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($listView.ItemsSource)
|
||||
if ($null -ne $existingCollectionView -and $existingCollectionView.Filter) {
|
||||
$existingFilter = $existingCollectionView.Filter
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure $State.Flags is a hashtable and contains the required sort properties
|
||||
if ($State.Flags -is [hashtable]) {
|
||||
if (-not $State.Flags.ContainsKey('lastSortProperty')) {
|
||||
$State.Flags['lastSortProperty'] = $null
|
||||
}
|
||||
if (-not $State.Flags.ContainsKey('lastSortAscending')) {
|
||||
$State.Flags['lastSortAscending'] = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning "Invoke-ListViewSort: \$State.Flags is not a hashtable or is null. Sort state may not work correctly."
|
||||
if ($null -eq $State.Flags) { $State.Flags = @{} }
|
||||
if ($State.Flags -is [hashtable]) {
|
||||
if (-not $State.Flags.ContainsKey('lastSortProperty')) { $State.Flags['lastSortProperty'] = $null }
|
||||
if (-not $State.Flags.ContainsKey('lastSortAscending')) { $State.Flags['lastSortAscending'] = $true }
|
||||
}
|
||||
}
|
||||
|
||||
# Toggle sort direction if clicking the same column
|
||||
if ($State.Flags.lastSortProperty -eq $property) {
|
||||
$State.Flags.lastSortAscending = -not $State.Flags.lastSortAscending
|
||||
}
|
||||
else {
|
||||
$State.Flags.lastSortAscending = $true
|
||||
}
|
||||
$State.Flags.lastSortProperty = $property
|
||||
|
||||
# Build the set of items to sort, enumerating the filtered view if a filter is active
|
||||
$currentItemsSource = $listView.ItemsSource
|
||||
$itemsToSort = @()
|
||||
if ($null -ne $existingCollectionView -and $null -ne $existingFilter) {
|
||||
foreach ($vItem in $existingCollectionView) {
|
||||
$itemsToSort += $vItem
|
||||
}
|
||||
}
|
||||
elseif ($null -ne $currentItemsSource) {
|
||||
$itemsToSort = @($currentItemsSource)
|
||||
}
|
||||
else {
|
||||
$itemsToSort = @($listView.Items)
|
||||
}
|
||||
|
||||
if ($itemsToSort.Count -eq 0) {
|
||||
return
|
||||
}
|
||||
|
||||
# Separate selected vs unselected for selected-first ordering
|
||||
$selectedItems = @($itemsToSort | Where-Object { $_.IsSelected })
|
||||
$unselectedItems = @($itemsToSort | Where-Object { -not $_.IsSelected })
|
||||
|
||||
# Define primary sort criterion
|
||||
$primarySortDefinition = @{
|
||||
Expression = {
|
||||
$val = $_.$property
|
||||
if ($null -eq $val) { '' } else { $val }
|
||||
}
|
||||
Ascending = $State.Flags.lastSortAscending
|
||||
}
|
||||
|
||||
$sortCriteria = [System.Collections.Generic.List[hashtable]]::new()
|
||||
$sortCriteria.Add($primarySortDefinition)
|
||||
|
||||
# Determine secondary sort property based on the ListView
|
||||
$secondarySortPropertyName = $null
|
||||
if ($listView.Name -eq 'lstDriverModels') {
|
||||
$secondarySortPropertyName = "Model"
|
||||
}
|
||||
elseif ($listView.Name -eq 'lstWingetResults') {
|
||||
$secondarySortPropertyName = "Name"
|
||||
}
|
||||
elseif ($listView.Name -eq 'lstAppsScriptVariables') {
|
||||
if ($property -eq "Key") {
|
||||
$secondarySortPropertyName = "Value"
|
||||
}
|
||||
elseif ($property -eq "Value") {
|
||||
$secondarySortPropertyName = "Key"
|
||||
}
|
||||
else {
|
||||
$secondarySortPropertyName = "Key"
|
||||
}
|
||||
}
|
||||
|
||||
# Add secondary sort definition if applicable
|
||||
if ($null -ne $secondarySortPropertyName -and $property -ne $secondarySortPropertyName) {
|
||||
$itemsHaveSecondaryProperty = $false
|
||||
if ($unselectedItems.Count -gt 0) {
|
||||
if ($null -ne $unselectedItems[0].PSObject.Properties[$secondarySortPropertyName]) {
|
||||
$itemsHaveSecondaryProperty = $true
|
||||
}
|
||||
}
|
||||
elseif ($selectedItems.Count -gt 0) {
|
||||
if ($null -ne $selectedItems[0].PSObject.Properties[$secondarySortPropertyName]) {
|
||||
$itemsHaveSecondaryProperty = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemsHaveSecondaryProperty) {
|
||||
$expressionScriptBlock = [scriptblock]::Create("`$_.$secondarySortPropertyName")
|
||||
$secondarySortDefinition = @{
|
||||
Expression = {
|
||||
$val = Invoke-Command -ScriptBlock $expressionScriptBlock -ArgumentList $_
|
||||
if ($null -eq $val) { '' } else { $val }
|
||||
}
|
||||
Ascending = $true
|
||||
}
|
||||
$sortCriteria.Add($secondarySortDefinition)
|
||||
}
|
||||
}
|
||||
|
||||
# Sort unselected items by combined sort criteria
|
||||
$sortedUnselected = $unselectedItems | Sort-Object -Property $sortCriteria.ToArray()
|
||||
if ($null -eq $sortedUnselected) {
|
||||
$sortedUnselected = @()
|
||||
}
|
||||
|
||||
# Merge selected first, then sorted unselected
|
||||
$newSortedList = [System.Collections.Generic.List[object]]::new()
|
||||
$newSortedList.AddRange($selectedItems)
|
||||
$newSortedList.AddRange($sortedUnselected)
|
||||
|
||||
# Reset ItemsSource and assign sorted list
|
||||
$listView.ItemsSource = $null
|
||||
$listView.ItemsSource = $newSortedList.ToArray()
|
||||
|
||||
# Reapply preserved filter to maintain the user's filtered view
|
||||
if ($null -ne $existingFilter) {
|
||||
$newView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($listView.ItemsSource)
|
||||
if ($null -ne $newView) {
|
||||
$newView.Filter = $existingFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SECTION: Modern Folder Picker
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 1) Define a C# class that uses the correct GUIDs for IFileDialog, IFileOpenDialog, and FileOpenDialog,
|
||||
# while omitting conflicting "GetResults/GetSelectedItems" from IFileDialog.
|
||||
if (-not ("ModernFolderBrowser" -as [type])) {
|
||||
$modernFolderBrowserCode = @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class ModernFolderBrowser
|
||||
{
|
||||
// Flags for IFileDialog
|
||||
[Flags]
|
||||
private enum FileDialogOptions : uint
|
||||
|
||||
{
|
||||
OverwritePrompt = 0x00000002,
|
||||
StrictFileTypes = 0x00000004,
|
||||
NoChangeDir = 0x00000008,
|
||||
PickFolders = 0x00000020,
|
||||
ForceFileSystem = 0x00000040,
|
||||
AllNonStorageItems = 0x00000080,
|
||||
NoValidate = 0x00000100,
|
||||
AllowMultiSelect = 0x00000200,
|
||||
PathMustExist = 0x00000800,
|
||||
FileMustExist = 0x00001000,
|
||||
CreatePrompt = 0x00002000,
|
||||
ShareAware = 0x00004000,
|
||||
NoReadOnlyReturn = 0x00008000,
|
||||
NoTestFileCreate = 0x00010000,
|
||||
DontAddToRecent = 0x02000000,
|
||||
ForceShowHidden = 0x10000000
|
||||
}
|
||||
|
||||
// IFileDialog (GUID from Windows SDK)
|
||||
// - Omitting GetResults / GetSelectedItems to avoid overshadow.
|
||||
[ComImport]
|
||||
[Guid("42F85136-DB7E-439C-85F1-E4075D135FC8")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IFileDialog
|
||||
{
|
||||
[PreserveSig]
|
||||
int Show(IntPtr parent);
|
||||
|
||||
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
|
||||
void SetFileTypeIndex(uint iFileType);
|
||||
void GetFileTypeIndex(out uint piFileType);
|
||||
void Advise(IntPtr pfde, out uint pdwCookie);
|
||||
void Unadvise(uint dwCookie);
|
||||
void SetOptions(FileDialogOptions fos);
|
||||
void GetOptions(out FileDialogOptions pfos);
|
||||
void SetDefaultFolder(IShellItem psi);
|
||||
void SetFolder(IShellItem psi);
|
||||
void GetFolder(out IShellItem ppsi);
|
||||
void GetCurrentSelection(out IShellItem ppsi);
|
||||
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
||||
void GetFileName(out IntPtr pszName);
|
||||
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
|
||||
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
|
||||
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
|
||||
void GetResult(out IShellItem ppsi);
|
||||
void AddPlace(IShellItem psi, int fdap);
|
||||
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
|
||||
void Close(int hr);
|
||||
void SetClientGuid(ref Guid guid);
|
||||
void ClearClientData();
|
||||
void SetFilter(IntPtr pFilter);
|
||||
|
||||
// NOTE: We intentionally do NOT define GetResults and GetSelectedItems here,
|
||||
// because they cause overshadow warnings in IFileOpenDialog.
|
||||
}
|
||||
|
||||
// IFileOpenDialog extends IFileDialog by adding 2 new methods with the same name,
|
||||
// which otherwise cause overshadow warnings. We'll define them only here.
|
||||
[ComImport]
|
||||
[Guid("D57C7288-D4AD-4768-BE02-9D969532D960")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IFileOpenDialog : IFileDialog
|
||||
{
|
||||
// These two come after the parent's vtable:
|
||||
void GetResults(out IntPtr ppenum);
|
||||
void GetSelectedItems(out IntPtr ppsai);
|
||||
}
|
||||
|
||||
// The coclass for creating an IFileOpenDialog
|
||||
[ComImport]
|
||||
[Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
|
||||
private class FileOpenDialog
|
||||
{
|
||||
}
|
||||
|
||||
// IShellItem
|
||||
[ComImport]
|
||||
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IShellItem
|
||||
{
|
||||
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
||||
void GetParent(out IShellItem ppsi);
|
||||
void GetDisplayName(uint sigdnName, out IntPtr ppszName);
|
||||
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
|
||||
void Compare(IShellItem psi, uint hint, out int piOrder);
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern int SHCreateItemFromParsingName([MarshalAs(UnmanagedType.LPWStr)] string pszPath, IntPtr pbc, ref Guid riid, [MarshalAs(UnmanagedType.Interface, IidParameterIndex = 2)] out IShellItem ppv);
|
||||
|
||||
private const uint SIGDN_FILESYSPATH = 0x80058000;
|
||||
private static readonly Guid IID_IShellItem = new Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE");
|
||||
|
||||
public static string ShowDialog(string title, IntPtr parentHandle, string initialDirectory)
|
||||
{
|
||||
// Create COM dialog instance
|
||||
IFileOpenDialog dialog = (IFileOpenDialog)(new FileOpenDialog());
|
||||
|
||||
// Get current options
|
||||
FileDialogOptions opts;
|
||||
dialog.GetOptions(out opts);
|
||||
|
||||
// Add flags for picking folders
|
||||
opts |= FileDialogOptions.PickFolders | FileDialogOptions.PathMustExist | FileDialogOptions.ForceFileSystem;
|
||||
dialog.SetOptions(opts);
|
||||
|
||||
// Set initial directory if provided
|
||||
if (!string.IsNullOrEmpty(initialDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Guid iid = IID_IShellItem; // Create a local copy to pass by ref
|
||||
if (SHCreateItemFromParsingName(initialDirectory, IntPtr.Zero, ref iid, out IShellItem initialFolder) == 0)
|
||||
{
|
||||
dialog.SetFolder(initialFolder);
|
||||
Marshal.ReleaseComObject(initialFolder);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors in setting initial directory (e.g., path doesn't exist)
|
||||
}
|
||||
}
|
||||
|
||||
// Set title
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
dialog.SetTitle(title);
|
||||
}
|
||||
|
||||
// Show the dialog
|
||||
int hr = dialog.Show(parentHandle);
|
||||
// 0 = S_OK. 1 or 0x800704C7 often means user canceled. Return null if so.
|
||||
if (hr != 0)
|
||||
{
|
||||
if ((uint)hr == 0x800704C7 || hr == 1)
|
||||
{
|
||||
return null; // Canceled
|
||||
}
|
||||
else
|
||||
{
|
||||
Marshal.ThrowExceptionForHR(hr);
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve the selection (IShellItem)
|
||||
IShellItem shellItem;
|
||||
dialog.GetResult(out shellItem);
|
||||
if (shellItem == null) return null;
|
||||
|
||||
// Convert to file system path
|
||||
IntPtr pszPath = IntPtr.Zero;
|
||||
shellItem.GetDisplayName(SIGDN_FILESYSPATH, out pszPath);
|
||||
if (pszPath == IntPtr.Zero) return null;
|
||||
|
||||
string folderPath = Marshal.PtrToStringAuto(pszPath);
|
||||
Marshal.FreeCoTaskMem(pszPath);
|
||||
|
||||
return folderPath;
|
||||
}
|
||||
}
|
||||
"@
|
||||
Add-Type -TypeDefinition $modernFolderBrowserCode -Language CSharp
|
||||
}
|
||||
|
||||
# 2) Define a PowerShell function that invokes our C# wrapper
|
||||
function Show-ModernFolderPicker {
|
||||
param(
|
||||
[string]$Title = "Select a folder",
|
||||
[string]$InitialDirectory
|
||||
)
|
||||
# For a simple test, pass IntPtr.Zero as the parent window handle
|
||||
return [ModernFolderBrowser]::ShowDialog($Title, [IntPtr]::Zero, $InitialDirectory)
|
||||
}
|
||||
|
||||
function Invoke-BrowseAction {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('Folder', 'OpenFile', 'SaveFile')]
|
||||
[string]$Type,
|
||||
|
||||
[string]$Title,
|
||||
[string]$Filter,
|
||||
[string]$InitialDirectory,
|
||||
[string]$FileName,
|
||||
[string]$DefaultExt,
|
||||
[switch]$AllowNewFile
|
||||
)
|
||||
|
||||
switch ($Type) {
|
||||
'Folder' {
|
||||
return Show-ModernFolderPicker -Title $Title -InitialDirectory $InitialDirectory
|
||||
}
|
||||
'OpenFile' {
|
||||
$dialog = New-Object Microsoft.Win32.OpenFileDialog
|
||||
$dialog.Title = $Title
|
||||
if (-not [string]::IsNullOrWhiteSpace($Filter)) { $dialog.Filter = $Filter }
|
||||
if ($AllowNewFile) { $dialog.CheckFileExists = $false }
|
||||
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
|
||||
$dialog.InitialDirectory = $InitialDirectory
|
||||
}
|
||||
if ($dialog.ShowDialog()) {
|
||||
return $dialog.FileName
|
||||
}
|
||||
}
|
||||
'SaveFile' {
|
||||
$dialog = New-Object Microsoft.Win32.SaveFileDialog
|
||||
$dialog.Title = $Title
|
||||
if (-not [string]::IsNullOrWhiteSpace($Filter)) { $dialog.Filter = $Filter }
|
||||
if ($AllowNewFile) { $dialog.CheckFileExists = $false } # This property is obsolete but used in existing code.
|
||||
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
|
||||
$dialog.InitialDirectory = $InitialDirectory
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($FileName)) {
|
||||
$dialog.FileName = $FileName
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($DefaultExt)) {
|
||||
$dialog.DefaultExt = $DefaultExt
|
||||
}
|
||||
if ($dialog.ShowDialog()) {
|
||||
return $dialog.FileName
|
||||
}
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Clear-ListViewContent {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Windows.Controls.ListView]$ListViewControl,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfirmationTitle,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfirmationMessage,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[System.Collections.IList]$BackingDataList,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$StatusMessage,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[System.Windows.Controls.TextBox[]]$TextBoxesToClear,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[scriptblock]$PostClearAction
|
||||
)
|
||||
|
||||
$result = [System.Windows.MessageBox]::Show($ConfirmationMessage, $ConfirmationTitle, [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
|
||||
if ($result -ne [System.Windows.MessageBoxResult]::Yes) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
# If a backing data list is provided, clear it and rebind. This is the preferred method.
|
||||
if ($null -ne $BackingDataList) {
|
||||
$BackingDataList.Clear()
|
||||
$ListViewControl.ItemsSource = $BackingDataList.ToArray()
|
||||
}
|
||||
# If no backing list, determine how to clear the control.
|
||||
else {
|
||||
# If ItemsSource is in use, the only valid way to clear is to set it to null or an empty collection.
|
||||
if ($null -ne $ListViewControl.ItemsSource) {
|
||||
$ListViewControl.ItemsSource = $null
|
||||
}
|
||||
# If ItemsSource is NOT in use, we can safely clear the Items collection directly (for BYO Apps).
|
||||
elseif ($null -ne $ListViewControl.Items) {
|
||||
$ListViewControl.Items.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
$ListViewControl.Items.Refresh()
|
||||
|
||||
# Clear any specified textboxes
|
||||
if ($null -ne $TextBoxesToClear) {
|
||||
foreach ($textBox in $TextBoxesToClear) {
|
||||
$textBox.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
# Update the status message if provided
|
||||
if (-not [string]::IsNullOrWhiteSpace($StatusMessage) -and $null -ne $State.Controls.txtStatus) {
|
||||
$State.Controls.txtStatus.Text = $StatusMessage
|
||||
}
|
||||
|
||||
# Execute any post-clear custom actions. The scriptblock will have access to the $State and $ListViewControl variables from this function's scope.
|
||||
if ($null -ne $PostClearAction) {
|
||||
& $PostClearAction
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error in Clear-ListViewContent for $($ListViewControl.Name): $($_.Exception.Message)"
|
||||
[System.Windows.MessageBox]::Show("An error occurred while clearing the list: $($_.Exception.Message)", "Error", "OK", "Error")
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -0,0 +1,687 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Manages the business logic for the 'Windows Settings' tab in the FFU Builder UI.
|
||||
.DESCRIPTION
|
||||
This module contains all the functions and data required to populate and manage the controls on the 'Windows Settings' tab. It handles dynamic updates for Windows Release, Version, SKU, and Architecture ComboBoxes based on user input, such as specifying an ISO path. It also includes logic for populating available languages, media types, and the grid of optional Windows features.
|
||||
#>
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SECTION: Module Variables (Static Data)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
$script:allowedFeatures = @(
|
||||
"AppServerClient", "Client-DeviceLockdown", "Client-EmbeddedBootExp", "Client-EmbeddedLogon",
|
||||
"Client-EmbeddedShellLauncher", "Client-KeyboardFilter", "Client-ProjFS", "Client-UnifiedWriteFilter",
|
||||
"Containers", "Containers-DisposableClientVM", "Containers-HNS", "Containers-SDN", "DataCenterBridging",
|
||||
"DirectoryServices-ADAM-Client", "DirectPlay", "HostGuardian", "HypervisorPlatform", "IIS-ApplicationDevelopment",
|
||||
"IIS-ApplicationInit", "IIS-ASP", "IIS-ASPNET45", "IIS-BasicAuthentication", "IIS-CertProvider",
|
||||
"IIS-CGI", "IIS-ClientCertificateMappingAuthentication", "IIS-CommonHttpFeatures", "IIS-CustomLogging",
|
||||
"IIS-DefaultDocument", "IIS-DirectoryBrowsing", "IIS-DigestAuthentication", "IIS-ESP", "IIS-FTPServer",
|
||||
"IIS-FTPExtensibility", "IIS-FTPSvc", "IIS-HealthAndDiagnostics", "IIS-HostableWebCore", "IIS-HttpCompressionDynamic",
|
||||
"IIS-HttpCompressionStatic", "IIS-HttpErrors", "IIS-HttpLogging", "IIS-HttpRedirect", "IIS-HttpTracing",
|
||||
"IIS-IPSecurity", "IIS-IIS6ManagementCompatibility", "IIS-IISCertificateMappingAuthentication",
|
||||
"IIS-ISAPIExtensions", "IIS-ISAPIFilter", "IIS-LoggingLibraries", "IIS-ManagementConsole", "IIS-ManagementService",
|
||||
"IIS-ManagementScriptingTools", "IIS-Metabase", "IIS-NetFxExtensibility", "IIS-NetFxExtensibility45",
|
||||
"IIS-ODBCLogging", "IIS-Performance", "IIS-RequestFiltering", "IIS-RequestMonitor", "IIS-Security", "IIS-ServerSideIncludes",
|
||||
"IIS-StaticContent", "IIS-URLAuthorization", "IIS-WebDAV", "IIS-WebServer", "IIS-WebServerManagementTools",
|
||||
"IIS-WebServerRole", "IIS-WebSockets", "LegacyComponents", "MediaPlayback", "Microsoft-Hyper-V", "Microsoft-Hyper-V-All",
|
||||
"Microsoft-Hyper-V-Hypervisor", "Microsoft-Hyper-V-Management-Clients", "Microsoft-Hyper-V-Management-PowerShell",
|
||||
"Microsoft-Hyper-V-Services", "Microsoft-Windows-Subsystem-Linux", "MSMQ-ADIntegration", "MSMQ-Container", "MSMQ-DCOMProxy",
|
||||
"MSMQ-HTTP", "MSMQ-Multicast", "MSMQ-Server", "MSMQ-Triggers", "MultiPoint-Connector", "MultiPoint-Connector-Services",
|
||||
"MultiPoint-Tools", "NetFx3", "NetFx4-AdvSrvs", "NetFx4Extended-ASPNET45", "NFS-Administration", "Printing-Foundation-Features",
|
||||
"Printing-Foundation-InternetPrinting-Client", "Printing-Foundation-LPDPrintService", "Printing-Foundation-LPRPortMonitor",
|
||||
"Printing-PrintToPDFServices-Features", "Printing-XPSServices-Features", "SearchEngine-Client-Package",
|
||||
"ServicesForNFS-ClientOnly", "SimpleTCP", "SMB1Protocol", "SMB1Protocol-Client", "SMB1Protocol-Deprecation",
|
||||
"SMB1Protocol-Server", "SmbDirect", "TFTP", "TelnetClient", "TIFFIFilter", "VirtualMachinePlatform", "WAS-ConfigurationAPI",
|
||||
"WAS-NetFxEnvironment", "WAS-ProcessModel", "WAS-WindowsActivationService", "WCF-HTTP-Activation", "WCF-HTTP-Activation45",
|
||||
"WCF-MSMQ-Activation45", "WCF-MSMQ-Activation", "WCF-NonHTTP-Activation", "WCF-Pipe-Activation45", "WCF-Services45",
|
||||
"WCF-TCP-Activation45", "WCF-TCP-PortSharing45", "Windows-Defender-ApplicationGuard",
|
||||
"Windows-Defender-Default-Definitions", "Windows-Identity-Foundation", "WindowsMediaPlayer", "WorkFolders-Client"
|
||||
)
|
||||
|
||||
$script:skuList = @(
|
||||
'Home',
|
||||
'Home N',
|
||||
'Home Single Language',
|
||||
'Education',
|
||||
'Education N',
|
||||
'Pro',
|
||||
'Pro N',
|
||||
'Pro Education',
|
||||
'Pro Education N',
|
||||
'Pro for Workstations',
|
||||
'Pro N for Workstations',
|
||||
'Enterprise',
|
||||
'Enterprise N',
|
||||
'Enterprise 2016 LTSB',
|
||||
'Enterprise N 2016 LTSB',
|
||||
'Enterprise LTSC',
|
||||
'Enterprise N LTSC',
|
||||
'IoT Enterprise LTSC',
|
||||
'IoT Enterprise N LTSC',
|
||||
'Standard',
|
||||
'Standard (Desktop Experience)',
|
||||
'Datacenter',
|
||||
'Datacenter (Desktop Experience)'
|
||||
)
|
||||
|
||||
$script:allowedLangs = @(
|
||||
'ar-sa',
|
||||
'bg-bg',
|
||||
'cs-cz',
|
||||
'da-dk',
|
||||
'de-de',
|
||||
'el-gr',
|
||||
'en-gb',
|
||||
'en-us',
|
||||
'es-es',
|
||||
'es-mx',
|
||||
'et-ee',
|
||||
'fi-fi',
|
||||
'fr-ca',
|
||||
'fr-fr',
|
||||
'he-il',
|
||||
'hr-hr',
|
||||
'hu-hu',
|
||||
'it-it',
|
||||
'ja-jp',
|
||||
'ko-kr',
|
||||
'lt-lt',
|
||||
'lv-lv',
|
||||
'nb-no',
|
||||
'nl-nl',
|
||||
'pl-pl',
|
||||
'pt-br',
|
||||
'pt-pt',
|
||||
'ro-ro',
|
||||
'ru-ru',
|
||||
'sk-sk',
|
||||
'sl-si',
|
||||
'sr-latn-rs',
|
||||
'sv-se',
|
||||
'th-th',
|
||||
'tr-tr',
|
||||
'uk-ua',
|
||||
'zh-cn',
|
||||
'zh-tw'
|
||||
)
|
||||
|
||||
$script:allWindowsReleases = @(
|
||||
[PSCustomObject]@{ Display = "Windows 10"; Value = 10 },
|
||||
[PSCustomObject]@{ Display = "Windows 11"; Value = 11 },
|
||||
[PSCustomObject]@{ Display = "Windows Server 2016"; Value = 2016 },
|
||||
[PSCustomObject]@{ Display = "Windows Server 2019"; Value = 2019 },
|
||||
[PSCustomObject]@{ Display = "Windows Server 2022"; Value = 2022 },
|
||||
[PSCustomObject]@{ Display = "Windows Server 2025"; Value = 2025 },
|
||||
[PSCustomObject]@{ Display = "Windows 10 LTSB 2016"; Value = 2016 }, # Changed Value from 1607
|
||||
[PSCustomObject]@{ Display = "Windows 10 LTSC 2019"; Value = 2019 }, # Changed Value from 1809
|
||||
[PSCustomObject]@{ Display = "Windows 10 LTSC 2021"; Value = 2021 },
|
||||
[PSCustomObject]@{ Display = "Windows 11 LTSC 2024"; Value = 2024 }
|
||||
)
|
||||
|
||||
$script:mctWindowsReleases = @(
|
||||
[PSCustomObject]@{ Display = "Windows 10"; Value = 10 },
|
||||
[PSCustomObject]@{ Display = "Windows 11"; Value = 11 }
|
||||
)
|
||||
|
||||
$script:windowsVersionMap = @{
|
||||
10 = @("22H2")
|
||||
11 = @("25H2", "24H2", "23H2", "22H2")
|
||||
2016 = @("1607") # Windows 10 LTSB 2016 & Server 2016
|
||||
2019 = @("1809") # Windows 10 LTSC 2019 & Server 2019
|
||||
# Note: Server 2016 and LTSB 2016 now share the key 2016, mapping to version "1607"
|
||||
# Note: Server 2019 and LTSC 2019 now share the key 2019, mapping to version "1809"
|
||||
2021 = @("21H2") # LTSC 2021
|
||||
2022 = @("21H2") # Server 2022
|
||||
2024 = @("24H2") # LTSC 2024
|
||||
2025 = @("24H2") # Server 2025
|
||||
}
|
||||
|
||||
# SKU Groups
|
||||
$script:clientSKUs = @(
|
||||
'Home',
|
||||
'Home N',
|
||||
'Home Single Language',
|
||||
'Education',
|
||||
'Education N',
|
||||
'Pro',
|
||||
'Pro N',
|
||||
'Pro Education',
|
||||
'Pro Education N',
|
||||
'Pro for Workstations',
|
||||
'Pro N for Workstations',
|
||||
'Enterprise',
|
||||
'Enterprise N'
|
||||
)
|
||||
|
||||
$script:serverSKUs = @(
|
||||
'Standard',
|
||||
'Standard (Desktop Experience)',
|
||||
'Datacenter',
|
||||
'Datacenter (Desktop Experience)'
|
||||
)
|
||||
|
||||
$script:ltsc2016SKUs = @(
|
||||
'Enterprise 2016 LTSB',
|
||||
'Enterprise N 2016 LTSB'
|
||||
)
|
||||
|
||||
$script:ltscGenericSKUs = @( # For LTSC 2019, 2021, 2024
|
||||
'Enterprise LTSC',
|
||||
'Enterprise N LTSC'
|
||||
)
|
||||
|
||||
$script:iotLtscSKUs = @(
|
||||
'IoT Enterprise LTSC',
|
||||
'IoT Enterprise N LTSC'
|
||||
)
|
||||
|
||||
# Map Windows Release Values to their corresponding SKU lists
|
||||
$script:windowsReleaseSkuMap = @{
|
||||
10 = $script:clientSKUs # Windows 10 Client
|
||||
11 = $script:clientSKUs # Windows 11 Client
|
||||
2016 = $script:serverSKUs # Windows Server 2016 (LTSB 2016 handled by Get-AvailableSkusForRelease)
|
||||
2019 = $script:serverSKUs # Windows Server 2019 (LTSC 2019 handled by Get-AvailableSkusForRelease)
|
||||
2022 = $script:serverSKUs # Windows Server 2022
|
||||
2025 = $script:serverSKUs # Windows Server 2025
|
||||
2021 = $script:ltscGenericSKUs + $script:iotLtscSKUs # Windows 10 LTSC 2021
|
||||
2024 = $script:ltscGenericSKUs + $script:iotLtscSKUs # Windows 10 LTSC 2024
|
||||
# Note: LTSC 2016 and LTSC 2019 SKUs are now conditionally returned by Get-AvailableSkusForRelease
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SECTION: Functions
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Function to return the default settings and static lists
|
||||
function Get-WindowsSettingsDefaults {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
return [PSCustomObject]@{
|
||||
DefaultISOPath = ""
|
||||
DefaultWindowsArch = "x64"
|
||||
DefaultWindowsLang = "en-us"
|
||||
DefaultWindowsSKU = "Pro"
|
||||
DefaultMediaType = "Consumer"
|
||||
DefaultOptionalFeatures = ""
|
||||
DefaultProductKey = ""
|
||||
AllowedFeatures = $script:allowedFeatures
|
||||
AllowedLanguages = $script:allowedLangs
|
||||
AllowedArchitectures = @('x86', 'x64', 'arm64')
|
||||
AllowedMediaTypes = @('Consumer', 'Business')
|
||||
SkuList = $script:skuList
|
||||
AllWindowsReleases = $script:allWindowsReleases
|
||||
MctWindowsReleases = $script:mctWindowsReleases
|
||||
WindowsVersionMap = $script:windowsVersionMap
|
||||
ClientSKUs = $script:clientSKUs
|
||||
ServerSKUs = $script:serverSKUs
|
||||
Ltsc2016SKUs = $script:ltsc2016SKUs
|
||||
LtscGenericSKUs = $script:ltscGenericSKUs
|
||||
IotLtscSKUs = $script:iotLtscSKUs
|
||||
WindowsReleaseSkuMap = $script:windowsReleaseSkuMap
|
||||
}
|
||||
}
|
||||
|
||||
# Function to get the appropriate list of Windows Releases based on ISO path
|
||||
function Get-AvailableWindowsReleases {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$IsoPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrEmpty($IsoPath) -and $IsoPath.EndsWith('.iso', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $State.Defaults.WindowsSettingsDefaults.AllWindowsReleases
|
||||
}
|
||||
else {
|
||||
return $State.Defaults.WindowsSettingsDefaults.MctWindowsReleases
|
||||
}
|
||||
}
|
||||
|
||||
# Function to get available Windows Versions for a given release and ISO path
|
||||
function Get-AvailableWindowsVersions {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[int]$SelectedRelease,
|
||||
|
||||
[string]$IsoPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$result = [PSCustomObject]@{
|
||||
Versions = @()
|
||||
DefaultVersion = $null
|
||||
IsEnabled = $false
|
||||
}
|
||||
|
||||
if (-not $State.Defaults.WindowsSettingsDefaults.WindowsVersionMap.ContainsKey($SelectedRelease)) {
|
||||
return $result
|
||||
}
|
||||
|
||||
$validVersions = $State.Defaults.WindowsSettingsDefaults.WindowsVersionMap[$SelectedRelease]
|
||||
|
||||
if (-not [string]::IsNullOrEmpty($IsoPath) -and $IsoPath.EndsWith('.iso', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
# Logic for when an ISO is specified
|
||||
$result.Versions = $validVersions
|
||||
# Set default selection logic (e.g., latest for Win11)
|
||||
if ($SelectedRelease -eq 11) {
|
||||
if ($validVersions -contains "25H2") {
|
||||
$result.DefaultVersion = "25H2"
|
||||
}
|
||||
elseif ($validVersions -contains "24H2") {
|
||||
$result.DefaultVersion = "24H2"
|
||||
}
|
||||
}
|
||||
if (-not $result.DefaultVersion -and $validVersions.Count -gt 0) {
|
||||
$result.DefaultVersion = $validVersions[0]
|
||||
}
|
||||
$result.IsEnabled = $true
|
||||
}
|
||||
else {
|
||||
# Logic for when no ISO is specified (MCT scenario)
|
||||
switch ($SelectedRelease) {
|
||||
10 { $result.DefaultVersion = "22H2" }
|
||||
11 { $result.DefaultVersion = "25H2" }
|
||||
# Server versions typically require an ISO, but handle just in case
|
||||
2016 { $result.DefaultVersion = "1607" }
|
||||
2019 { $result.DefaultVersion = "1809" }
|
||||
2022 { $result.DefaultVersion = "21H2" }
|
||||
2025 { $result.DefaultVersion = "24H2" }
|
||||
default {
|
||||
if ($validVersions.Count -gt 0) { $result.DefaultVersion = $validVersions[0] }
|
||||
}
|
||||
}
|
||||
$result.Versions = @($result.DefaultVersion) # Only the default is available/relevant
|
||||
$result.IsEnabled = $false # Combo should be disabled
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
# Function to get available SKUs for a given Windows Release value and display name
|
||||
function Get-AvailableSkusForRelease {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[int]$SelectedReleaseValue,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$SelectedReleaseDisplayName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
WriteLog "Get-AvailableSkusForRelease: Getting SKUs for Release Value '$SelectedReleaseValue', Display Name '$SelectedReleaseDisplayName'."
|
||||
|
||||
# Handle LTSC 2016 specifically
|
||||
if ($SelectedReleaseValue -eq 2016 -and $SelectedReleaseDisplayName -like '*LTSB*') {
|
||||
WriteLog "Get-AvailableSkusForRelease: Matched LTSB 2016. Returning LTSC 2016 SKUs."
|
||||
return $State.Defaults.WindowsSettingsDefaults.Ltsc2016SKUs
|
||||
}
|
||||
# Handle LTSC 2019 specifically
|
||||
# Ensure "Server" is not in the display name to avoid matching "Windows Server 2019"
|
||||
elseif ($SelectedReleaseValue -eq 2019 -and $SelectedReleaseDisplayName -like '*LTSC*' -and $SelectedReleaseDisplayName -notlike '*Server*') {
|
||||
WriteLog "Get-AvailableSkusForRelease: Matched LTSC 2019. Returning generic LTSC SKUs (including IoT)."
|
||||
# Assuming LTSC 2019 uses the generic LTSC SKUs + IoT LTSC SKUs
|
||||
return ($State.Defaults.WindowsSettingsDefaults.LtscGenericSKUs + $State.Defaults.WindowsSettingsDefaults.IotLtscSKUs | Select-Object -Unique)
|
||||
}
|
||||
# For all other cases, use the main SKU map
|
||||
elseif ($State.Defaults.WindowsSettingsDefaults.WindowsReleaseSkuMap.ContainsKey($SelectedReleaseValue)) {
|
||||
$availableSkus = $State.Defaults.WindowsSettingsDefaults.WindowsReleaseSkuMap[$SelectedReleaseValue]
|
||||
WriteLog "Get-AvailableSkusForRelease: Found $($availableSkus.Count) SKUs for Release '$SelectedReleaseValue' using standard map."
|
||||
return $availableSkus
|
||||
}
|
||||
else {
|
||||
WriteLog "Get-AvailableSkusForRelease: Warning - Release Value '$SelectedReleaseValue' not found in SKU map. Returning default client SKUs."
|
||||
# Fallback to a default list (e.g., client SKUs) or an empty list
|
||||
return $State.Defaults.WindowsSettingsDefaults.ClientSKUs
|
||||
}
|
||||
}
|
||||
|
||||
# Function to refresh the Windows Release ComboBox based on ISO path
|
||||
function Update-WindowsReleaseCombo {
|
||||
param(
|
||||
[string]$isoPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
if (-not $State.Controls.cmbWindowsRelease) { return }
|
||||
|
||||
$oldSelectedItemValue = $null
|
||||
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
|
||||
$oldSelectedItemValue = $State.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||
}
|
||||
|
||||
# Get the appropriate list of releases from the helper module
|
||||
$availableReleases = Get-AvailableWindowsReleases -IsoPath $isoPath -State $State
|
||||
|
||||
# Update the ComboBox ItemsSource
|
||||
$State.Controls.cmbWindowsRelease.ItemsSource = $availableReleases
|
||||
$State.Controls.cmbWindowsRelease.DisplayMemberPath = 'Display'
|
||||
$State.Controls.cmbWindowsRelease.SelectedValuePath = 'Value'
|
||||
|
||||
# Try to re-select the previously selected item, or default
|
||||
$itemToSelect = $availableReleases | Where-Object { $_.Value -eq $oldSelectedItemValue } | Select-Object -First 1
|
||||
if ($null -ne $itemToSelect) {
|
||||
$State.Controls.cmbWindowsRelease.SelectedItem = $itemToSelect
|
||||
}
|
||||
elseif ($availableReleases.Count -gt 0) {
|
||||
# Default to Windows 11 if available, otherwise the first item
|
||||
$defaultItem = $availableReleases | Where-Object { $_.Value -eq 11 } | Select-Object -First 1
|
||||
if ($null -eq $defaultItem) {
|
||||
$defaultItem = $availableReleases[0]
|
||||
}
|
||||
$State.Controls.cmbWindowsRelease.SelectedItem = $defaultItem
|
||||
}
|
||||
else {
|
||||
# No items available (should not happen with current logic)
|
||||
$State.Controls.cmbWindowsRelease.SelectedIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
# Function to refresh the Windows Version ComboBox based on selected release and ISO path
|
||||
function Update-WindowsVersionCombo {
|
||||
param(
|
||||
[int]$selectedRelease,
|
||||
[string]$isoPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$combo = $State.Controls.cmbWindowsVersion
|
||||
if (-not $combo) { return }
|
||||
|
||||
# Get available versions and default from the helper module
|
||||
$versionData = Get-AvailableWindowsVersions -SelectedRelease $selectedRelease -IsoPath $isoPath -State $State
|
||||
|
||||
# Update the ComboBox ItemsSource and IsEnabled state
|
||||
$combo.ItemsSource = $versionData.Versions
|
||||
$combo.IsEnabled = $versionData.IsEnabled
|
||||
|
||||
# Set the selected item
|
||||
if ($null -ne $versionData.DefaultVersion -and $versionData.Versions -contains $versionData.DefaultVersion) {
|
||||
$combo.SelectedItem = $versionData.DefaultVersion
|
||||
}
|
||||
elseif ($versionData.Versions.Count -gt 0) {
|
||||
$combo.SelectedIndex = 0
|
||||
}
|
||||
else {
|
||||
$combo.SelectedIndex = -1 # No items available
|
||||
}
|
||||
}
|
||||
|
||||
# Function to refresh the Windows SKU ComboBox based on selected release
|
||||
function Update-WindowsSkuCombo {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$skuCombo = $State.Controls.cmbWindowsSKU
|
||||
if (-not $skuCombo) {
|
||||
WriteLog "Update-WindowsSkuCombo: SKU ComboBox not found."
|
||||
return
|
||||
}
|
||||
|
||||
$releaseCombo = $State.Controls.cmbWindowsRelease
|
||||
if (-not $releaseCombo -or $null -eq $releaseCombo.SelectedItem) {
|
||||
WriteLog "Update-WindowsSkuCombo: Windows Release ComboBox not found or no item selected. Cannot update SKUs."
|
||||
$skuCombo.ItemsSource = @() # Clear SKUs
|
||||
$skuCombo.SelectedIndex = -1
|
||||
return
|
||||
}
|
||||
|
||||
$selectedReleaseItem = $releaseCombo.SelectedItem
|
||||
$selectedReleaseValue = $selectedReleaseItem.Value
|
||||
$selectedReleaseDisplayName = $selectedReleaseItem.Display
|
||||
|
||||
$previousSelectedSku = $null
|
||||
if ($null -ne $skuCombo.SelectedItem) {
|
||||
$previousSelectedSku = $skuCombo.SelectedItem
|
||||
}
|
||||
|
||||
WriteLog "Update-WindowsSkuCombo: Updating SKUs for Release Value '$selectedReleaseValue' (Display: '$selectedReleaseDisplayName')."
|
||||
# Call Get-AvailableSkusForRelease with both Value and DisplayName
|
||||
$availableSkus = Get-AvailableSkusForRelease -SelectedReleaseValue $selectedReleaseValue -SelectedReleaseDisplayName $selectedReleaseDisplayName -State $State
|
||||
|
||||
$skuCombo.ItemsSource = $availableSkus
|
||||
WriteLog "Update-WindowsSkuCombo: Set ItemsSource with $($availableSkus.Count) SKUs."
|
||||
|
||||
# Attempt to re-select the previous SKU, or "Pro", or the first available
|
||||
if ($null -ne $previousSelectedSku -and $availableSkus -contains $previousSelectedSku) {
|
||||
$skuCombo.SelectedItem = $previousSelectedSku
|
||||
WriteLog "Update-WindowsSkuCombo: Re-selected previous SKU '$previousSelectedSku'."
|
||||
}
|
||||
elseif ($availableSkus -contains "Pro") {
|
||||
$skuCombo.SelectedItem = "Pro"
|
||||
WriteLog "Update-WindowsSkuCombo: Selected default SKU 'Pro'."
|
||||
}
|
||||
elseif ($availableSkus.Count -gt 0) {
|
||||
$skuCombo.SelectedIndex = 0
|
||||
WriteLog "Update-WindowsSkuCombo: Selected first available SKU '$($skuCombo.SelectedItem)'."
|
||||
}
|
||||
else {
|
||||
$skuCombo.SelectedIndex = -1 # No SKUs available
|
||||
WriteLog "Update-WindowsSkuCombo: No SKUs available for Release '$selectedReleaseValue' (Display: '$selectedReleaseDisplayName')."
|
||||
}
|
||||
}
|
||||
|
||||
# Function to refresh the Windows Architecture ComboBox based on selected release, version, and SKU
|
||||
function Update-WindowsArchCombo {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$archCombo = $State.Controls.cmbWindowsArch
|
||||
if (-not $archCombo) {
|
||||
WriteLog "Update-WindowsArchCombo: Architecture ComboBox not found."
|
||||
return
|
||||
}
|
||||
|
||||
$previousSelectedArch = $archCombo.SelectedItem
|
||||
|
||||
# Start with a safe, common default
|
||||
$availableArchitectures = @('x64')
|
||||
|
||||
$releaseItem = $State.Controls.cmbWindowsRelease.SelectedItem
|
||||
$versionItem = $State.Controls.cmbWindowsVersion.SelectedItem
|
||||
$skuItem = $State.Controls.cmbWindowsSKU.SelectedItem
|
||||
|
||||
if ($null -eq $releaseItem) {
|
||||
WriteLog "Update-WindowsArchCombo: No release selected. Defaulting to x64."
|
||||
$archCombo.ItemsSource = $availableArchitectures
|
||||
$archCombo.SelectedItem = 'x64'
|
||||
return
|
||||
}
|
||||
|
||||
$releaseDisplay = $releaseItem.Display
|
||||
$versionValue = if ($null -ne $versionItem) { $versionItem } else { "" }
|
||||
$skuValue = if ($null -ne $skuItem) { $skuItem } else { "" }
|
||||
|
||||
if ($releaseDisplay -like 'Windows Server*') {
|
||||
# All servers are x64 only
|
||||
$availableArchitectures = @('x64')
|
||||
}
|
||||
elseif ($releaseDisplay -like 'Windows 11*') {
|
||||
if ($releaseDisplay -like '*LTSC*') {
|
||||
# Windows 11 LTSC 2024
|
||||
if ($skuValue -like 'IoT*') {
|
||||
$availableArchitectures = @('x64', 'arm64')
|
||||
}
|
||||
else {
|
||||
$availableArchitectures = @('x64')
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Standard Windows 11
|
||||
if ($versionValue -in @('24H2', '25H2')) {
|
||||
$availableArchitectures = @('x64', 'arm64')
|
||||
}
|
||||
else {
|
||||
# 22H2, 23H2
|
||||
$availableArchitectures = @('x64')
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($releaseDisplay -like 'Windows 10*') {
|
||||
if ($releaseDisplay -like '*LTSB*' -or $releaseDisplay -like '*LTSC*') {
|
||||
# Windows 10 LTSB 2016, LTSC 2019, LTSC 2021
|
||||
if ($releaseDisplay -like '*2021*' -and $skuValue -like 'IoT*') {
|
||||
$availableArchitectures = @('x64', 'arm64')
|
||||
}
|
||||
else {
|
||||
# LTSB 2016, LTSC 2019, LTSC 2021 (non-IoT)
|
||||
$availableArchitectures = @('x86', 'x64')
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Standard Windows 10 (22H2)
|
||||
$availableArchitectures = @('x86', 'x64')
|
||||
}
|
||||
}
|
||||
|
||||
$archCombo.ItemsSource = $availableArchitectures
|
||||
|
||||
if ($availableArchitectures -contains $previousSelectedArch) {
|
||||
$archCombo.SelectedItem = $previousSelectedArch
|
||||
}
|
||||
elseif ($availableArchitectures -contains 'x64') {
|
||||
$archCombo.SelectedItem = 'x64'
|
||||
}
|
||||
elseif ($availableArchitectures.Count -gt 0) {
|
||||
$archCombo.SelectedIndex = 0
|
||||
}
|
||||
else {
|
||||
$archCombo.SelectedIndex = -1
|
||||
}
|
||||
WriteLog "Update-WindowsArchCombo: Updated available architectures to ($($availableArchitectures -join ', ')). Selected: $($archCombo.SelectedItem)"
|
||||
}
|
||||
|
||||
# Combined function to initialize the Release, Version, and SKU combos
|
||||
function Get-WindowsSettingsCombos {
|
||||
param(
|
||||
[string]$isoPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
# Determine visibility for download-specific controls based on ISO path
|
||||
$visibility = if (-not [string]::IsNullOrEmpty($isoPath) -and $isoPath.EndsWith('.iso', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
'Collapsed'
|
||||
}
|
||||
else {
|
||||
'Visible'
|
||||
}
|
||||
|
||||
# Set visibility for Language and Media Type controls
|
||||
$State.Controls.WindowsLangStackPanel.Visibility = $visibility
|
||||
$State.Controls.cmbWindowsLang.Visibility = $visibility
|
||||
$State.Controls.MediaTypeStackPanel.Visibility = $visibility
|
||||
$State.Controls.cmbMediaType.Visibility = $visibility
|
||||
|
||||
# Update Release combo first
|
||||
Update-WindowsReleaseCombo -isoPath $isoPath -State $State
|
||||
|
||||
# Get the newly selected release value
|
||||
$selectedReleaseValue = 11 # Default to 11 if selection is null
|
||||
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
|
||||
$selectedReleaseValue = $State.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||
}
|
||||
|
||||
# Update Version combo based on the selected release
|
||||
Update-WindowsVersionCombo -selectedRelease $selectedReleaseValue -isoPath $isoPath -State $State
|
||||
|
||||
# Update SKU combo based on the selected release (now derives values internally)
|
||||
Update-WindowsSkuCombo -State $State
|
||||
|
||||
# Finally, update the Architecture combo to match the initial state
|
||||
Update-WindowsArchCombo -State $State
|
||||
}
|
||||
|
||||
# Dynamic checkboxes for optional features in Windows Settings tab
|
||||
function UpdateOptionalFeaturesString {
|
||||
param(
|
||||
[psobject]$State
|
||||
)
|
||||
$checkedFeatures = @()
|
||||
foreach ($entry in $State.Controls.featureCheckBoxes.GetEnumerator()) {
|
||||
if ($entry.Value.IsChecked) { $checkedFeatures += $entry.Key }
|
||||
}
|
||||
}
|
||||
function BuildFeaturesGrid {
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.FrameworkElement]$parent,
|
||||
[Parameter(Mandatory)]
|
||||
[array]$allowedFeatures, # Pass the list of features explicitly
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
$parent.Children.Clear()
|
||||
$State.Controls.featureCheckBoxes.Clear() # Clear the tracking hashtable
|
||||
|
||||
$sortedFeatures = $allowedFeatures | Sort-Object
|
||||
$rows = 10 # Define number of rows for layout
|
||||
$columns = [math]::Ceiling($sortedFeatures.Count / $rows)
|
||||
|
||||
$featuresGrid = New-Object System.Windows.Controls.Grid
|
||||
$featuresGrid.Margin = "0,5,0,5"
|
||||
$featuresGrid.ShowGridLines = $false
|
||||
|
||||
# Define grid rows
|
||||
for ($r = 0; $r -lt $rows; $r++) {
|
||||
$rowDef = New-Object System.Windows.Controls.RowDefinition
|
||||
$rowDef.Height = [System.Windows.GridLength]::Auto
|
||||
$featuresGrid.RowDefinitions.Add($rowDef) | Out-Null
|
||||
}
|
||||
# Define grid columns
|
||||
for ($c = 0; $c -lt $columns; $c++) {
|
||||
$colDef = New-Object System.Windows.Controls.ColumnDefinition
|
||||
$colDef.Width = [System.Windows.GridLength]::Auto
|
||||
$featuresGrid.ColumnDefinitions.Add($colDef) | Out-Null
|
||||
}
|
||||
|
||||
# Populate grid with checkboxes
|
||||
for ($i = 0; $i -lt $sortedFeatures.Count; $i++) {
|
||||
$featureName = $sortedFeatures[$i]
|
||||
$colIndex = [int]([math]::Floor($i / $rows))
|
||||
$rowIndex = $i % $rows
|
||||
|
||||
$chk = New-Object System.Windows.Controls.CheckBox
|
||||
$chk.Content = $featureName
|
||||
$chk.Margin = "5"
|
||||
$chk.Add_Checked({
|
||||
param($eventSource, $e)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
if ($null -ne $window) {
|
||||
UpdateOptionalFeaturesString -State $window.Tag
|
||||
}
|
||||
})
|
||||
$chk.Add_Unchecked({
|
||||
param($eventSource, $e)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
if ($null -ne $window) {
|
||||
UpdateOptionalFeaturesString -State $window.Tag
|
||||
}
|
||||
})
|
||||
|
||||
$State.Controls.featureCheckBoxes[$featureName] = $chk # Track the checkbox
|
||||
|
||||
[System.Windows.Controls.Grid]::SetRow($chk, $rowIndex)
|
||||
[System.Windows.Controls.Grid]::SetColumn($chk, $colIndex)
|
||||
$featuresGrid.Children.Add($chk) | Out-Null
|
||||
}
|
||||
$parent.Children.Add($featuresGrid) | Out-Null
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SECTION: Module Export
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -0,0 +1,497 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Manages all Winget-related functionality for the 'Applications' tab in the FFU Builder UI.
|
||||
.DESCRIPTION
|
||||
This module provides the business logic for interacting with Winget from the FFU Builder UI. It includes functions for searching for packages, importing and exporting application lists, checking for and installing necessary Winget components (CLI and PowerShell module), and managing the parallel download of selected applications. It works in conjunction with FFU.Common.Winget for lower-level operations and FFU.Common.Parallel for managing concurrent downloads.
|
||||
#>
|
||||
|
||||
# Function to search for Winget apps
|
||||
function Search-WingetApps {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$searchQuery = $State.Controls.txtWingetSearch.Text
|
||||
if ([string]::IsNullOrWhiteSpace($searchQuery)) { return }
|
||||
|
||||
$State.Controls.txtStatus.Text = "Searching Winget for apps matching query '$searchQuery'..."
|
||||
$State.Window.Cursor = [System.Windows.Input.Cursors]::Wait
|
||||
$State.Controls.btnWingetSearch.IsEnabled = $false
|
||||
|
||||
try {
|
||||
# Get current items from the ListView
|
||||
$currentItemsInListView = @()
|
||||
if ($null -ne $State.Controls.lstWingetResults.ItemsSource) {
|
||||
$currentItemsInListView = @($State.Controls.lstWingetResults.ItemsSource)
|
||||
}
|
||||
elseif ($State.Controls.lstWingetResults.HasItems) {
|
||||
$currentItemsInListView = @($State.Controls.lstWingetResults.Items)
|
||||
}
|
||||
|
||||
# Store selected apps from the current view
|
||||
$selectedAppsFromView = @($currentItemsInListView | Where-Object { $_.IsSelected })
|
||||
|
||||
# Get default architecture from the UI
|
||||
$defaultArch = $State.Controls.cmbWindowsArch.SelectedItem
|
||||
|
||||
# Search for new apps, which are streamed directly as PSCustomObjects
|
||||
# with the required properties for performance.
|
||||
$searchedAppResults = Search-WingetPackagesPublic -Query $searchQuery -DefaultArchitecture $defaultArch
|
||||
$finalAppList = [System.Collections.Generic.List[object]]::new()
|
||||
$addedAppIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
# Add previously selected apps first
|
||||
foreach ($app in $selectedAppsFromView) {
|
||||
$finalAppList.Add($app)
|
||||
$addedAppIds.Add($app.Id) | Out-Null
|
||||
}
|
||||
|
||||
# Add new search results, avoiding duplicates of already added (selected) apps
|
||||
$newAppsAddedCount = 0
|
||||
foreach ($result in $searchedAppResults) {
|
||||
# HashSet.Add returns $true if the item was added, $false if it already existed.
|
||||
if ($addedAppIds.Add($result.Id)) {
|
||||
$finalAppList.Add($result)
|
||||
$newAppsAddedCount++
|
||||
}
|
||||
}
|
||||
|
||||
# Update the ListView's ItemsSource using the passed-in State object
|
||||
$State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray()
|
||||
|
||||
# Update status text
|
||||
$statusText = ""
|
||||
if ($newAppsAddedCount -gt 0) {
|
||||
$statusText = "Found $newAppsAddedCount new applications. "
|
||||
}
|
||||
else {
|
||||
$statusText = "No new applications found. "
|
||||
}
|
||||
$statusText += "Displaying $($finalAppList.Count) total applications."
|
||||
$State.Controls.txtStatus.Text = $statusText
|
||||
}
|
||||
catch {
|
||||
$errorMessage = "Error searching for apps: $($_.Exception.Message)"
|
||||
$State.Controls.txtStatus.Text = $errorMessage
|
||||
[System.Windows.MessageBox]::Show($errorMessage, "Error", "OK", "Error")
|
||||
}
|
||||
finally {
|
||||
$State.Window.Cursor = $null
|
||||
$State.Controls.btnWingetSearch.IsEnabled = $true
|
||||
}
|
||||
}
|
||||
|
||||
# Function to save selected apps to JSON
|
||||
function Save-WingetList {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
try {
|
||||
$selectedApps = $State.Controls.lstWingetResults.Items | Where-Object { $_.IsSelected }
|
||||
if (-not $selectedApps) {
|
||||
[System.Windows.MessageBox]::Show("No apps selected to save.", "Warning", "OK", "Warning")
|
||||
return
|
||||
}
|
||||
|
||||
$appList = @{
|
||||
apps = @($selectedApps | ForEach-Object {
|
||||
[ordered]@{
|
||||
name = (ConvertTo-SafeName -Name $_.Name)
|
||||
id = $_.Id
|
||||
source = $_.Source.ToLower()
|
||||
architecture = $_.Architecture
|
||||
AdditionalExitCodes = if ($_.PSObject.Properties['AdditionalExitCodes']) { $_.AdditionalExitCodes } else { "" }
|
||||
IgnoreNonZeroExitCodes = if ($_.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$_.IgnoreNonZeroExitCodes } else { $false }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$sfd = New-Object System.Windows.Forms.SaveFileDialog
|
||||
$sfd.Filter = "JSON files (*.json)|*.json"
|
||||
$sfd.Title = "Save App List"
|
||||
# Correctly get the path from the UI control via the State object
|
||||
$sfd.InitialDirectory = $State.Controls.txtApplicationPath.Text
|
||||
$sfd.FileName = "AppList.json"
|
||||
|
||||
if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
$appList | ConvertTo-Json -Depth 10 | Set-Content $sfd.FileName -Encoding UTF8
|
||||
[System.Windows.MessageBox]::Show("App list saved successfully.", "Success", "OK", "Information")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
[System.Windows.MessageBox]::Show("Error saving app list: $_", "Error", "OK", "Error")
|
||||
}
|
||||
}
|
||||
|
||||
# Function to import app list from JSON
|
||||
function Import-WingetList {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
try {
|
||||
$ofd = New-Object System.Windows.Forms.OpenFileDialog
|
||||
$ofd.Filter = "JSON files (*.json)|*.json"
|
||||
$ofd.Title = "Import App List"
|
||||
# Correctly get the path from the UI control via the State object
|
||||
$ofd.InitialDirectory = $State.Controls.txtApplicationPath.Text
|
||||
|
||||
if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
$importedAppsData = Get-Content $ofd.FileName -Raw | ConvertFrom-Json
|
||||
|
||||
$newAppListForItemsSource = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
if ($null -ne $importedAppsData.apps) {
|
||||
# Get default architecture from the UI for fallback
|
||||
$defaultArch = $State.Controls.cmbWindowsArch.SelectedItem
|
||||
|
||||
foreach ($appInfo in $importedAppsData.apps) {
|
||||
$arch = if ($appInfo.source -eq 'msstore') { 'NA' } else { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } }
|
||||
$newAppListForItemsSource.Add([PSCustomObject]@{
|
||||
IsSelected = $true # Imported apps are marked as selected
|
||||
Name = $appInfo.name
|
||||
Id = $appInfo.id
|
||||
Version = "" # Will be populated when searching or if data exists
|
||||
Source = $appInfo.source
|
||||
Architecture = $arch
|
||||
AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" }
|
||||
IgnoreNonZeroExitCodes = if ($appInfo.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$appInfo.IgnoreNonZeroExitCodes } else { $false }
|
||||
DownloadStatus = ""
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
$State.Controls.lstWingetResults.ItemsSource = $newAppListForItemsSource.ToArray()
|
||||
|
||||
[System.Windows.MessageBox]::Show("App list imported successfully.", "Success", "OK", "Information")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
[System.Windows.MessageBox]::Show("Error importing app list: $_", "Error", "OK", "Error")
|
||||
}
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SECTION: Winget Management Functions (Moved from FFUUI.Core.psm1)
|
||||
# --------------------------------------------------------------------------
|
||||
function Search-WingetPackagesPublic {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Query,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DefaultArchitecture
|
||||
)
|
||||
|
||||
WriteLog "Searching Winget packages with query: '$Query'"
|
||||
try {
|
||||
# Using ForEach-Object -Parallel can speed up object creation on multi-core systems
|
||||
# by distributing the work across multiple threads.
|
||||
$results = Find-WinGetPackage -Query $Query -ErrorAction Stop
|
||||
WriteLog "Found $($results.Count) packages matching query '$Query'."
|
||||
WriteLog "Creating output objects for Winget search results, please wait..."
|
||||
$output = $results | ForEach-Object -Parallel {
|
||||
$arch = if ($_.Source -eq 'msstore') { 'NA' } else { $using:DefaultArchitecture }
|
||||
[PSCustomObject]@{
|
||||
IsSelected = [bool]$false
|
||||
Name = [string]$_.Name
|
||||
Id = [string]$_.Id
|
||||
Version = [string]$_.Version
|
||||
Source = [string]$_.Source
|
||||
Architecture = [string]$arch
|
||||
AdditionalExitCodes = [string]::Empty
|
||||
IgnoreNonZeroExitCodes = [bool]$false
|
||||
DownloadStatus = [string]::Empty
|
||||
}
|
||||
} -ThrottleLimit 20
|
||||
WriteLog "Winget search completed. Created $($output.Count) output objects."
|
||||
return $output
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error during Winget search: $($_.Exception.Message)"
|
||||
# Return an empty array or throw, depending on desired UI policy
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
function Test-WingetCLI {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$minVersion = [version]"1.8.1911"
|
||||
|
||||
# Check Winget CLI
|
||||
$wingetCmd = Get-Command -Name winget -ErrorAction SilentlyContinue
|
||||
if (-not $wingetCmd) {
|
||||
return @{
|
||||
Version = "Not installed"
|
||||
Status = "Not installed - Install from Microsoft Store"
|
||||
}
|
||||
}
|
||||
|
||||
# Get and check version
|
||||
$wingetVersion = & winget.exe --version
|
||||
if ($wingetVersion -match 'v?(\d+\.\d+.\d+)') {
|
||||
$version = [version]$matches[1]
|
||||
if ($version -lt $minVersion) {
|
||||
return @{
|
||||
Version = $version.ToString()
|
||||
Status = "Update required - Install from Microsoft Store"
|
||||
}
|
||||
}
|
||||
return @{
|
||||
Version = $version.ToString()
|
||||
Status = $version.ToString()
|
||||
}
|
||||
}
|
||||
|
||||
return @{
|
||||
Version = "Unknown"
|
||||
Status = "Version check failed"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function Install-WingetComponents {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Add parameter to accept a script block for UI updates
|
||||
[Parameter(Mandatory)]
|
||||
[scriptblock]$UiUpdateCallback
|
||||
)
|
||||
|
||||
$minVersion = [version]"1.8.1911"
|
||||
$module = $null
|
||||
|
||||
try {
|
||||
# Check and update PowerShell Module
|
||||
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction SilentlyContinue
|
||||
if (-not $module -or $module.Version -lt $minVersion) {
|
||||
WriteLog "Winget module needs install/update. Attempting..."
|
||||
# Invoke the callback provided by the UI script to update status
|
||||
# Note: We don't have the CLI version readily available here, pass a placeholder or adjust if needed.
|
||||
& $UiUpdateCallback "Checking..." "Installing..."
|
||||
|
||||
# Store and modify PSGallery trust setting temporarily if needed
|
||||
$PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy
|
||||
if ($PSGalleryTrust -eq 'Untrusted') {
|
||||
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
|
||||
}
|
||||
|
||||
# Install/Update the module
|
||||
Install-Module -Name Microsoft.WinGet.Client -Force -Repository 'PSGallery' -Scope AllUsers
|
||||
|
||||
# Restore original PSGallery trust setting
|
||||
if ($PSGalleryTrust -eq 'Untrusted') {
|
||||
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted
|
||||
}
|
||||
|
||||
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction Stop
|
||||
}
|
||||
|
||||
return $module
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to install/update Winget PowerShell module: $_"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
# Winget Module Check Function (UI Version)
|
||||
# Performs checks, triggers install if needed, and reports status back to the UI.
|
||||
function Confirm-WingetInstallationUI {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Callback for intermediate UI updates (e.g., "Installing...")
|
||||
[Parameter(Mandatory)]
|
||||
[scriptblock]$UiUpdateCallback
|
||||
)
|
||||
|
||||
$minVersion = [version]"1.8.1911"
|
||||
$result = [PSCustomObject]@{
|
||||
Success = $false
|
||||
Message = ""
|
||||
CliVersion = "Unknown"
|
||||
ModuleVersion = "Unknown"
|
||||
NeedsUpdate = $false
|
||||
UpdateAttempted = $false
|
||||
}
|
||||
|
||||
try {
|
||||
# Initial Check
|
||||
WriteLog "Confirm-WingetInstallationUI: Starting checks..."
|
||||
$cliStatus = Test-WingetCLI
|
||||
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction SilentlyContinue
|
||||
|
||||
$result.CliVersion = $cliStatus.Version
|
||||
$result.ModuleVersion = if ($null -ne $module) { $module.Version.ToString() } else { "Not installed" }
|
||||
|
||||
# Use callback for initial status display
|
||||
& $UiUpdateCallback $result.CliVersion $result.ModuleVersion
|
||||
|
||||
# Determine if install/update is needed
|
||||
$needsCliUpdate = $cliStatus.Status -notmatch '^\d+\.\d+\.\d+$' -or ([version]$cliStatus.Version -lt $minVersion)
|
||||
$needsModuleUpdate = ($null -eq $module) -or ([version]$module.Version -lt $minVersion)
|
||||
$result.NeedsUpdate = $needsCliUpdate -or $needsModuleUpdate
|
||||
|
||||
if ($result.NeedsUpdate) {
|
||||
WriteLog "Confirm-WingetInstallationUI: Update needed. CLI Needs Update: $needsCliUpdate, Module Needs Update: $needsModuleUpdate"
|
||||
$result.UpdateAttempted = $true
|
||||
|
||||
# Use callback to indicate installation attempt
|
||||
& $UiUpdateCallback $result.CliVersion "Installing/Updating..."
|
||||
|
||||
# Attempt to install/update Winget CLI and module
|
||||
$installedModule = Install-WingetComponents -UiUpdateCallback $UiUpdateCallback
|
||||
|
||||
# Re-check status after attempt
|
||||
WriteLog "Confirm-WingetInstallationUI: Re-checking status after update attempt..."
|
||||
$cliStatus = Test-WingetCLI
|
||||
$result.CliVersion = $cliStatus.Version
|
||||
$result.ModuleVersion = if ($null -ne $installedModule) { $installedModule.Version } else { "Install Failed" }
|
||||
# Use callback for final status display after update attempt
|
||||
& $UiUpdateCallback $result.CliVersion $result.ModuleVersion
|
||||
|
||||
# Check if update was successful
|
||||
$cliOk = $cliStatus.Status -match '^\d+\.\d+\.\d+$' -and ([version]$cliStatus.Version -ge $minVersion)
|
||||
$moduleOk = ($null -ne $installedModule) -and ([version]$installedModule.Version -ge $minVersion)
|
||||
$result.Success = $cliOk -and $moduleOk
|
||||
$result.Message = if ($result.Success) { "Winget components installed/updated successfully." } else { "Winget component installation/update failed or is incomplete." }
|
||||
WriteLog "Confirm-WingetInstallationUI: Update attempt finished. Success: $($result.Success). Message: $($result.Message)"
|
||||
}
|
||||
else {
|
||||
# Already up-to-date
|
||||
$result.Success = $true
|
||||
$result.Message = "Winget components are up-to-date."
|
||||
WriteLog "Confirm-WingetInstallationUI: Components already up-to-date."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$result.Success = $false
|
||||
$result.Message = "Error during Winget check/install: $($_.Exception.Message)"
|
||||
WriteLog "Confirm-WingetInstallationUI: Error - $($result.Message)"
|
||||
# Use callback to show error state
|
||||
& $UiUpdateCallback $result.CliVersion "Error"
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
# Note: Start-WingetAppDownloadTask has been moved to FFU.Common.Winget.psm1
|
||||
# to enable code reuse between UI and CLI builds. It is imported via the FFU.Common module.
|
||||
|
||||
function Invoke-WingetDownload {
|
||||
param(
|
||||
[psobject]$State,
|
||||
[object]$Button
|
||||
)
|
||||
try {
|
||||
$selectedApps = $State.Controls.lstWingetResults.Items | Where-Object { $_.IsSelected }
|
||||
if (-not $selectedApps) {
|
||||
[System.Windows.MessageBox]::Show("No applications selected to download.", "Download Winget Apps", "OK", "Information")
|
||||
return
|
||||
}
|
||||
|
||||
$Button.IsEnabled = $false
|
||||
$State.Controls.pbOverallProgress.Visibility = 'Visible'
|
||||
$State.Controls.pbOverallProgress.Value = 0
|
||||
$State.Controls.txtStatus.Text = "Starting Winget app downloads..."
|
||||
|
||||
# Define necessary task-specific variables locally
|
||||
$localAppsPath = $State.Controls.txtApplicationPath.Text
|
||||
$localAppListJsonPath = $State.Controls.txtAppListJsonPath.Text
|
||||
$localWindowsArch = $State.Controls.cmbWindowsArch.SelectedItem
|
||||
$localOrchestrationPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath "Orchestration"
|
||||
|
||||
# Create hashtable for task-specific arguments to pass to Invoke-ParallelProcessing
|
||||
# UI downloads skip WinGetWin32Apps.json creation - it's generated at build time
|
||||
$taskArguments = @{
|
||||
AppsPath = $localAppsPath
|
||||
AppListJsonPath = $localAppListJsonPath
|
||||
OrchestrationPath = $localOrchestrationPath
|
||||
WindowsArch = $localWindowsArch
|
||||
SkipWin32Json = $true
|
||||
}
|
||||
|
||||
# Select only necessary properties before passing to Invoke-ParallelProcessing
|
||||
$itemsToProcess = $selectedApps | Select-Object Name, Id, Source, Version, Architecture # Include Version and Architecture if needed
|
||||
|
||||
# Before downloading, persist the selected apps to AppList.json including exit-code fields (parity with Save-WingetList)
|
||||
try {
|
||||
# Determine AppList.json path; default if empty
|
||||
if ([string]::IsNullOrWhiteSpace($localAppListJsonPath)) {
|
||||
$localAppListJsonPath = Join-Path -Path $localAppsPath -ChildPath "AppList.json"
|
||||
$taskArguments.AppListJsonPath = $localAppListJsonPath
|
||||
WriteLog "AppListJsonPath was empty. Defaulting to: $localAppListJsonPath"
|
||||
}
|
||||
|
||||
# Build apps payload from current selection, preserving AdditionalExitCodes/IgnoreNonZeroExitCodes
|
||||
$appListToSave = @{
|
||||
apps = @($selectedApps | ForEach-Object {
|
||||
[ordered]@{
|
||||
name = (ConvertTo-SafeName -Name $_.Name)
|
||||
id = $_.Id
|
||||
source = $_.Source.ToLower()
|
||||
architecture = $_.Architecture
|
||||
AdditionalExitCodes = if ($_.PSObject.Properties['AdditionalExitCodes']) { $_.AdditionalExitCodes } else { "" }
|
||||
IgnoreNonZeroExitCodes = if ($_.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$_.IgnoreNonZeroExitCodes } else { $false }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# Ensure destination directory exists and write AppList.json
|
||||
$destDir = Split-Path -Parent $localAppListJsonPath
|
||||
if (-not (Test-Path -LiteralPath $destDir)) {
|
||||
[void][System.IO.Directory]::CreateDirectory($destDir)
|
||||
}
|
||||
$appListToSave | ConvertTo-Json -Depth 10 | Set-Content -Path $localAppListJsonPath -Encoding UTF8
|
||||
WriteLog "Persisted AppList.json with selected apps and exit-code fields to: $localAppListJsonPath"
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Failed to persist AppList.json prior to download. Error: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Invoke the centralized parallel processing function
|
||||
# Pass task type and task-specific arguments
|
||||
Invoke-ParallelProcessing -ItemsToProcess $itemsToProcess `
|
||||
-ListViewControl $State.Controls.lstWingetResults `
|
||||
-IdentifierProperty 'Id' `
|
||||
-StatusProperty 'DownloadStatus' `
|
||||
-TaskType 'WingetDownload' `
|
||||
-TaskArguments $taskArguments `
|
||||
-CompletedStatusText "Completed" `
|
||||
-ErrorStatusPrefix "Error: " `
|
||||
-WindowObject $State.Window `
|
||||
-MainThreadLogPath $State.LogFilePath `
|
||||
-ThrottleLimit $State.Controls.txtThreads.Text
|
||||
|
||||
# Final status update is handled by Invoke-ParallelProcessing, but we need to re-enable the button
|
||||
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||
$Button.IsEnabled = $true
|
||||
}
|
||||
catch {
|
||||
WriteLog "FATAL Error in Invoke-WingetDownload: $($_.Exception.ToString())"
|
||||
[System.Windows.MessageBox]::Show("A critical error occurred while starting the Winget download: $($_.Exception.Message)", "Error", "OK", "Error")
|
||||
# Reset UI state on error
|
||||
if ($Button) { $Button.IsEnabled = $true }
|
||||
if ($State.Controls.pbOverallProgress) { $State.Controls.pbOverallProgress.Visibility = 'Collapsed' }
|
||||
if ($State.Controls.txtStatus) { $State.Controls.txtStatus.Text = "Winget download failed to start." }
|
||||
}
|
||||
}
|
||||
|
||||
function Update-WingetVersionFields {
|
||||
param(
|
||||
[psobject]$State,
|
||||
[string]$wingetText,
|
||||
[string]$moduleText
|
||||
)
|
||||
$State.Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action] {
|
||||
$State.Controls.txtWingetVersion.Text = $wingetText
|
||||
$State.Controls.txtWingetModuleVersion.Text = $moduleText
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
})
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -0,0 +1,143 @@
|
||||
#
|
||||
# Module manifest for module 'FFUUI.Core'
|
||||
#
|
||||
# Generated by: Richard Balsley
|
||||
#
|
||||
# Generated on: 6/11/2025
|
||||
#
|
||||
|
||||
@{
|
||||
|
||||
# Script module or binary module file associated with this manifest.
|
||||
RootModule = 'FFUUI.Core.psm1'
|
||||
|
||||
# Version number of this module.
|
||||
ModuleVersion = '0.0.1'
|
||||
|
||||
# Supported PSEditions
|
||||
# CompatiblePSEditions = @()
|
||||
|
||||
# ID used to uniquely identify this module
|
||||
GUID = '826c5868-c452-48a9-a3d8-9ff7fea54feb'
|
||||
|
||||
# Author of this module
|
||||
Author = 'Richard Balsley'
|
||||
|
||||
# Company or vendor of this module
|
||||
CompanyName = 'Unknown'
|
||||
|
||||
# Copyright statement for this module
|
||||
Copyright = '(c) Richard Balsley. All rights reserved.'
|
||||
|
||||
# Description of the functionality provided by this module
|
||||
Description = 'Core UI logic for the FFU Builder application.'
|
||||
|
||||
# Minimum version of the PowerShell engine required by this module
|
||||
# PowerShellVersion = ''
|
||||
|
||||
# Name of the PowerShell host required by this module
|
||||
# PowerShellHostName = ''
|
||||
|
||||
# Minimum version of the PowerShell host required by this module
|
||||
# PowerShellHostVersion = ''
|
||||
|
||||
# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
|
||||
# DotNetFrameworkVersion = ''
|
||||
|
||||
# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
|
||||
# ClrVersion = ''
|
||||
|
||||
# Processor architecture (None, X86, Amd64) required by this module
|
||||
# ProcessorArchitecture = ''
|
||||
|
||||
# Modules that must be imported into the global environment prior to importing this module
|
||||
RequiredModules = @('..\FFU.Common\FFU.Common.psd1')
|
||||
|
||||
# Assemblies that must be loaded prior to importing this module
|
||||
# RequiredAssemblies = @()
|
||||
|
||||
# Script files (.ps1) that are run in the caller's environment prior to importing this module.
|
||||
# ScriptsToProcess = @()
|
||||
|
||||
# Type files (.ps1xml) to be loaded when importing this module
|
||||
# TypesToProcess = @()
|
||||
|
||||
# Format files (.ps1xml) to be loaded when importing this module
|
||||
# FormatsToProcess = @()
|
||||
|
||||
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
|
||||
NestedModules = @('FFUUI.Core.Applications.psm1',
|
||||
'FFUUI.Core.Config.psm1',
|
||||
'FFUUI.Core.Drivers.psm1',
|
||||
'FFUUI.Core.Drivers.Dell.psm1',
|
||||
'FFUUI.Core.Drivers.HP.psm1',
|
||||
'FFUUI.Core.Drivers.Lenovo.psm1',
|
||||
'FFUUI.Core.Drivers.Microsoft.psm1',
|
||||
'FFUUI.Core.Handlers.psm1',
|
||||
'FFUUI.Core.Initialize.psm1',
|
||||
'FFUUI.Core.Shared.psm1',
|
||||
'FFUUI.Core.WindowsSettings.psm1',
|
||||
'FFUUI.Core.Winget.psm1')
|
||||
|
||||
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
|
||||
FunctionsToExport = '*'
|
||||
|
||||
# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
|
||||
CmdletsToExport = '*'
|
||||
|
||||
# Variables to export from this module
|
||||
VariablesToExport = '*'
|
||||
|
||||
# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
|
||||
AliasesToExport = '*'
|
||||
|
||||
# DSC resources to export from this module
|
||||
# DscResourcesToExport = @()
|
||||
|
||||
# List of all modules packaged with this module
|
||||
# ModuleList = @()
|
||||
|
||||
# List of all files packaged with this module
|
||||
# FileList = @()
|
||||
|
||||
# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell.
|
||||
PrivateData = @{
|
||||
|
||||
PSData = @{
|
||||
|
||||
# Tags applied to this module. These help with module discovery in online galleries.
|
||||
# Tags = @()
|
||||
|
||||
# A URL to the license for this module.
|
||||
# LicenseUri = ''
|
||||
|
||||
# A URL to the main website for this project.
|
||||
# ProjectUri = ''
|
||||
|
||||
# A URL to an icon representing this module.
|
||||
# IconUri = ''
|
||||
|
||||
# ReleaseNotes of this module
|
||||
# ReleaseNotes = ''
|
||||
|
||||
# Prerelease string of this module
|
||||
# Prerelease = ''
|
||||
|
||||
# Flag to indicate whether the module requires explicit user acceptance for install/update/save
|
||||
# RequireLicenseAcceptance = $false
|
||||
|
||||
# External dependent modules of this module
|
||||
# ExternalModuleDependencies = @()
|
||||
|
||||
} # End of PSData hashtable
|
||||
|
||||
} # End of PrivateData hashtable
|
||||
|
||||
# HelpInfo URI of this module
|
||||
# HelpInfoURI = ''
|
||||
|
||||
# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
|
||||
# DefaultCommandPrefix = ''
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Core logic module for the FFU Builder UI, providing helper functions, data retrieval, and UI state management.
|
||||
.DESCRIPTION
|
||||
This module serves as the central logic hub for the FFU Builder UI. It contains functions for retrieving system information (like Hyper-V switches and USB drives), providing default application settings, and dynamically managing the visibility and state of various UI controls across different tabs based on user selections. It orchestrates the interactions between different parts of the UI to ensure a consistent and logical user experience.
|
||||
#>
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SECTION: Module Variables (Static Data & State)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#Microsoft sites will intermittently fail on downloads. These headers and user agent are to help with that.
|
||||
$script:Headers = @{
|
||||
"Accept" = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
|
||||
"Accept-Encoding" = "gzip, deflate, br, zstd"
|
||||
"Accept-Language" = "en-US,en;q=0.9"
|
||||
"Priority" = "u=0, i"
|
||||
"Sec-Ch-Ua" = "`"Not)A;Brand`";v=`"8`", `"Chromium`";v=`"138`", `"Microsoft Edge`";v=`"138`""
|
||||
"Sec-Ch-Ua-Mobile" = "?0"
|
||||
"Sec-Ch-Ua-Platform" = "`"Windows`""
|
||||
"Sec-Fetch-Dest" = "document"
|
||||
"Sec-Fetch-Mode" = "navigate"
|
||||
"Sec-Fetch-Site" = "none"
|
||||
"Sec-Fetch-User" = "?1"
|
||||
"Upgrade-Insecure-Requests" = "1"
|
||||
}
|
||||
$script:UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0'
|
||||
|
||||
function Get-CoreStaticVariables {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
return @{
|
||||
Headers = $script:Headers
|
||||
UserAgent = $script:UserAgent
|
||||
}
|
||||
}
|
||||
|
||||
# Function to get VM Switch names and associated IP addresses
|
||||
function Get-VMSwitchData {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$switchMap = @{}
|
||||
$switchNames = @()
|
||||
|
||||
try {
|
||||
$allSwitches = Get-VMSwitch -ErrorAction SilentlyContinue
|
||||
if ($null -ne $allSwitches) {
|
||||
foreach ($sw in $allSwitches) {
|
||||
$adapterNamePattern = "*($($sw.Name))*"
|
||||
|
||||
# Attempt to find the network adapter associated with the vSwitch
|
||||
# Select-Object -First 1 ensures we only get one adapter if multiple match (unlikely but possible)
|
||||
$netAdapter = Get-NetAdapter -Name $adapterNamePattern -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
|
||||
if ($netAdapter) {
|
||||
# Get IPv4 addresses for the found adapter's interface index
|
||||
$netIPs = Get-NetIPAddress -InterfaceIndex $netAdapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
|
||||
|
||||
# Filter out Automatic Private IP Addressing (APIPA) addresses (169.254.x.x)
|
||||
# and select the first valid IP found.
|
||||
$validIP = $netIPs | Where-Object { $_.IPAddress -notlike '169.254.*' -and $_.IPAddress } | Select-Object -First 1
|
||||
|
||||
if ($validIP) {
|
||||
# Store the valid IP address in the map with the switch name as the key
|
||||
$switchMap[$sw.Name] = $validIP.IPAddress
|
||||
# Log the found IP address for debugging/information using WriteLog
|
||||
WriteLog "Found IP $($validIP.IPAddress) for vSwitch '$($sw.Name)' (Adapter: $($netAdapter.Name)). Adding to list."
|
||||
# Add the switch name to the list ONLY if a valid IP was found
|
||||
$switchNames += $sw.Name
|
||||
}
|
||||
else {
|
||||
WriteLog "No valid non-APIPA IPv4 address found for vSwitch '$($sw.Name)' (Adapter: $($netAdapter.Name)). Skipping from list."
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Could not find a network adapter matching pattern '$adapterNamePattern' for vSwitch '$($sw.Name)'. Skipping from list."
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "No Hyper-V virtual switches found on this system."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error occurred while getting VM Switch data: $($_.Exception.Message)"
|
||||
}
|
||||
return [PSCustomObject]@{
|
||||
SwitchNames = $switchNames
|
||||
SwitchMap = $switchMap
|
||||
}
|
||||
}
|
||||
|
||||
# Function to return general default settings for various UI elements
|
||||
function Get-GeneralDefaults {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FFUDevelopmentPath
|
||||
)
|
||||
|
||||
# Derive paths based on the main development path
|
||||
$appsPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "Apps"
|
||||
$driversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "Drivers"
|
||||
$peDriversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "PEDrivers"
|
||||
$vmLocationPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "VM"
|
||||
$ffuCapturePath = Join-Path -Path $FFUDevelopmentPath -ChildPath "FFU"
|
||||
$officePath = Join-Path -Path $appsPath -ChildPath "Office"
|
||||
$appListJsonPath = Join-Path -Path $appsPath -ChildPath "AppList.json"
|
||||
$driversJsonPath = Join-Path -Path $driversPath -ChildPath "Drivers.json"
|
||||
|
||||
return [PSCustomObject]@{
|
||||
# Build Tab Defaults
|
||||
CustomFFUNameTemplate = "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}"
|
||||
FFUCaptureLocation = $ffuCapturePath
|
||||
ShareName = "FFUCaptureShare"
|
||||
Username = "ffu_user"
|
||||
Threads = 5
|
||||
BitsPriority = 'Normal'
|
||||
MaxUSBDrives = 5
|
||||
BuildUSBDriveEnable = $false
|
||||
CompactOS = $true
|
||||
Optimize = $true
|
||||
AllowVHDXCaching = $false
|
||||
CreateCaptureMedia = $true
|
||||
CreateDeploymentMedia = $true
|
||||
Verbose = $false
|
||||
AllowExternalHardDiskMedia = $false
|
||||
PromptExternalHardDiskMedia = $true
|
||||
SelectSpecificUSBDrives = $false
|
||||
CopyAdditionalFFUFiles = $false
|
||||
CopyAutopilot = $false
|
||||
CopyUnattend = $false
|
||||
CopyPPKG = $false
|
||||
InjectUnattend = $false
|
||||
CleanupAppsISO = $true
|
||||
CleanupCaptureISO = $true
|
||||
CleanupDeployISO = $true
|
||||
CleanupDrivers = $false
|
||||
RemoveFFU = $false
|
||||
RemoveApps = $false
|
||||
RemoveUpdates = $false
|
||||
# Hyper-V Settings Defaults
|
||||
VMHostIPAddress = ""
|
||||
DiskSizeGB = 50
|
||||
MemoryGB = 4
|
||||
Processors = 4
|
||||
VMLocation = $vmLocationPath
|
||||
VMNamePrefix = "_FFU"
|
||||
LogicalSectorSize = 512
|
||||
# Updates Tab Defaults
|
||||
UpdateLatestCU = $true
|
||||
UpdateLatestNet = $true
|
||||
UpdateLatestDefender = $true
|
||||
UpdateEdge = $true
|
||||
UpdateOneDrive = $true
|
||||
UpdateLatestMSRT = $true
|
||||
UpdateLatestMicrocode = $false
|
||||
UpdatePreviewCU = $false
|
||||
# Applications Tab Defaults
|
||||
InstallApps = $false
|
||||
ApplicationPath = $appsPath
|
||||
AppListJsonPath = $appListJsonPath
|
||||
InstallWingetApps = $false
|
||||
BringYourOwnApps = $false
|
||||
# M365 Apps/Office Tab Defaults
|
||||
InstallOffice = $true
|
||||
OfficePath = $officePath
|
||||
CopyOfficeConfigXML = $false
|
||||
OfficeConfigXMLFilePath = ""
|
||||
# Drivers Tab Defaults
|
||||
DriversFolder = $driversPath
|
||||
PEDriversFolder = $peDriversPath
|
||||
DriversJsonPath = $driversJsonPath
|
||||
DownloadDrivers = $false
|
||||
InstallDrivers = $false
|
||||
CopyDrivers = $false
|
||||
CopyPEDrivers = $false
|
||||
UseDriversAsPEDrivers = $false
|
||||
UpdateADK = $true
|
||||
CompressDownloadedDriversToWim = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Function to get USB Drives (Moved from BuildFFUVM_UI.ps1)
|
||||
# Uses Get-Disk to retrieve UniqueId which is more reliable than SerialNumber
|
||||
# UniqueId is trimmed to remove the machine name suffix (characters after colon)
|
||||
function Get-USBDrives {
|
||||
Get-WmiObject Win32_DiskDrive | Where-Object {
|
||||
($_.MediaType -eq 'Removable Media' -or $_.MediaType -eq 'External hard disk media')
|
||||
} | ForEach-Object {
|
||||
$size = [math]::Round($_.Size / 1GB, 2)
|
||||
# Get the disk using the index to retrieve UniqueId
|
||||
$disk = Get-Disk -Number $_.Index -ErrorAction SilentlyContinue
|
||||
# Trim the machine name suffix (everything after the colon) from UniqueId
|
||||
$uniqueId = if ($disk -and $disk.UniqueId) {
|
||||
$rawId = $disk.UniqueId
|
||||
if ($rawId -match ':') {
|
||||
$rawId.Split(':')[0]
|
||||
}
|
||||
else {
|
||||
$rawId
|
||||
}
|
||||
}
|
||||
else {
|
||||
"N/A"
|
||||
}
|
||||
@{
|
||||
IsSelected = $false
|
||||
Model = $_.Model.Trim()
|
||||
UniqueId = $uniqueId
|
||||
Size = $size
|
||||
DriveIndex = $_.Index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Returns a list of FFU files from the provided folder with selection metadata
|
||||
function Get-FFUFiles {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
if (-not (Test-Path -Path $Path)) {
|
||||
return @()
|
||||
}
|
||||
Get-ChildItem -Path $Path -Filter '*.ffu' -File -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
IsSelected = $false
|
||||
Name = $_.Name
|
||||
LastModified = $_.LastWriteTime
|
||||
FullName = $_.FullName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Helper: Populate Additional FFU List from the capture folder
|
||||
function Update-AdditionalFFUList {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[PSCustomObject]$State
|
||||
)
|
||||
try {
|
||||
$ffuFolder = $State.Controls.txtFFUCaptureLocation.Text
|
||||
$listView = $State.Controls.lstAdditionalFFUs
|
||||
if ($null -eq $listView) { return }
|
||||
$listView.Items.Clear()
|
||||
if ([string]::IsNullOrWhiteSpace($ffuFolder) -or -not (Test-Path -Path $ffuFolder)) {
|
||||
WriteLog "Additional FFUs: Capture folder not set or not found: $ffuFolder"
|
||||
}
|
||||
else {
|
||||
$items = Get-ChildItem -Path $ffuFolder -Filter '*.ffu' -File -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
IsSelected = $false
|
||||
Name = $_.Name
|
||||
LastModified = $_.LastWriteTime
|
||||
FullName = $_.FullName
|
||||
}
|
||||
}
|
||||
foreach ($it in $items) { $listView.Items.Add($it) | Out-Null }
|
||||
WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder."
|
||||
}
|
||||
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Update-AdditionalFFUList error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Function to manage the visibility of the application UI panels
|
||||
function Update-ApplicationPanelVisibility {
|
||||
param(
|
||||
[PSCustomObject]$State,
|
||||
[string]$TriggeringControlName # Optional: to know which control initiated the change
|
||||
)
|
||||
|
||||
# If BYO Apps, Winget Apps, or Define Apps Script Variables is checked, force Install Apps to be checked
|
||||
if ($State.Controls.chkBringYourOwnApps.IsChecked -or $State.Controls.chkInstallWingetApps.IsChecked -or $State.Controls.chkDefineAppsScriptVariables.IsChecked) {
|
||||
$State.Controls.chkInstallApps.IsChecked = $true
|
||||
}
|
||||
|
||||
$installAppsChecked = $State.Controls.chkInstallApps.IsChecked
|
||||
|
||||
# If the main 'Install Apps' is unchecked, everything below it gets hidden and reset.
|
||||
if ($TriggeringControlName -eq 'chkInstallApps' -and -not $installAppsChecked) {
|
||||
$State.Controls.chkInstallWingetApps.IsChecked = $false
|
||||
$State.Controls.chkBringYourOwnApps.IsChecked = $false
|
||||
$State.Controls.chkDefineAppsScriptVariables.IsChecked = $false
|
||||
}
|
||||
|
||||
$byoAppsChecked = $State.Controls.chkBringYourOwnApps.IsChecked
|
||||
$wingetAppsChecked = $State.Controls.chkInstallWingetApps.IsChecked
|
||||
$defineVarsChecked = $State.Controls.chkDefineAppsScriptVariables.IsChecked
|
||||
|
||||
# Visibility of primary sub-options
|
||||
$subOptionVisibility = if ($installAppsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
$State.Controls.applicationPathPanel.Visibility = $subOptionVisibility
|
||||
$State.Controls.appListJsonPathPanel.Visibility = $subOptionVisibility
|
||||
$State.Controls.chkInstallWingetApps.Visibility = $subOptionVisibility
|
||||
$State.Controls.chkBringYourOwnApps.Visibility = $subOptionVisibility
|
||||
$State.Controls.chkDefineAppsScriptVariables.Visibility = $subOptionVisibility
|
||||
|
||||
# Visibility of panels dependent on sub-options
|
||||
$State.Controls.byoApplicationPanel.Visibility = if ($installAppsChecked -and $byoAppsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
$State.Controls.wingetPanel.Visibility = if ($installAppsChecked -and $wingetAppsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
$State.Controls.appsScriptVariablesPanel.Visibility = if ($installAppsChecked -and $defineVarsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
|
||||
# Special handling for wingetSearchPanel, which is shown by another button.
|
||||
# We only collapse it if its parent becomes invisible.
|
||||
if (-not ($installAppsChecked -and $wingetAppsChecked)) {
|
||||
$State.Controls.wingetSearchPanel.Visibility = 'Collapsed'
|
||||
}
|
||||
}
|
||||
|
||||
# Function to manage the state of the main "Install Apps" checkbox based on selections in Updates/Office
|
||||
function Update-InstallAppsState {
|
||||
param([PSCustomObject]$State)
|
||||
|
||||
$installAppsChk = $State.Controls.chkInstallApps
|
||||
$installOfficeChk = $State.Controls.chkInstallOffice
|
||||
|
||||
# Determine if any checkbox that forces "Install Apps" is checked
|
||||
$anyUpdateChecked = $State.Controls.chkUpdateLatestDefender.IsChecked -or `
|
||||
$State.Controls.chkUpdateEdge.IsChecked -or `
|
||||
$State.Controls.chkUpdateOneDrive.IsChecked -or `
|
||||
$State.Controls.chkUpdateLatestMSRT.IsChecked
|
||||
|
||||
$isForced = $anyUpdateChecked -or $installOfficeChk.IsChecked
|
||||
|
||||
if ($isForced) {
|
||||
# If InstallApps is not already forced (i.e., it's enabled), save its current state.
|
||||
if ($installAppsChk.IsEnabled) {
|
||||
$State.Flags.prevInstallAppsState = $installAppsChk.IsChecked
|
||||
}
|
||||
$installAppsChk.IsChecked = $true
|
||||
$installAppsChk.IsEnabled = $false
|
||||
}
|
||||
else {
|
||||
# No longer forced. Restore the previous state if it was saved.
|
||||
if ($State.Flags.ContainsKey('prevInstallAppsState')) {
|
||||
$installAppsChk.IsChecked = $State.Flags.prevInstallAppsState
|
||||
$State.Flags.Remove('prevInstallAppsState') # Use the saved state only once
|
||||
}
|
||||
else {
|
||||
# If no state was saved (e.g., it was never forced), ensure it's unchecked.
|
||||
$installAppsChk.IsChecked = $false
|
||||
}
|
||||
|
||||
# If BYO, Winget, or Apps Script Variables are checked, it overrides the restoration and keeps Install Apps checked.
|
||||
if ($State.Controls.chkBringYourOwnApps.IsChecked -or $State.Controls.chkInstallWingetApps.IsChecked -or $State.Controls.chkDefineAppsScriptVariables.IsChecked) {
|
||||
$installAppsChk.IsChecked = $true
|
||||
}
|
||||
|
||||
$installAppsChk.IsEnabled = $true
|
||||
}
|
||||
}
|
||||
|
||||
# Function to manage the enabled state of interdependent driver-related checkboxes
|
||||
function Update-DriverCheckboxStates {
|
||||
param([PSCustomObject]$State)
|
||||
|
||||
$installDriversChk = $State.Controls.chkInstallDrivers
|
||||
$copyDriversChk = $State.Controls.chkCopyDrivers
|
||||
$compressWimChk = $State.Controls.chkCompressDriversToWIM
|
||||
$copyPEDriversChk = $State.Controls.chkCopyPEDrivers
|
||||
$useDriversAsPeChk = $State.Controls.chkUseDriversAsPEDrivers
|
||||
|
||||
# Default to enabled, then apply disabling rules
|
||||
$installDriversChk.IsEnabled = $true
|
||||
$copyDriversChk.IsEnabled = $true
|
||||
$compressWimChk.IsEnabled = $true
|
||||
$copyPEDriversChk.IsEnabled = $true
|
||||
|
||||
if ($installDriversChk.IsChecked) {
|
||||
$copyDriversChk.IsEnabled = $false
|
||||
$compressWimChk.IsEnabled = $false
|
||||
}
|
||||
|
||||
if ($copyDriversChk.IsChecked) {
|
||||
$installDriversChk.IsEnabled = $false
|
||||
}
|
||||
|
||||
if ($compressWimChk.IsChecked) {
|
||||
$installDriversChk.IsEnabled = $false
|
||||
}
|
||||
|
||||
# Sub-option visibility logic: only show UseDriversAsPEDrivers when CopyPEDrivers is checked
|
||||
if ($copyPEDriversChk.IsChecked) {
|
||||
$useDriversAsPeChk.Visibility = 'Visible'
|
||||
}
|
||||
else {
|
||||
# Parent unchecked: hide and clear sub-option
|
||||
$useDriversAsPeChk.IsChecked = $false
|
||||
$useDriversAsPeChk.Visibility = 'Collapsed'
|
||||
}
|
||||
}
|
||||
|
||||
# Function to manage the visibility of Office UI panels
|
||||
function Update-OfficePanelVisibility {
|
||||
param([PSCustomObject]$State)
|
||||
|
||||
if ($State.Controls.chkInstallOffice.IsChecked) {
|
||||
$State.Controls.OfficePathStackPanel.Visibility = 'Visible'
|
||||
$State.Controls.OfficePathGrid.Visibility = 'Visible'
|
||||
$State.Controls.CopyOfficeConfigXMLStackPanel.Visibility = 'Visible'
|
||||
# Show/hide XML file path based on checkbox state
|
||||
$State.Controls.OfficeConfigurationXMLFileStackPanel.Visibility = if ($State.Controls.chkCopyOfficeConfigXML.IsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
$State.Controls.OfficeConfigurationXMLFileGrid.Visibility = if ($State.Controls.chkCopyOfficeConfigXML.IsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
}
|
||||
else {
|
||||
$State.Controls.OfficePathStackPanel.Visibility = 'Collapsed'
|
||||
$State.Controls.OfficePathGrid.Visibility = 'Collapsed'
|
||||
$State.Controls.CopyOfficeConfigXMLStackPanel.Visibility = 'Collapsed'
|
||||
$State.Controls.OfficeConfigurationXMLFileStackPanel.Visibility = 'Collapsed'
|
||||
$State.Controls.OfficeConfigurationXMLFileGrid.Visibility = 'Collapsed'
|
||||
}
|
||||
}
|
||||
|
||||
# Function to manage the visibility of the driver download UI panels
|
||||
function Update-DriverDownloadPanelVisibility {
|
||||
param([PSCustomObject]$State)
|
||||
|
||||
if ($State.Controls.chkDownloadDrivers.IsChecked) {
|
||||
$State.Controls.spMakeSection.Visibility = 'Visible'
|
||||
$State.Controls.btnGetModels.Visibility = 'Visible'
|
||||
# The other panels are shown/hidden by the Get Models button click handler
|
||||
}
|
||||
else {
|
||||
$State.Controls.spMakeSection.Visibility = 'Collapsed'
|
||||
$State.Controls.btnGetModels.Visibility = 'Collapsed'
|
||||
$State.Controls.spModelFilterSection.Visibility = 'Collapsed'
|
||||
$State.Controls.lstDriverModels.Visibility = 'Collapsed'
|
||||
$State.Controls.spDriverActionButtons.Visibility = 'Collapsed'
|
||||
$State.Controls.lstDriverModels.ItemsSource = $null
|
||||
$State.Data.allDriverModels.Clear()
|
||||
$State.Controls.txtModelFilter.Text = ""
|
||||
}
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SECTION: Module Export
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Export only the functions intended for public use by the UI script
|
||||
Export-ModuleMember -Function *
|
||||
@@ -4,22 +4,34 @@ param(
|
||||
$DeployISOPath,
|
||||
[Switch]$DisableAutoPlay
|
||||
)
|
||||
$Host.UI.RawUI.WindowTitle = 'USB Imaging Tool Creator'
|
||||
$Host.UI.RawUI.WindowTitle = 'Imaging Tool USB Creator'
|
||||
|
||||
if($DeployISOPath){
|
||||
$DevelopmentPath = $DeployISOPath | Split-Path
|
||||
$ImagesPath = "$DevelopmentPath\FFU"
|
||||
function WriteLog($LogText) {
|
||||
$LogFileName = '\Script.log'
|
||||
$LogFile = $DevelopmentPath + $LogFilename
|
||||
Add-Content -path $LogFile -value "$((Get-Date).ToString()) $LogText" -Force -ErrorAction SilentlyContinue
|
||||
Write-Verbose $LogText
|
||||
}
|
||||
Function Get-USBDrive {
|
||||
$USBDrives = (Get-WmiObject -Class Win32_DiskDrive -Filter "MediaType='Removable Media'")
|
||||
|
||||
function Write-ProgressLog {
|
||||
param(
|
||||
[string]$Activity,
|
||||
[string]$Status
|
||||
)
|
||||
Write-Progress -Activity $Activity -Status $Status -PercentComplete (($currentStep / $totalSteps) * 100)
|
||||
WriteLog $Status
|
||||
$script:currentStep++
|
||||
|
||||
}
|
||||
Function Get-RemovableDrive {
|
||||
writelog "Get information for all removable drives"
|
||||
$USBDrives = Get-WmiObject Win32_DiskDrive | Where-Object {$_.MediaType -eq "Removable media" -or $_.MediaType -eq "External hard disk media"}
|
||||
If($USBDrives -and ($null -eq $USBDrives.count)) {
|
||||
$USBDrivesCount = 1
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$USBDrivesCount = $USBDrives.Count
|
||||
}
|
||||
WriteLog "Found $USBDrivesCount USB drives"
|
||||
@@ -27,26 +39,30 @@ Function Get-USBDrive {
|
||||
if ($null -eq $USBDrives) {
|
||||
WriteLog "No removable USB drive found. Exiting"
|
||||
Write-Error "No removable USB drive found. Exiting"
|
||||
Pause
|
||||
exit 1
|
||||
}
|
||||
return $USBDrives, $USBDrivesCount
|
||||
}
|
||||
|
||||
Function Build-DeploymentUSB{
|
||||
param(
|
||||
[Array]$Drives
|
||||
)
|
||||
writelog "Checking if ffu files are present in the ffu folder"
|
||||
$Images = Get-ChildItem -Path $FFUPath -Filter "*.ffu" -File -Recurse
|
||||
writelog "Creating list of FFU image files"
|
||||
$Images = Get-ChildItem -Path $ImagesPath -Filter "*.ffu" -File -Recurse
|
||||
writelog "Checking if drivers are present in the drivers folder"
|
||||
$Drivers = Get-ChildItem -Path $DriversPath -Recurse
|
||||
$DrivesCount = $Drives.Count
|
||||
Writelog "Creating partitions..."
|
||||
Write-ProgressLog "Create Imaging Tool" "Creating partitions..."
|
||||
writelog "Create job to partition each usb drive"
|
||||
foreach ($USBDrive in $Drives) {
|
||||
$DriveNumber = $USBDrive.DeviceID.Replace("\\.\PHYSICALDRIVE", "")
|
||||
$Model = $USBDrive.model
|
||||
$ScriptBlock = {
|
||||
param($DriveNumber)
|
||||
Clear-Disk -Number $DriveNumber -RemoveData -RemoveOEM -Confirm:$false
|
||||
Initialize-Disk -Number $DriveNumber
|
||||
$Disk = Get-Disk -Number $DriveNumber
|
||||
$PartitionStyle = $Disk.PartitionStyle
|
||||
if($PartitionStyle -ne 'MBR'){
|
||||
@@ -97,11 +113,12 @@ $Destination = $Drive + ":\"
|
||||
[string]$SFolder,
|
||||
[string]$DFolder
|
||||
)
|
||||
New-Item -Path $DFolder -ItemType Directory -Force -Confirm: $false | Out-Null
|
||||
Robocopy $SFolder $DFolder /E /COPYALL /R:5 /W:5 /J
|
||||
}
|
||||
|
||||
WriteLog "Start job to copy all FFU files to $Destination"
|
||||
Start-Job -ScriptBlock $jobScriptBlock -ArgumentList $FFUPath, $Destination | Out-Null
|
||||
Start-Job -ScriptBlock $jobScriptBlock -ArgumentList $ImagesPath, $Destination | Out-Null
|
||||
}
|
||||
}
|
||||
if($Drivers){
|
||||
@@ -128,14 +145,14 @@ if(!($Drivers)){
|
||||
}
|
||||
}
|
||||
if($DrivesCount -gt 1){
|
||||
Writelog "Building $DrivesCount drives concurrently...Please be patient..."
|
||||
Write-ProgressLog "Create Imaging Tool" "Building $DrivesCount drives concurrently...Please be patient..."
|
||||
} else {
|
||||
Writelog "Building the imaging tool on $model...Please be patient..."
|
||||
Write-ProgressLog "Create Imaging Tool" "Building the imaging tool on $model...Please be patient..."
|
||||
}
|
||||
Get-Job | Wait-Job | Out-Null
|
||||
|
||||
Dismount-DiskImage -ImagePath $DeployISOPath | Out-Null
|
||||
Writelog "Drive creation jobs completed..."
|
||||
Write-ProgressLog "Create Imaging Tool" "Drive creation jobs completed..."
|
||||
}
|
||||
|
||||
Function New-DeploymentUSB {
|
||||
@@ -167,20 +184,15 @@ Function New-DeploymentUSB {
|
||||
$var = $true
|
||||
$DriveSelected = Read-Host 'Enter the drive number to apply the .iso to'
|
||||
$DriveSelected = ($DriveSelected -as [int]) -1
|
||||
if($Last){
|
||||
writelog "All drives selected"
|
||||
}else{
|
||||
writelog "Drive $DriveSelected selected"}
|
||||
writelog "Drive $DriveSelected selected"
|
||||
}
|
||||
|
||||
catch {
|
||||
Write-Host 'Input was not in correct format. Please enter a valid FFU number'
|
||||
$var = $false
|
||||
}
|
||||
} until (($DriveSelected -le $Count -1 -or $last) -and $var)
|
||||
|
||||
$DisableAutoPlayCurrentSetting = (Get-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\AutoplayHandlers" -Name DisableAutoplay).DisableAutoplay
|
||||
if($DisableAutoPlay -and $DisableAutoPlayCurrentSetting -ne 1){
|
||||
writelog "Disable autoPlay current setting is $DisableAutoPlayCurrentSetting"
|
||||
if($DisableAutoPlay){
|
||||
WriteLog "Setting the registry key to disable autoplay for all drives"
|
||||
Set-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\AutoplayHandlers" -Name "DisableAutoplay" -Value 1 -Type DWORD
|
||||
}
|
||||
@@ -199,10 +211,10 @@ Function New-DeploymentUSB {
|
||||
}
|
||||
WriteLog "Setting the registry key to re-enable autoplay for all drives"
|
||||
if($DisableAutoPlay){
|
||||
Writelog "Setting disable autoplay setting back to $DisableAutoPlayCurrentSetting"
|
||||
Set-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\AutoplayHandlers" -Name "DisableAutoplay" -Value $DisableAutoPlayCurrentSetting -Type DWORD
|
||||
Write-ProgressLog "Create Imaging Tool" "Enabling Autoplay"
|
||||
Set-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\AutoplayHandlers" -Name "DisableAutoplay" -Value 0 -Type DWORD
|
||||
}
|
||||
Writelog "Completed!"
|
||||
Write-ProgressLog "Create Imaging Tool" "Completed!"
|
||||
}
|
||||
#Get USB Drive and create log file
|
||||
if(Test-Path "$DevelopmentPath\Script.log"){
|
||||
@@ -211,8 +223,11 @@ New-item -Path $DevelopmentPath -Name 'Script.log' -ItemType "file" -Force | Out
|
||||
}
|
||||
WriteLog 'Begin Logging'
|
||||
WriteLog 'Getting USB drive information and usb drive count'
|
||||
$USBDrives,$USBDrivesCount = Get-USBDrive
|
||||
$USBDrives,$USBDrivesCount = Get-RemovableDrive
|
||||
WriteLog 'Setting first step for percentage progress bar'
|
||||
$currentStep = 1
|
||||
New-DeploymentUSB -Drives $USBDrives -Count $USBDrivesCount
|
||||
|
||||
read-host -Prompt "USB drive creation complete. Press ENTER to exit"
|
||||
|
||||
Exit
|
||||
|
||||
@@ -1,69 +1,237 @@
|
||||
#Modify the net use W: \\192.168.1.158\FFUCaptureShare /user:ffu_user ddb1f077-3eed-433c-b4d9-7b8cd54ce727
|
||||
net use W: \\192.168.1.158\FFUCaptureShare /user:ffu_user ddb1f077-3eed-433c-b4d9-7b8cd54ce727
|
||||
$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'
|
||||
reg load "HKLM\FFU" $Software
|
||||
try {
|
||||
Write-Host "Loading software registry hive to $Software"
|
||||
if (-not (Test-Path -Path $Software)) {
|
||||
throw "Software registry hive not found at $Software"
|
||||
}
|
||||
$regResult = reg load "HKLM\FFU" $Software 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Registry load failed with exit code $($LASTEXITCODE): $regResult"
|
||||
}
|
||||
Write-Host "Successfully loaded software registry hive."
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to load registry hive: $_"
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
#Find Windows version values
|
||||
|
||||
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'
|
||||
$DisplayVersion = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'DisplayVersion'
|
||||
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 { 'HomeN'}
|
||||
CoreSingleLanguage { 'HomeSL'}
|
||||
CoreN { 'Home_N' }
|
||||
CoreSingleLanguage { 'Home_SL' }
|
||||
Professional { 'Pro' }
|
||||
ProfessionalN { 'ProN'}
|
||||
ProfessionalN { 'Pro_N' }
|
||||
ProfessionalEducation { 'Pro_Edu' }
|
||||
ProfessionalEducationN { 'Pro_EduN' }
|
||||
ProfessionalEducationN { 'Pro_Edu_N' }
|
||||
Enterprise { 'Ent' }
|
||||
EnterpriseN { 'EntN'}
|
||||
EnterpriseN { 'Ent_N' }
|
||||
EnterpriseS { 'Ent_LTSC' }
|
||||
EnterpriseSN { 'Ent_N_LTSC' }
|
||||
IoTEnterpriseS { 'IoT_Ent_LTSC' }
|
||||
Education { 'Edu' }
|
||||
EducationN { 'EduN'}
|
||||
EducationN { 'Edu_N' }
|
||||
ProfessionalWorkstation { 'Pro_Wks' }
|
||||
ProfessionalWorkstationN { 'Pro_WksN' }
|
||||
ProfessionalWorkstationN { 'Pro_Wks_N' }
|
||||
ServerStandard { 'Srv_Std' }
|
||||
ServerDatacenter { 'Srv_Dtc' }
|
||||
}
|
||||
|
||||
if ($InstallationType -eq "Client") {
|
||||
if ($CurrentBuild -ge 22000) {
|
||||
$Name = 'Win11'
|
||||
$WindowsRelease = 'Win11'
|
||||
Write-Host "WindowsRelease: $WindowsRelease"
|
||||
}
|
||||
else {
|
||||
$Name = 'Win10'
|
||||
$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 | Out-Null
|
||||
$Office = Get-childitem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue
|
||||
$Office = Get-ChildItem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue
|
||||
if ($Office) {
|
||||
$ffuFilePath = "W:\$Name`_$DisplayVersion`_$SKU`_Office`_$BuildDate.ffu"
|
||||
$dismArgs = "/capture-ffu /imagefile=$ffuFilePath /capturedrive=\\.\PhysicalDrive0 /name:$Name$DisplayVersion$SKU /Compress:Default"
|
||||
|
||||
|
||||
$ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Office`_$BuildDate.ffu"
|
||||
Write-Host "Office is installed, using modified FFU file name: $ffuFilePath"
|
||||
}
|
||||
else {
|
||||
$ffuFilePath = "W:\$Name`_$DisplayVersion`_$SKU`_Apps`_$BuildDate.ffu"
|
||||
$dismArgs = "/capture-ffu /imagefile=$ffuFilePath /capturedrive=\\.\PhysicalDrive0 /name:$Name$DisplayVersion$SKU /Compress:Default"
|
||||
|
||||
$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
|
||||
Remove-Variable DisplayVersion
|
||||
if ($CurrentBuild -notin 14393, 17763) {
|
||||
Remove-Variable WindowsVersion
|
||||
}
|
||||
if ($Office) {
|
||||
Remove-Variable Office
|
||||
reg unload "HKLM\FFU"
|
||||
#This prevents Critical Process Died errors you can have during deployment of the FFU - may not happen during capture from WinPE, but adding here to be consistent with VHDX capture
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "Unloading registry hive HKLM\FFU..."
|
||||
$regUnloadResult = reg unload "HKLM\FFU" 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Registry unload failed with exit code $($LASTEXITCODE): $regUnloadResult"
|
||||
}
|
||||
Write-Host "Successfully unloaded registry hive."
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to unload registry hive: $_"
|
||||
|
||||
}
|
||||
|
||||
Write-Host "Sleeping for 60 seconds to allow registry to unload prior to capture"
|
||||
Start-sleep 60
|
||||
Start-Process -FilePath dism.exe -ArgumentList $dismArgs -Wait -PassThru -ErrorAction Stop | Out-Null
|
||||
#Copy DISM log to Host
|
||||
xcopy X:\Windows\logs\dism\dism.log W:\ /Y | Out-Null
|
||||
|
||||
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 +1,6 @@
|
||||
wpeinit
|
||||
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
|
||||
@echo off
|
||||
wpeinit > NUL
|
||||
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c > NUL
|
||||
powershell -Noprofile -ExecutionPolicy Bypass -File x:\ApplyFFU.ps1
|
||||
exit
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||
<settings pass="specialize">
|
||||
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
|
||||
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<ComputerName>MYCOMPUTER</ComputerName><!--Leave Default will be renamed-->
|
||||
<TimeZone>Eastern Standard Time</TimeZone><!--Add Your Local TimeZone-->
|
||||
</component>
|
||||
<!-- Place additional Components Elements and Settings below here: -->
|
||||
<component name="Microsoft-Windows-Deployment" 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">
|
||||
<RunASynchronous>
|
||||
<RunASynchronousCommand wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<Path>cmd.exe /c date 09-07-2024</Path> <!--Set the device clock to the current date. Helpful when BIOS clocks out of sync. -->
|
||||
<Description>Set system date to a specific date</Description>
|
||||
</RunASynchronousCommand>
|
||||
</RunASynchronous>
|
||||
</component>
|
||||
</settings>
|
||||
<settings pass="oobeSystem">
|
||||
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<InputLocale>0409:00000409</InputLocale><!--Set your Keybaord and System Local https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-8.1-and-8/hh825682(v=win.10) -->
|
||||
<SystemLocale>en-US</SystemLocale>
|
||||
<UILanguage>en-US</UILanguage>
|
||||
<UserLocale>en-US</UserLocale>
|
||||
</component>
|
||||
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<OOBE>
|
||||
<ProtectYourPC>3</ProtectYourPC> <!--Disable Diagnostic Data sent to Microsoft-->
|
||||
<HideEULAPage>true</HideEULAPage><!--Hide the End User License agreement -->
|
||||
<HideWirelessSetupInOOBE>false</HideWirelessSetupInOOBE> <!--Show Wifi Setup -->
|
||||
</OOBE>
|
||||
</component>
|
||||
</settings>
|
||||
</unattend>
|
||||
@@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||
<settings pass="specialize">
|
||||
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
|
||||
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<ComputerName>MyComputer</ComputerName>
|
||||
</component>
|
||||
<!--Place addtional Components Elements and settings below here. -->
|
||||
</settings>
|
||||
</unattend>
|
||||
@@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||
<settings pass="specialize">
|
||||
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
|
||||
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<ComputerName>MyComputer</ComputerName>
|
||||
</component>
|
||||
<!--Place addtional Components Elements and settings below here. -->
|
||||
</settings>
|
||||
</unattend>
|
||||
@@ -1,19 +1,105 @@
|
||||
# Using Full Flash Update (FFU) files to speed up Windows deployment
|
||||
|
||||
This repo contains the full FFU process that we use in US Education at Microsoft to help customers with large deployments of Windows as they prepare for the new school year. This process isn't limited to only large deployments at the start of the year, but is the most common.
|
||||
What if you could have a Windows image (Windows 10/11/Server/LTSC) that has:
|
||||
|
||||
This process will copy Windows in about 2-3 minutes to the target device, optionally copy drivers, provisioning packages, Autopilot, etc. School technicians have even given the USB sticks to teachers and teachers calling them their "Magic USB sticks" to quickly get student devices reimaged in the event of an issue with their Windows PC.
|
||||
- The latest Windows cumulative update
|
||||
- The latest .NET cumulative update
|
||||
- The latest Windows Defender Platform and Definition Updates
|
||||
- The latest version of Microsoft Edge
|
||||
- The latest version of OneDrive (Per-Machine)
|
||||
- The latest version of Microsoft 365 Apps/Office
|
||||
- The latest drivers from any of the major OEMs (Dell, HP, Lenovo, Microsoft) (yes, the latest, not some out of date enterprise CAB file from years ago)
|
||||
- Winget support so you can integrate any app available from Winget directly in your image
|
||||
- ARM64 support for the latest Copilot+ PCs
|
||||
- The ability to bring your own drivers and apps if necessary
|
||||
- Custom WinRE support
|
||||
|
||||
While we use this in Education at Microsoft, other industries can use it as well. We esepcially see a need for something like this with partners who do re-imaging on behalf of customers. The difference in Education is that they typically have large deployments that tend to happen at the beginning of the school year and any amount of time saved is helpful. Microsoft Deployment Toolkit, Configuration Manager, and other community solutions are all great solutions, but are typically slower due to WIM deployments being file-based while FFU files are sector-based.
|
||||
And the best part: **it takes less than two minutes** to apply the image, even with all of these updates added to the media. After setting Windows up and going through Autopilot or a provisioning package, total elapsed time ~10 minutes (depending on what Intune or your device management tool is deploying).
|
||||
|
||||
The Full-Flash update (FFU) process can automatically download the latest release of Windows 11, the updates mentioned above, and creates a USB drive that can be used to quickly reimage a machine.
|
||||
|
||||
# Updates
|
||||
|
||||
2406.1 has been released! Check out the changes in the new [Change Log](ChangeLog.md)
|
||||
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
|
||||
|
||||
If you're not familiar with Github, you can click the Green code button above and select download zip. Extract the zip file and make sure to copy the FFUDevelopment folder to the root of your C: drive. That will make it easy to follow the guide and allow the scripts to work properly.
|
||||
- Download the latest [release](https://github.com/rbalsleyMSFT/FFU/releases)
|
||||
- Extract the FFUDevelopment folder from the ZIP file (recommend to C:\FFUDevelopment)
|
||||
- Watch the Youtube video (updated docs for the UI coming soon)
|
||||
|
||||
If extracted correctly, your c:\FFUDevelopment folder should look like the following. If it does, go to c:\FFUDevelopment\Docs\BuildDeployFFU.docx to get started.
|
||||
## YouTube Detailed Walkthrough
|
||||
|
||||

|
||||
Here's a detailed overview of the new UI process.
|
||||
|
||||
[](https://youtu.be/oozG1aVcg9M "Reimage Windows Fast with FFU Builder 2507.1 Preview")
|
||||
|
||||
Chapters:
|
||||
|
||||
[00:00](https://www.youtube.com/watch?v=oozG1aVcg9M&t=0s) Begin
|
||||
|
||||
[01:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=67s) Prereqs
|
||||
|
||||
[06:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=392s) Demo Begins
|
||||
|
||||
[07:16](https://www.youtube.com/watch?v=oozG1aVcg9M&t=436s) Running the BuildFFUVM_UI.ps1 script
|
||||
|
||||
[08:15](https://www.youtube.com/watch?v=oozG1aVcg9M&t=495s) UI Overview
|
||||
|
||||
[10:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=613s) Hyper-V Settings
|
||||
|
||||
[16:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=964s) Windows Settings
|
||||
|
||||
[22:35](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1355s) Updates
|
||||
|
||||
[24:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1489s) Applications
|
||||
|
||||
[29:39](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1779s) Install Winget Applications
|
||||
|
||||
[45:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=2729s) Bring Your Own Applications
|
||||
|
||||
[54:14](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3254s) Apps Script Variables
|
||||
|
||||
[57:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3463s) M365 Apps/Office
|
||||
|
||||
[59:01](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3541s) Drivers
|
||||
|
||||
[01:01:22](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3682s) Drivers.json example
|
||||
|
||||
[01:02:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3727s) DriverMapping.json explanation
|
||||
|
||||
[01:06:08](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3968s) Driver WIM Compression
|
||||
|
||||
[01:10:50](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4250s) Build
|
||||
|
||||
[01:12:41](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4361s) Build USB Drive
|
||||
|
||||
[01:20:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4807s) Monitor
|
||||
|
||||
[01:20:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4832s) Setting up the Demo Build
|
||||
|
||||
[01:24:10](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5050s) Save/Load Config Files
|
||||
|
||||
[01:25:11](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5111s) Kicking off the Demo Build/Going over the monitor tab
|
||||
|
||||
[01:32:26](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5546s) Demoing the new FFU Builder Orchestrator
|
||||
|
||||
[01:35:25](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5725s) New captureffu.ps1 console output
|
||||
|
||||
[01:42:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6149s) Demo Build Complete
|
||||
|
||||
[01:42:42](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6162s) How to configure a VM to test your newly built FFU
|
||||
|
||||
[01:48:58](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6538s) The moment of truth: What does the new deployment experience look like?
|
||||
|
||||
[01:53:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6793s) How to bypass OOBE using a provisioning package
|
||||
|
||||
[01:55:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6949s) Preview Focus Areas
|
||||
|
||||
[02:04:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7444s) Known Issues/Things to fix before GA
|
||||
|
||||
[02:05:38](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7538s) Providing Feedback
|
||||
|
||||
[02:06:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7603s) Thank you
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: M365 Apps/Office
|
||||
nav_order: 8
|
||||
prev_url: /appsscriptvariables.html
|
||||
prev_label: Apps Script Variables
|
||||
next_url: /drivers.html
|
||||
next_label: Drivers
|
||||
parent: UI Overview
|
||||
---
|
||||
# M365 Apps/Office
|
||||
|
||||

|
||||
|
||||
FFU Builder uses the Office Deployment Toolkit (ODT) to install Office. In the `.\FFUDevelopment\Apps\Office` folder you'll find two files:
|
||||
|
||||
* `DownloadFFU.xml`
|
||||
* `DeployFFU.xml`
|
||||
|
||||
## DownloadFFU.xml
|
||||
|
||||
`DownloadFFU.xml` is responsible for the download of Office. It's invoked by `setup.exe /download .\DownloadFFU.xml` during the build process. It defaults to downloading the current channel 64-bit version of Office matching the current OS language to `C:\FFUDevelopment\Apps\Office`.
|
||||
|
||||
`DownloadFFU.xml` contents:
|
||||
|
||||
```
|
||||
<Configuration ID="efa6df21-a106-428e-8eaa-d89a5dda6030">
|
||||
<Add SourcePath="C:\FFUDevelopment\Apps\Office" OfficeClientEdition="64" Channel="Current">
|
||||
<Product ID="O365ProPlusRetail">
|
||||
<Language ID="MatchOS" />
|
||||
</Product>
|
||||
</Add>
|
||||
</Configuration>
|
||||
```
|
||||
|
||||
If you want to modify the language, you'll need to change the language ID to the language you wish to download and install.
|
||||
|
||||
For more information about deploying languages see: [Overview of deploying languages for Microsoft 365 Apps - Microsoft 365 Apps Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-365-apps/deploy/overview-deploying-languages-microsoft-365-apps)
|
||||
|
||||
## DeployFFU.xml
|
||||
|
||||
`DeployFFU.xml` is responsible for customizing the installation of Office. If you don't provide a custom XML, it will default to using what's in `DeployFFU.xml`. The default configuration will install the 64-bit current channel version of Office with Word, Excel, Powerpoint. Below is what's currently in `DeployFFU.xml`:
|
||||
|
||||
```
|
||||
<Configuration ID="efa6df21-a106-428e-8eaa-d89a5dda6030">
|
||||
<Add OfficeClientEdition="64" Channel="Current">
|
||||
<Product ID="O365ProPlusRetail">
|
||||
<Language ID="MatchOS" />
|
||||
<ExcludeApp ID="Access" />
|
||||
<ExcludeApp ID="Lync" />
|
||||
<ExcludeApp ID="Publisher" />
|
||||
<ExcludeApp ID="Bing" />
|
||||
<ExcludeApp ID="Teams" />
|
||||
<ExcludeApp ID="Outlook" />
|
||||
</Product>
|
||||
</Add>
|
||||
<Property Name="SharedComputerLicensing" Value="0" />
|
||||
<Property Name="FORCEAPPSHUTDOWN" Value="FALSE" />
|
||||
<Property Name="DeviceBasedLicensing" Value="0" />
|
||||
<Property Name="SCLCacheOverride" Value="0" />
|
||||
<Updates Enabled="TRUE" />
|
||||
<Display Level="None" AcceptEULA="TRUE" />
|
||||
</Configuration>
|
||||
```
|
||||
|
||||
## Copy Office Configuration XML
|
||||
|
||||
If you want to include your own custom XML file for office, check **Copy Office Configuration XML** and browse to the location of your custom XML file. The path to your custom Office configuration XML file is stored in the `OfficeConfigXMLFile` parameter. This file gets added to the `.\FFUDevelopment\Apps\Office` folder and is referenced in the `.\FFUDevelopment\Apps\Orchestration\Install-Office.ps1` file.
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,23 @@
|
||||
title: FFU Builder
|
||||
description: Build and deploy Windows FFU images
|
||||
remote_theme: just-the-docs/just-the-docs@v0.10.1
|
||||
plugins:
|
||||
- jekyll-remote-theme
|
||||
- jekyll-seo-tag
|
||||
- jekyll-sitemap
|
||||
|
||||
search_enabled: true
|
||||
|
||||
# Because you’ll publish as a project site at /FFU
|
||||
baseurl: "/FFU"
|
||||
|
||||
callouts:
|
||||
note:
|
||||
title: Note
|
||||
color: purple
|
||||
tip:
|
||||
title: Tip
|
||||
color: green
|
||||
warning:
|
||||
title: Warning
|
||||
color: yellow
|
||||
@@ -0,0 +1,199 @@
|
||||
<!-- docs/_includes/head_custom.html -->
|
||||
<style>
|
||||
/* Layout: remove Just-the-Docs "centered narrow" constraints on wide screens */
|
||||
@media (min-width: 50rem) {
|
||||
.main {
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 66.5rem) {
|
||||
.side-bar {
|
||||
width: 16.5rem !important;
|
||||
min-width: 16.5rem !important;
|
||||
}
|
||||
|
||||
.side-bar+.main {
|
||||
margin-left: 16.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Readability: wider column + slightly larger, less-thin text */
|
||||
@media (min-width: 66.5rem) {
|
||||
.main-content {
|
||||
max-width: 1100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 90rem) {
|
||||
.main-content {
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typography: approximate Microsoft Learn (Segoe UI Variable + regular body + semibold headings) */
|
||||
body,
|
||||
.main-content {
|
||||
font-family: "Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
font-size: 1rem;
|
||||
/* 16px-ish, closer to Learn */
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
|
||||
/* Just-the-Docs defaults body text to a mid-grey; make it closer to Learn */
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
.main-content p,
|
||||
.main-content li {
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.main-content h1,
|
||||
.main-content h2,
|
||||
.main-content h3 {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.main-content code,
|
||||
.main-content pre code {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/* Wrapping: prevent long code/paths from overflowing into the page TOC */
|
||||
.main-content {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.main-content :not(pre) > code {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.main-content a {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Images: make it obvious they're zoomable (opt-out via class="no-zoom") */
|
||||
.main-content img:not(.no-zoom) {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Image zoom: ensure the zoom overlay sits above the right TOC */
|
||||
.medium-zoom-overlay {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
.medium-zoom-image--opened {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
/* Right-side page TOC (desktop only) */
|
||||
@media (min-width: 66.5rem) {
|
||||
.main-content-wrap.has-page-toc {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 16rem;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
"breadcrumb breadcrumb"
|
||||
"content toc";
|
||||
column-gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Breadcrumbs (when present) always span full width */
|
||||
.main-content-wrap.has-page-toc .breadcrumb-nav {
|
||||
grid-area: breadcrumb;
|
||||
}
|
||||
|
||||
/* Main content always stays in the left column */
|
||||
.main-content-wrap.has-page-toc .main-content {
|
||||
grid-area: content;
|
||||
|
||||
/* Prevent wide tables/code from forcing overlap */
|
||||
min-width: 0;
|
||||
|
||||
/* Force the content to respect the grid column width (no centering/max-width overflow) */
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
justify-self: stretch;
|
||||
|
||||
/* Safety net: if anything still overflows, don't let it render under the TOC */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* TOC always stays in the right column */
|
||||
.page-toc {
|
||||
grid-area: toc;
|
||||
position: sticky;
|
||||
top: 5.5rem;
|
||||
max-height: calc(100vh - 6.5rem);
|
||||
overflow: auto;
|
||||
padding-left: 1rem;
|
||||
border-left: 1px solid #eeebee;
|
||||
font-size: 0.875rem;
|
||||
|
||||
/* Ensure the TOC doesn’t visually blend with overflowing content */
|
||||
background-color: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.page-toc__title {
|
||||
font-weight: 600;
|
||||
color: #27262b;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.page-toc__list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-toc__item {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.page-toc__item--h3 {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.page-toc__link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 0.125rem 0 0.125rem 0.75rem;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.page-toc__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.page-toc__link.is-active {
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
border-left-color: #2563eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<meta name="ffu-right-toc" content="{% if page.right_toc == false %}false{% else %}true{% endif %}">
|
||||
|
||||
<script src="{{ '/assets/js/vendor/medium-zoom.min.js' | relative_url }}" defer></script>
|
||||
<script src="{{ '/assets/js/image-zoom.js' | relative_url }}" defer></script>
|
||||
<script src="{{ '/assets/js/page-toc.js' | relative_url }}" defer></script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- docs/_includes/page_nav.html -->
|
||||
<div class="d-flex flex-justify-between mt-6">
|
||||
{% assign prev_url = include.prev_url | default: page.prev_url %}
|
||||
{% assign prev_label = include.prev_label| default: page.prev_label | default: 'Home' %}
|
||||
{% assign next_url = include.next_url | default: page.next_url %}
|
||||
{% assign next_label = include.next_label| default: page.next_label | default: 'Next' %}
|
||||
|
||||
{% if prev_url %}
|
||||
<a class="btn btn-outline" href="{{ prev_url | relative_url }}">← {{ prev_label }}</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
|
||||
{% if next_url %}
|
||||
<a class="btn btn-blue" href="{{ next_url | relative_url }}">{{ next_label }} →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: Applications
|
||||
nav_order: 4
|
||||
prev_url: /updates.html
|
||||
prev_label: Updates
|
||||
next_url: /winget.html
|
||||
next_label: Install Winget Applications
|
||||
parent: UI Overview
|
||||
has_toc: false
|
||||
---
|
||||
# Applications
|
||||
|
||||

|
||||
|
||||
Applications can be installed in three different ways:
|
||||
|
||||
* Winget (using an AppList.json file)
|
||||
* Bring Your Own Applications (using files you provide - can also be used to run command lines with or without content)
|
||||
* Apps Script Variables (key/value pairs used in conjunction with a PowerShell script to install custom applications)
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: Apps Script Variables
|
||||
nav_order: 7
|
||||
prev_url: /byoapps.html
|
||||
prev_label: BYO Applications
|
||||
next_url: /M365appsoffice.html
|
||||
next_label: M365 Apps Office
|
||||
parent: Applications
|
||||
grand_parent: UI Overview
|
||||
---
|
||||
# Apps Script Variables
|
||||
|
||||

|
||||
|
||||
Apps Script Variables are key value pairs that are used to create a hashtable that is passed to the `BuildFFUVM.ps1` script (stored in the `$AppScriptVariables` parameter as a hashtable). At build time, `BuildFFUVM.ps1` will export the `$AppsScriptVariables` hashtable to an `AppsScriptVariables.json` file in the `$OrchestrationPath` folder (`$AppsPath\Orchestration`). You can also manually create your own `AppsScriptVariables.json `file and place it in the `$AppsPath\Orchestration` folder.
|
||||
|
||||
In the VM, the `Orchestrator.ps1` file will call `Invoke-AppsScript.ps1` if `AppsScriptVariables.json` exists. `Invoke-AppsScript.ps1` must be modified to handle your variables.
|
||||
|
||||
`Invoke-AppsScript.ps1` has the following commented example of how to modify the file:
|
||||
|
||||
```
|
||||
# Example of how to use the AppsScriptVariables hashtable to control script execution
|
||||
|
||||
# Note: The UI saves the values as strings, so if you type true for a value, it'll save to the config file as a string, not boolean
|
||||
|
||||
# Example: Check if a variable named 'foo' is set to string 'bar' and run a script accordingly
|
||||
# if ($AppsScriptVariables['foo'] -eq 'bar') {
|
||||
# Write-Host "Foo would have installed"
|
||||
# }
|
||||
# else {
|
||||
# Write-Host "Foo would not have installed"
|
||||
# }
|
||||
|
||||
# Example: Check if a variable named 'Teams' is set to string 'true' and run a script accordingly
|
||||
# if ($AppsScriptVariables['Teams'] -eq 'true') {
|
||||
# Write-Host "Teams would have been installed"
|
||||
# }
|
||||
# else {
|
||||
# Write-Host "Teams would not have been installed"
|
||||
# }
|
||||
```
|
||||
|
||||
## Why use Apps Script Variables?
|
||||
|
||||
This allows for you to create a dynamic task sequence via a PowerShell script with simple if statements to run apps, commands, etc. This is all driven by your `FFUConfig.json` file. For example, the following `FFUConfig.json` file contains an AppsScriptVariables hashtable of foo and vmwaretools like the screenshot above. If you build servers that require vmware tools, you may set the value to true. However there may be situations where you don't need vmwaretools installed. If that's the case, you set vmwaretools to false. This allows for your `Invoke-AppsScript.ps1` file to stay the same and all you have to do is adjust the variables.
|
||||
|
||||
```
|
||||
{
|
||||
"AdditionalFFUFiles": [],
|
||||
"AllowExternalHardDiskMedia": false,
|
||||
"AllowVHDXCaching": false,
|
||||
"AppListPath": "C:\\FFUDevelopment\\Apps\\AppList.json",
|
||||
"AppsPath": "C:\\FFUDevelopment\\Apps",
|
||||
"AppsScriptVariables": {
|
||||
"foo": "bar",
|
||||
"vmwaretools": "true"
|
||||
},
|
||||
"BuildUSBDrive": false,
|
||||
"CleanupAppsISO": true,
|
||||
"CleanupCaptureISO": true,
|
||||
"CleanupDeployISO": true,
|
||||
"CleanupDrivers": false,
|
||||
"CompactOS": true,
|
||||
"CompressDownloadedDriversToWim": false,
|
||||
"CopyAdditionalFFUFiles": false,
|
||||
"CopyAutopilot": false,
|
||||
"CopyDrivers": false,
|
||||
"CopyOfficeConfigXML": false,
|
||||
"CopyPEDrivers": false,
|
||||
"CopyPPKG": false,
|
||||
"CopyUnattend": false,
|
||||
"CreateCaptureMedia": true,
|
||||
"CreateDeploymentMedia": true,
|
||||
"CustomFFUNameTemplate": "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}",
|
||||
"Disksize": 53687091200,
|
||||
"DownloadDrivers": false,
|
||||
"DriversFolder": "C:\\FFUDevelopment\\Drivers",
|
||||
"DriversJsonPath": "C:\\FFUDevelopment\\Drivers\\Drivers.json",
|
||||
"FFUCaptureLocation": "C:\\FFUDevelopment\\FFU",
|
||||
"FFUDevelopmentPath": "C:\\FFUDevelopment",
|
||||
"FFUPrefix": "_FFU",
|
||||
"InjectUnattend": false,
|
||||
"InstallApps": true,
|
||||
"InstallDrivers": false,
|
||||
"InstallOffice": false,
|
||||
"InstallWingetApps": false,
|
||||
"ISOPath": "",
|
||||
"LogicalSectorSizeBytes": 512,
|
||||
"MaxUSBDrives": 5,
|
||||
"MediaType": "Consumer",
|
||||
"Memory": 4294967296,
|
||||
"OfficeConfigXMLFile": "",
|
||||
"OfficePath": "C:\\FFUDevelopment\\Apps\\Office",
|
||||
"Optimize": true,
|
||||
"OptionalFeatures": "",
|
||||
"OrchestrationPath": "C:\\FFUDevelopment\\Apps\\Orchestration",
|
||||
"PEDriversFolder": "C:\\FFUDevelopment\\PEDrivers",
|
||||
"Processors": 4,
|
||||
"ProductKey": "",
|
||||
"PromptExternalHardDiskMedia": true,
|
||||
"RemoveApps": false,
|
||||
"RemoveFFU": false,
|
||||
"RemoveUpdates": false,
|
||||
"ShareName": "FFUCaptureShare",
|
||||
"Threads": 5,
|
||||
"UpdateADK": true,
|
||||
"UpdateEdge": true,
|
||||
"UpdateLatestCU": true,
|
||||
"UpdateLatestDefender": true,
|
||||
"UpdateLatestMicrocode": false,
|
||||
"UpdateLatestMSRT": true,
|
||||
"UpdateLatestNet": true,
|
||||
"UpdateOneDrive": true,
|
||||
"UpdatePreviewCU": false,
|
||||
"USBDriveList": {},
|
||||
"UseDriversAsPEDrivers": false,
|
||||
"UserAppListPath": "C:\\FFUDevelopment\\Apps\\UserAppList.json",
|
||||
"Username": "ffu_user",
|
||||
"Verbose": false,
|
||||
"VMHostIPAddress": "192.168.1.169",
|
||||
"VMLocation": "C:\\FFUDevelopment\\VM",
|
||||
"VMSwitchName": "External",
|
||||
"WindowsArch": "x64",
|
||||
"WindowsLang": "en-us",
|
||||
"WindowsRelease": 11,
|
||||
"WindowsSKU": "Pro",
|
||||
"WindowsVersion": "25H2"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Example command line to run with vmwaretools set to false and foo set to foo. This will create the `AppsScriptVariables.json` file in the Orchestration folder with the updated values of `foo=foo` and `vmwaretools=false` without the need to modify the config file.
|
||||
|
||||
`.\BuildFFUVM.ps1 -configFile 'C:\FFUDevelopment\config\FFUConfig.json' -appsScriptVariables @{foo='foo'; vmwaretools='false'}`
|
||||
|
||||
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,22 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function InitImageZoom() {
|
||||
if (window.mediumZoom === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.mediumZoom('.main-content img:not(.no-zoom):not([src$=".svg"])', {
|
||||
margin: 24,
|
||||
background: 'rgba(0,0,0,0.80)',
|
||||
scrollOffset: 0
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', InitImageZoom);
|
||||
return;
|
||||
}
|
||||
|
||||
InitImageZoom();
|
||||
})();
|
||||
@@ -0,0 +1,286 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function IsRightTocEnabled() {
|
||||
var meta = document.querySelector('meta[name="ffu-right-toc"]');
|
||||
if (meta && meta.content && meta.content.toLowerCase() === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function IsDesktopViewport() {
|
||||
try {
|
||||
return window.matchMedia && window.matchMedia('(min-width: 66.5rem)').matches;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function GetHeadings(container) {
|
||||
var headings = container.querySelectorAll('h2, h3');
|
||||
var results = [];
|
||||
|
||||
for (var i = 0; i < headings.length; i++) {
|
||||
var heading = headings[i];
|
||||
|
||||
if (heading.classList.contains('no_toc')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = heading.getAttribute('id');
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = (heading.textContent || '').trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
level: heading.tagName.toLowerCase(),
|
||||
id: id,
|
||||
text: text
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function BuildToc(headings) {
|
||||
var nav = document.createElement('nav');
|
||||
nav.className = 'page-toc';
|
||||
nav.setAttribute('aria-label', 'On this page');
|
||||
|
||||
var title = document.createElement('div');
|
||||
title.className = 'page-toc__title';
|
||||
title.textContent = 'In this article';
|
||||
nav.appendChild(title);
|
||||
|
||||
var list = document.createElement('ul');
|
||||
list.className = 'page-toc__list';
|
||||
|
||||
for (var i = 0; i < headings.length; i++) {
|
||||
var item = headings[i];
|
||||
|
||||
var li = document.createElement('li');
|
||||
li.className = 'page-toc__item page-toc__item--' + item.level;
|
||||
|
||||
var a = document.createElement('a');
|
||||
a.className = 'page-toc__link';
|
||||
a.href = '#' + item.id;
|
||||
a.textContent = item.text;
|
||||
|
||||
li.appendChild(a);
|
||||
list.appendChild(li);
|
||||
}
|
||||
|
||||
nav.appendChild(list);
|
||||
return nav;
|
||||
}
|
||||
|
||||
function SetActiveTocLink(toc, activeId) {
|
||||
if (!toc) {
|
||||
return;
|
||||
}
|
||||
|
||||
var links = toc.querySelectorAll('.page-toc__link');
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
var link = links[i];
|
||||
var href = link.getAttribute('href') || '';
|
||||
var isActive = ('#' + activeId) === href;
|
||||
|
||||
if (isActive) {
|
||||
link.classList.add('is-active');
|
||||
|
||||
/* Keep the active item visible inside the TOC panel */
|
||||
try {
|
||||
link.scrollIntoView({ block: 'nearest' });
|
||||
} catch (e) {
|
||||
link.scrollIntoView();
|
||||
}
|
||||
} else {
|
||||
link.classList.remove('is-active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function SetupScrollSpy(main, toc, headings) {
|
||||
if (!main || !toc || !headings || headings.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Scrollspy is desktop-only; on mobile it can cause "fighting" scroll behavior */
|
||||
if (!IsDesktopViewport()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var headingElements = [];
|
||||
for (var i = 0; i < headings.length; i++) {
|
||||
var el = document.getElementById(headings[i].id);
|
||||
if (el) {
|
||||
headingElements.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
if (headingElements.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
var activeId = null;
|
||||
var ticking = false;
|
||||
var lockActiveUntilMs = 0;
|
||||
|
||||
function IsNearBottomOfPage() {
|
||||
var thresholdPx = 24;
|
||||
var scrollY = window.scrollY || window.pageYOffset || 0;
|
||||
var viewportBottom = scrollY + window.innerHeight;
|
||||
var pageHeight = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
|
||||
|
||||
return viewportBottom >= (pageHeight - thresholdPx);
|
||||
}
|
||||
|
||||
function GetCurrentHeadingId() {
|
||||
/* If we're at the bottom, force the last heading active (Learn-like behavior) */
|
||||
if (IsNearBottomOfPage()) {
|
||||
return headingElements[headingElements.length - 1].getAttribute('id');
|
||||
}
|
||||
|
||||
/* Choose the heading closest to the top "activation line" */
|
||||
var activationLine = 16;
|
||||
var current = null;
|
||||
|
||||
for (var i = 0; i < headingElements.length; i++) {
|
||||
var rectTop = headingElements[i].getBoundingClientRect().top;
|
||||
|
||||
if (rectTop <= activationLine) {
|
||||
current = headingElements[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null === current) {
|
||||
current = headingElements[i];
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (null === current) {
|
||||
current = headingElements[0];
|
||||
}
|
||||
|
||||
return current.getAttribute('id');
|
||||
}
|
||||
|
||||
function Update() {
|
||||
ticking = false;
|
||||
|
||||
if (Date.now() < lockActiveUntilMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
var currentId = GetCurrentHeadingId();
|
||||
if (!currentId || currentId === activeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeId = currentId;
|
||||
SetActiveTocLink(toc, activeId);
|
||||
}
|
||||
|
||||
function OnScrollOrResize() {
|
||||
if (ticking) {
|
||||
return;
|
||||
}
|
||||
|
||||
ticking = true;
|
||||
window.requestAnimationFrame(Update);
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', OnScrollOrResize, { passive: true });
|
||||
window.addEventListener('resize', OnScrollOrResize);
|
||||
|
||||
/* Update immediately and also when clicking TOC links */
|
||||
toc.addEventListener('click', function (evt) {
|
||||
var target = evt.target;
|
||||
if (!target || !target.classList || !target.classList.contains('page-toc__link')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var href = target.getAttribute('href') || '';
|
||||
if (href.charAt(0) !== '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
var id = href.substring(1);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Prevent scrollspy from immediately overriding the clicked section */
|
||||
lockActiveUntilMs = Date.now() + 800;
|
||||
|
||||
activeId = id;
|
||||
SetActiveTocLink(toc, activeId);
|
||||
});
|
||||
|
||||
Update();
|
||||
}
|
||||
|
||||
function InitRightToc() {
|
||||
if (!IsRightTocEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
var main = document.querySelector('.main-content main');
|
||||
if (!main) {
|
||||
return;
|
||||
}
|
||||
|
||||
var headings = GetHeadings(main);
|
||||
if (headings.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
var wrap = document.querySelector('.main-content-wrap');
|
||||
var content = document.querySelector('.main-content');
|
||||
if (!wrap || !content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (wrap.querySelector('.page-toc')) {
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.classList.add('has-page-toc');
|
||||
|
||||
var toc = BuildToc(headings);
|
||||
wrap.appendChild(toc);
|
||||
|
||||
SetupScrollSpy(main, toc, headings);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', InitRightToc);
|
||||
return;
|
||||
}
|
||||
|
||||
InitRightToc();
|
||||
})();
|
||||
@@ -0,0 +1,822 @@
|
||||
---
|
||||
title: Build
|
||||
nav_order: 9
|
||||
prev_url: /drivers.html
|
||||
prev_label: Drivers
|
||||
next_url: /monitor.html
|
||||
next_label: Monitor
|
||||
parent: UI Overview
|
||||
---
|
||||
# Build
|
||||
|
||||

|
||||
|
||||
The Build tab is where the magic happens
|
||||
|
||||
## FFU Development Path
|
||||
|
||||
The FFU Development path (`$FFUDevelopmentPath`) is the root path of where most other paths are derived. The default is `$PSScriptRoot`, which is the location the script is currently running from and can be changed to another location from within the UI.
|
||||
|
||||
If you want to download and test new releases, or want to create a new FFUDevelopment folder without modifying your existing one, you can always download the source files and put them in another location.
|
||||
|
||||
The recommendation is to run from `C:\FFUDevelopment` and in most cases the path shouldn't need to be changed.
|
||||
|
||||
## Custom FFU Name Template
|
||||
|
||||
Controls the `-CustomFFUNameTemplate` parameter. This allows you to define a custom naming convention for the captured FFU file using placeholders that are replaced at build time.
|
||||
|
||||
If left blank, the default FFU naming convention is used.
|
||||
|
||||
### Available Placeholders
|
||||
|
||||
| Placeholder | Description | Example |
|
||||
| -------------------- | ---------------------- | ------------------------------------------------------------------------------ |
|
||||
| `{WindowsRelease}` | Windows release number | `10`, `11`, `2016`, `2019`, `2022`, `2025` |
|
||||
| `{WindowsVersion}` | Windows version | `1607`, `1809`, `21h2`, `22h2`, `23h2`, `24h2` |
|
||||
| `{SKU}` | Windows edition | `Home`, `Pro`, `Enterprise`, `Education`, `Standard`, `Datacenter` |
|
||||
| `{BuildDate}` | Month and year | `Nov2025` |
|
||||
| `{yyyy}` | 4-digit year | `2025` |
|
||||
| `{MM}` | 2-digit month | `11` (for November) |
|
||||
| `{dd}` | 2-digit day | `28` |
|
||||
| `{HH}` | Hour in 24-hour format | `14` (for 2 PM) |
|
||||
| `{hh}` | Hour in 12-hour format | `02` (for 2 PM) |
|
||||
| `{mm}` | 2-digit minute | `09` |
|
||||
| `{tt}` | AM/PM designator | `AM` or `PM` |
|
||||
|
||||
### Examples
|
||||
|
||||
**Basic template with date and time:**
|
||||
|
||||
```
|
||||
{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}
|
||||
```
|
||||
|
||||
Result: `Win11_24h2_Pro_2025-11-28_1425.ffu`
|
||||
|
||||
**Template with static text (e.g., indicating Office is installed):**
|
||||
|
||||
```
|
||||
{WindowsRelease}_{WindowsVersion}_{SKU}_Office_{yyyy}-{MM}-{dd}_{HH}{mm}
|
||||
```
|
||||
|
||||
Result: `Win11_24h2_Pro_Office_2025-11-28_1425.ffu`
|
||||
|
||||
**Simple template with build date:**
|
||||
|
||||
```
|
||||
{WindowsRelease}_{WindowsVersion}_{SKU}_{BuildDate}
|
||||
```
|
||||
|
||||
Result: `Win11_24h2_Pro_Nov2025.ffu`
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> The `.ffu` extension is automatically appended if not included in the template.
|
||||
|
||||
## FFU Capture Location
|
||||
|
||||
The FFU Capture Location sets the `-FFUCaptureLocation` parameter that determines where completed `.ffu` images are written. By default it points to `$FFUDevelopmentPath\FFU`, and the build script creates the folder automatically if it does not already exist.
|
||||
|
||||
When apps are installed in a VM, the host converts this folder into a temporary SMB share using the **Share Name** and **Username** fields. The capture WinPE environment maps that share as drive `W:` and streams the captured image directly into this folder. When the build finishes, the share and local account are removed, but the FFU files remain unless a cleanup option deletes them.
|
||||
|
||||
Choose a path on fast storage with plenty of free space—the directory must be local to the host running `BuildFFUVM.ps1`, and large captures can easily exceed 25–30 GB. This location also feeds other options such as **Copy Additional FFU Files**, **Build USB Drive**, and **Remove FFU**, so keeping all finished images here keeps those workflows simple.
|
||||
|
||||
## Share Name
|
||||
|
||||
The Share Name sets the `-ShareName` parameter that defines the name of the temporary SMB share created during the FFU capture process. The default is `FFUCaptureShare`.
|
||||
|
||||
During the build, the host creates an SMB share that points to the **FFU Capture Location** and grants access to the temporary local user account defined in **Username**. The capture WinPE environment maps this share as drive `W:` using `net use` and streams the captured FFU image directly to it.
|
||||
|
||||
When the build completes, the share is automatically removed along with the temporary user account, leaving only the captured FFU files behind in the FFU Capture Location.
|
||||
|
||||
## Username
|
||||
|
||||
The Username field sets the `-Username` parameter that `BuildFFUVM.ps1` uses when creating the temporary SMB share user. The value becomes a local standard user account that is granted Full Control on the **FFU Capture Location** share (default C:\FFUDevelopment\FFU) so the capture WinPE session can copy the FFU over `net use `. The default `ffu_user` account name works for most scenarios, but you can supply any other local account name that meets your organization's policies.
|
||||
|
||||
When the build starts, the script ensures the account exists, rotates its password to a randomly generated GUID, and grants it access to the share. The Capture WinPE environment maps drive `W:` with those credentials, then writes the captured image directly into the FFU Capture Location.
|
||||
|
||||
After the build finishes, the share is removed and the temporary account is deleted, leaving only the FFU files stored in the capture folder.
|
||||
|
||||
## Threads
|
||||
|
||||
Controls the `-Threads` parameter, which sets the number of parallel threads used for concurrent operations throughout FFU Builder. The default value is **5**.
|
||||
|
||||
### Operations Affected by Threads
|
||||
|
||||
The Threads value applies to the following parallel operations:
|
||||
|
||||
| Operation | Description |
|
||||
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| **Winget Application Downloads** | When downloading multiple Winget applications, each application download runs as a parallel task |
|
||||
| **BYO Application Copy** | When copying multiple Bring Your Own (BYO) applications to the Apps folder, each copy operation runs in parallel |
|
||||
| **Driver Downloads** | When downloading drivers for multiple device models, each driver download and extraction runs as a parallel task |
|
||||
|
||||
### Recommended Values
|
||||
|
||||
| Threads | Use Case |
|
||||
| -------------- | ------------------------------------------------------------------------- |
|
||||
| **1** | Minimal system impact; useful for troubleshooting or low-resource systems |
|
||||
| **5** | Default; balanced performance for most systems |
|
||||
| **8-10** | Higher concurrency for systems with fast storage and network connections |
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> Setting a higher thread count may improve download times but will increase resource utilization. If you experience stability issues or resource constraints, try reducing the thread count.
|
||||
|
||||
### Validation
|
||||
|
||||
The UI validates that the Threads value is a valid integer greater than or equal to 1. If an invalid value is entered, it automatically resets to **1**.
|
||||
|
||||
## BITS Priority
|
||||
|
||||
Controls the `-BitsPriority` parameter, which determines the priority level for Background Intelligent Transfer Service (BITS) downloads. The default value is **Normal**.
|
||||
|
||||
If you want faster downloads, change the priority to Foreground. Normal priority will significantly slow down downloads since BITS treats non-Foreground downloads as synchronous and queues each download. This means multiple driver or winget application downloads will go much slower than using Foreground. Normal is default as per Microsoft best practice guidance for using BITS.
|
||||
|
||||
## Build USB Drive
|
||||
|
||||
The following sub-options control how the USB drive is created
|
||||
|
||||
### Allow External Hard Disk Media
|
||||
|
||||
Controls the `-AllowExternalHardDiskMedia` parameter. When checked, allows the use of drives identified as "External hard disk media" via the WMI class `Win32_DiskDrive`. The default is **unchecked**.
|
||||
|
||||
Most USB thumb drives are identified by Windows as "Removable Media" and work with the default settings. However, faster USB drives—such as portable SSDs or high-speed USB 3.x drives—may be identified as "External hard disk media" instead. If you want to use these faster drives for imaging, enable this option.
|
||||
|
||||
{: .warning-title}
|
||||
|
||||
> Warning
|
||||
>
|
||||
> Enabling this option may expose external hard drives attached to your machine to the USB imaging process. To prevent accidental data loss, use the **Prompt for External Hard Disk Media** option (enabled by default when this option is checked) to confirm which drive to use before formatting.
|
||||
|
||||
### Prompt for External Hard Disk Media
|
||||
|
||||
Controls the `-PromptExternalHardDiskMedia` parameter. When checked, prompts for user confirmation before using any drive identified as "External hard disk media". The default is **checked** when **Allow External Hard Disk Media** is enabled.
|
||||
|
||||
This option is only available when **Allow External Hard Disk Media** is checked.
|
||||
|
||||
When enabled, the build process will:
|
||||
|
||||
1. Display a table listing all detected external hard disk media drives, including drive name, serial number, partition style, and status.
|
||||
2. Prompt you to select which drive to use for imaging.
|
||||
3. Only create a USB drive on the selected drive.
|
||||
|
||||
When disabled, the script will not prompt and can use multiple external hard disk drives simultaneously, similar to how removable USB drives function. This is useful for automated or batch imaging scenarios but increases the risk of accidental data loss.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> If you do not want to be prompted each time, you can disable this option after verifying that only your intended imaging drives are connected.
|
||||
|
||||
### Select Specific USB Drives
|
||||
|
||||
When checked, enables manual selection of specific USB drives for imaging. The default is **unchecked**.
|
||||
|
||||
This option is only available when **Build USB Drive** is checked.
|
||||
|
||||
When enabled, a **Check USB drives** button and a list view appear. Click **Check USB drives** to scan for connected USB drives. The list displays all detected drives with the following information:
|
||||
|
||||
| Column | Description |
|
||||
| ------------------- | ----------------------------------------------------- |
|
||||
| **Select** | Checkbox to include or exclude the drive from imaging |
|
||||
| **Model** | The model name of the USB drive |
|
||||
| **Unique ID** | A unique identifier for the drive |
|
||||
| **Size (GB)** | The total capacity of the drive in gigabytes |
|
||||
|
||||
Select one or more drives by checking the checkbox in the **Select** column. Only selected drives will be formatted and used for imaging when the build completes.
|
||||
|
||||
Use the **Select All** checkbox in the column header to quickly select or deselect all drives.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> If **Select Specific USB Drives** is unchecked, the build process will automatically use all discovered USB drives.
|
||||
|
||||
### Copy Autopilot Profile
|
||||
|
||||
Controls the `-CopyAutopilot` parameter. When checked, copies the contents of the `.\FFUDevelopment\Autopilot` folder to the `Autopilot` folder on the Deployment partition of the USB drive. The default is **unchecked**.
|
||||
|
||||
This option is only available when **Build USB Drive** is checked.
|
||||
|
||||
This leverages the Autopilot for existing devices json file. It's not recommended to use this method any longer as devices enrolled via this method are enrolled as personal instead of corporate.
|
||||
|
||||
### Copy Unattend.xml
|
||||
|
||||
Controls the `-CopyUnattend` parameter. When checked, copies the architecture-appropriate unattend XML file from `.\FFUDevelopment\Unattend` to an `Unattend` folder on the Deployment partition of the USB drive. The default is **unchecked**.
|
||||
|
||||
This option is only available when **Build USB Drive** is checked.
|
||||
|
||||
When enabled, the build process copies:
|
||||
|
||||
- **unattend_x64.xml** (for x64 builds) or **unattend_arm64.xml** (for arm64 builds) → renamed to **Unattend.xml** on the USB drive
|
||||
- **prefixes.txt** (if present) → copied alongside the unattend file
|
||||
|
||||
During deployment, `ApplyFFU.ps1` detects the `Unattend` folder and uses these files to customize the device name and apply other Windows settings during OOBE.
|
||||
|
||||
#### 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:
|
||||
|
||||
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.
|
||||
|
||||
#### Prompt for 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.
|
||||
|
||||
#### Device Naming with prefixes.txt
|
||||
|
||||
If a `prefixes.txt` file exists in the `Unattend` folder and there are multiple prefixes in the file, the deployment script prompts the technician to select a prefix from the file. The prefix is combined with the device's serial number to create the computer name. If there is a single prefix, the technician is not prompted and the script will automatically select that prefix.
|
||||
|
||||
For example, with a prefix of `CORP-` and a serial number of `ABC123`, the resulting computer name would be `CORP-ABC123` (truncated to 15 characters if necessary).
|
||||
|
||||
Sample `prefixes.txt` content:
|
||||
|
||||
```plaintext
|
||||
CORP-
|
||||
STORE-
|
||||
KIOSK-
|
||||
```
|
||||
|
||||
{: .warning-title}
|
||||
|
||||
> Warning
|
||||
>
|
||||
> If using a provisioning package or autopilot json file, DO NOT specify a name in either of these. They will overwrite the name you have specified in the unattend.xml.
|
||||
|
||||
#### Creating Your Unattend Files
|
||||
|
||||
The `.\FFUDevelopment\Unattend` folder includes sample files you can customize:
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | ------------------------------------------ |
|
||||
| **SampleUnattend_x64.xml** | Example unattend file for x64 systems |
|
||||
| **unattend_x64.xml** | Active unattend file used for x64 builds |
|
||||
| **unattend_arm64.xml** | Active unattend file used for arm64 builds |
|
||||
| **SamplePrefixes.txt** | Example prefixes file for device naming |
|
||||
|
||||
Copy and customize the sample files to create your own `unattend_x64.xml`, `unattend_arm64.xml`, and `prefixes.txt` files.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> The unattend file must contain a `<ComputerName>` element in the `Microsoft-Windows-Shell-Setup` component for device naming to work. See the sample files for the correct structure.
|
||||
|
||||
### Copy Provisioning Package
|
||||
|
||||
Controls the `-CopyPPKG` parameter. When checked, copies the contents of the `.\FFUDevelopment\PPKG` folder to the `PPKG` folder on the Deployment partition of the USB drive. The default is **unchecked**.
|
||||
|
||||
This option is only available when **Build USB Drive** is checked.
|
||||
|
||||
#### How It Works
|
||||
|
||||
1. **During Build**: The build process copies all `.ppkg` files from `.\FFUDevelopment\PPKG` to the USB drive.
|
||||
2. **During Deployment**: When `ApplyFFU.ps1` runs, it detects the `PPKG` folder and the provisioning packages within it.
|
||||
- If **multiple** `.ppkg` files are found, the technician is prompted to select which package to apply.
|
||||
- If **one** `.ppkg` file is found, it is automatically selected.
|
||||
3. **Application**: The selected provisioning package is copied to the root of the USB drive, where Windows picks it up during OOBE and applies the settings.
|
||||
|
||||
### Copy Additional FFU Files
|
||||
|
||||
Controls the `-CopyAdditionalFFUFiles` parameter. When checked, allows you to select existing FFU files from the FFU Capture Location to copy to the USB drive alongside the newly built FFU. The default is **unchecked**.
|
||||
|
||||
This option is only available when **Build USB Drive** is checked.
|
||||
|
||||
#### How It Works
|
||||
|
||||
When enabled, an **Additional FFU Files** panel appears below the checkbox with the following controls:
|
||||
|
||||
| Control | Description |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| **Refresh** | Scans the FFU Capture Location folder for existing `.ffu` files and populates the list |
|
||||
| **FFU Name** | The filename of the FFU file |
|
||||
| **Last Modified** | The date and time the FFU file was last modified, useful for identifying the most recent builds |
|
||||
|
||||
The list displays all `.ffu` files found in the FFU Capture Location (default `.\FFUDevelopment\FFU`). Click on individual rows to select which FFU files you want to include on the USB drive. Selected files are highlighted in the list.
|
||||
|
||||
#### Use Cases
|
||||
|
||||
- **Multiple device configurations**: Copy different FFU files for different windows/application configurations (e.g., different versions of windows, different application stacks) to a single USB drive, allowing technicians to choose during deployment.
|
||||
- **Previous builds**: Include a known-good FFU from a previous build alongside the new build as a fallback option.
|
||||
- **Multi-architecture imaging**: Include both x64 and arm64 FFU files on the same USB drive for mixed-architecture environments.
|
||||
|
||||
#### Command Line Usage
|
||||
|
||||
When running `BuildFFUVM.ps1` from the command line with `-CopyAdditionalFFUFiles $true` and no `-AdditionalFFUFiles` parameter specified, the script displays an interactive prompt listing all available FFU files in the capture folder. You can:
|
||||
|
||||
- Enter numbers separated by commas (e.g., `1,3,5`) to select specific files
|
||||
- Enter `A` to select all available files
|
||||
- Press **Enter** to skip and not include any additional files
|
||||
|
||||
Example command line usage with pre-selected files:
|
||||
|
||||
```powershell
|
||||
.\BuildFFUVM.ps1 -configFile .\config\FFUConfig.json -CopyAdditionalFFUFiles $true -AdditionalFFUFiles @("C:\FFUDevelopment\FFU\Win11_24h2_Pro_Nov2025.ffu", "C:\FFUDevelopment\FFU\Win11_24h2_Enterprise_Nov2025.ffu")
|
||||
```
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> The newly captured FFU from the current build is always copied to the USB drive. Additional FFU files selected here are copied in addition to the new FFU.
|
||||
|
||||
### Max USB Drives
|
||||
|
||||
Controls the `-MaxUSBDrives` parameter, which sets the maximum number of USB drives to build in parallel. The default value is **5**.
|
||||
|
||||
This option is only available when **Build USB Drive** is checked.
|
||||
|
||||
When building USB drives, the script processes multiple drives concurrently to speed up imaging. This setting controls how many drives are formatted and copied to simultaneously.
|
||||
|
||||
## Compact OS
|
||||
|
||||
Controls the `-CompactOS` parameter. When checked, the Windows image is applied using compressed files. The default is **checked**.
|
||||
|
||||
### How It Works
|
||||
|
||||
When enabled, the build script uses the `-Compact` switch with `Expand-WindowsImage` when applying the Windows image to the OS partition. This compresses Windows system files using Compact OS compression, which reduces the disk footprint of the operating system. On an x64 image, space savings is ~3.5-4GB.
|
||||
|
||||
### Benefits
|
||||
|
||||
| Benefit | Description |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| **Reduced Disk Space** | Windows files are stored in a compressed state, saving several gigabytes of storage |
|
||||
| **Smaller FFU Size** | The captured FFU file is smaller because the OS partition contains compressed files |
|
||||
| **Faster Deployment** | Smaller FFU files transfer more quickly to USB drives and deploy faster to target devices |
|
||||
| **No Performance Impact** | Modern CPUs decompress files faster than they can be read from storage, so performance is maintained |
|
||||
|
||||
### When to Disable
|
||||
|
||||
You may want to disable Compact OS in the following scenarios:
|
||||
|
||||
- **Windows Server builds**: The script automatically disables Compact OS for Windows Server operating systems because the Windows Overlay Filter (wof.sys) is not included in Server SKUs
|
||||
- **Troubleshooting**: If you experience issues with specific applications that are incompatible with compressed files
|
||||
- **Maximum performance requirements**: In rare cases where every CPU cycle matters
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> Compact OS is automatically disabled when building Windows Server images, regardless of this setting. The script detects Server operating systems and applies the Windows image without compression.
|
||||
|
||||
## Update ADK
|
||||
|
||||
Controls the `-UpdateADK` parameter. When checked, the script checks for and installs or updates to the latest Windows ADK and WinPE add-on before starting the build. The default is **checked**.
|
||||
|
||||
### How It Works
|
||||
|
||||
When enabled, the build process performs the following checks before starting:
|
||||
|
||||
1. **Version Check**: Queries the [Microsoft ADK installation page](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install) to determine the latest available ADK version
|
||||
2. **Compare Versions**: Compares the installed ADK and WinPE add-on versions (if present) against the latest available version
|
||||
3. **Update if Needed**: If an older version is detected:
|
||||
- Uninstalls the existing Windows ADK
|
||||
- Uninstalls the existing WinPE add-on
|
||||
- Downloads and installs the latest Windows ADK with Deployment Tools feature
|
||||
- Downloads and installs the latest WinPE add-on
|
||||
|
||||
### Features Installed
|
||||
|
||||
When installing or updating the ADK, the following features are included:
|
||||
|
||||
| Component | Feature ID | Description |
|
||||
| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------- |
|
||||
| **Windows Deployment Tools** | `OptionId.DeploymentTools` | Includes DISM, Oscdimg, and other deployment-related tools |
|
||||
| **WinPE Environment** | `OptionId.WindowsPreinstallationEnvironment` | Windows Preinstallation Environment used for capture and deploy |
|
||||
|
||||
### Installation Location
|
||||
|
||||
The ADK is installed to the default location: `C:\Program Files (x86)\Windows Kits\10`
|
||||
|
||||
### When to Disable
|
||||
|
||||
You may want to disable Update ADK in the following scenarios:
|
||||
|
||||
- **Offline or air-gapped environments**: When internet access is not available to download the latest ADK
|
||||
- **Controlled ADK versions**: When you need to maintain a specific ADK version for compatibility or compliance reasons
|
||||
- **Faster builds**: When you have already verified you are running the latest ADK version and want to skip the version check
|
||||
|
||||
{: .warning-title}
|
||||
|
||||
> Warning
|
||||
>
|
||||
> If Update ADK is disabled and the Windows ADK or WinPE add-on is not installed, the build will fail. Ensure you have manually installed the required components before disabling this option.
|
||||
|
||||
### Manual ADK Installation
|
||||
|
||||
If you prefer to manually install the ADK, visit:
|
||||
|
||||
[Download and install the Windows ADK](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install)
|
||||
|
||||
You must install both:
|
||||
|
||||
- Windows Assessment and Deployment Kit (with Deployment Tools feature)
|
||||
- Windows PE add-on for the Windows ADK
|
||||
|
||||
## Optimize
|
||||
|
||||
Controls the `-Optimize` parameter. When enabled, FFU Builder runs the Windows ADK version of DISM to optimize the captured `.ffu` file:
|
||||
|
||||
- `DISM /Optimize-FFU /ImageFile:<path-to-ffu>`
|
||||
|
||||
This post-processing step typically takes a few minutes and is intended to make FFU images faster to deploy and easier to deploy to differently-sized disks (by allowing the Windows partition to expand or shrink during apply).
|
||||
|
||||
**Default:** Enabled (`-Optimize $true`)
|
||||
|
||||
### When to Disable
|
||||
|
||||
You may want to disable Optimize (`-Optimize $false`) if you are troubleshooting, or if you want to skip the extra post-processing time.
|
||||
|
||||
{: .warning-title}
|
||||
|
||||
> Warning
|
||||
>
|
||||
> If you plan to deploy the same FFU to devices with different storage sizes (especially smaller disks), keep `-Optimize` enabled. Non-optimized FFUs are more likely to require additional partition management during deployment.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> FFU Builder also performs a separate “optimize VHDX before capture” step. That VHDX optimization is independent of `-Optimize`, so you may still see “Optimizing VHDX before capture…” even when `-Optimize` is disabled.
|
||||
|
||||
## Allow VHDX Caching
|
||||
|
||||
Controls the `-AllowVHDXCaching` parameter. When enabled, FFU Builder caches the base VHDX it creates in `$FFUDevelopmentPath\VHDXCache` and writes a matching `*_config.json` file alongside it. On later builds, if a cached VHDX exists that matches your selected Windows settings and update set, the script reuses it to avoid re-applying the base image and integrating updates again.
|
||||
|
||||
**Default:** Disabled (`-AllowVHDXCaching $false`)
|
||||
|
||||
### Cache Matching
|
||||
|
||||
A cached VHDX is reused only when the cache metadata matches your current build inputs, including:
|
||||
|
||||
- Windows release, version, and SKU
|
||||
- Logical sector size (512 vs 4096)
|
||||
- Optional features selection
|
||||
- The exact set of update payload file names downloaded for that run (SSU/CU/.NET/etc.)
|
||||
|
||||
### Disk Usage and Cleanup
|
||||
|
||||
VHDX caching trades disk space for speed. The `VHDXCache` folder can grow over time as you build different combinations. Periodically check the folder and remove old cached vhdx and config json files as necessary.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> To force a full rebuild, delete the contents of `$FFUDevelopmentPath\VHDXCache` (or disable **Allow VHDX Caching**) and run the build again.
|
||||
|
||||
## Create Capture Media
|
||||
|
||||
Controls the `-CreateCaptureMedia` parameter.
|
||||
|
||||
When enabled, FFU Builder creates WinPE capture media that is used during VM-based builds (when apps are installed in the VM). FFU Builder attaches this media to the VM and adjusts boot order so the VM can reboot into WinPE and automatically capture the FFU to your **FFU Capture Location**.
|
||||
|
||||
The capture media uses the parameter values from `VMHostIPAddress`, `ShareName`, `UserName`, and `CustomFFUNameTemplate` and inserts them into `CaptureFFU.ps1` which is what is responsible for capturing the FFU from the guest VM to the Host.
|
||||
|
||||
**Default:** Enabled (`-CreateCaptureMedia $true`)
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> This option is only relevant when **Install Apps** is enabled. If **Install Apps** is enabled, the build forces `-CreateCaptureMedia` to `$true` because capture media is required to capture an FFU from the VM.
|
||||
|
||||
{: .tip-title}
|
||||
|
||||
> Tip
|
||||
>
|
||||
> If you just need to re-create media, you can use the `Create-PEMedia.ps1` script to regenerate the capture or deploy ISO using `Create-PEMedia.ps1 -Capture $true` or `CreatePEMedia.ps1 -Deploy $true`.
|
||||
|
||||
## Create Deployment Media
|
||||
|
||||
Controls the `-CreateDeploymentMedia` parameter.
|
||||
|
||||
When enabled, FFU Builder creates WinPE deployment media that is used to deploy an FFU image to a physical device. This media contains the WinPE environment and deployment scripts needed to boot a target machine and apply the FFU image.
|
||||
|
||||
The deployment media is saved as an ISO file at `$FFUDevelopmentPath\WinPE_FFU_Deploy_x64.iso` (or `WinPE_FFU_Deploy_arm64.iso` for ARM64 builds). This ISO can then be used with the **Build USB Drive** option to create bootable USB media for physical deployments.
|
||||
|
||||
**Default:** Enabled (`-CreateDeploymentMedia $true`)
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> If you only need to capture FFUs from VMs and do not plan to deploy to physical devices, you can disable this option to save time during the build process. However, most scenarios require deployment media for the final step of applying the FFU to target hardware.
|
||||
|
||||
{: .tip-title}
|
||||
|
||||
> Tip
|
||||
>
|
||||
> If you just need to re-create media, you can use the `Create-PEMedia.ps1` script to regenerate the capture or deploy ISO using `Create-PEMedia.ps1 -Capture $true` or `CreatePEMedia.ps1 -Deploy $true`.
|
||||
|
||||
## Inject Unattend.xml
|
||||
|
||||
Controls the `-InjectUnattend` parameter. When checked, copies the architecture-specific unattend XML file from `.\FFUDevelopment\unattend` into the Apps ISO so it's baked into the FFU during the VM build process. The default is **unchecked**.
|
||||
|
||||
This option is only available when **Install Apps** is checked.
|
||||
|
||||
### How It Works
|
||||
|
||||
When enabled, the build process:
|
||||
|
||||
1. Determines the correct unattend file based on the target architecture:
|
||||
* **unattend_x64.xml** for x64 builds
|
||||
* **unattend_arm64.xml** for arm64 builds
|
||||
2. Creates an `Unattend` folder inside `.\FFUDevelopment\Apps` if it doesn't exist
|
||||
3. Copies the architecture-specific unattend file to `.\FFUDevelopment\Apps\Unattend\Unattend.xml`
|
||||
4. Includes the unattend file in the Apps ISO, making it available to sysprep during the VM build
|
||||
|
||||
The unattend file is then used by sysprep during the specialize phase and/or other OOBE phases when the FFU is deployed.
|
||||
|
||||
### Creating Your Unattend Files
|
||||
|
||||
Modify the architecture-specific unattend file in the `.\FFUDevelopment\unattend` folder:
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------- | ----------------------------------- |
|
||||
| **unattend_x64.xml** | Unattend file used for x64 builds |
|
||||
| **unattend_arm64.xml** | Unattend file used for arm64 builds |
|
||||
|
||||
{: .warning-title}
|
||||
|
||||
> Important
|
||||
>
|
||||
> Keep the file names with the architecture suffix (e.g., unattend_x64.xml). The script handles renaming the file to `Unattend.xml` when copying it to the Apps folder.
|
||||
|
||||
### When to Use This Option
|
||||
|
||||
This option is primarily intended for scenarios where:
|
||||
|
||||
* You are **not using the USB drive** to deploy the FFU and use other deployment methods (e.g., network deployment, disk cloning, etc)
|
||||
* You want the unattend configuration **baked directly into the FFU** rather than applied at deployment time
|
||||
|
||||
### Limitations
|
||||
|
||||
| Limitation | Description |
|
||||
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **No prefixes.txt support** | Unlike the**Copy Unattend** option for USB drives, this method does not support `prefixes.txt` for dynamic device naming based on serial numbers |
|
||||
| **Fixed configuration** | The unattend settings are baked into the FFU at build time and cannot be changed at deployment time |
|
||||
| **Requires VM to be built** | This option only works when**Install Apps** is `$true` because the unattend file is included in the Apps ISO |
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> Most users should continue using the **Copy Unattend** option via the USB drive, which provides more flexibility including support for `prefixes.txt` device naming. Use **Inject Unattend.xml** only when you won't be using the USB drive for deployment.
|
||||
|
||||
{: .tip-title}
|
||||
|
||||
> Tip
|
||||
>
|
||||
> If you're using this option, you can disable **Build Deploy ISO** to save time during the build process since the deployment ISO is not needed when you're not using the USB drive method.
|
||||
|
||||
## Verbose
|
||||
|
||||
Controls the `-Verbose` common parameter. When checked, enables detailed verbose output during the build process. The default is **unchecked**.
|
||||
|
||||
In prior builds it was necessary to enable `-verbose` output to track in real-time the build process if you didn't have `cmtrace.exe` or some other log-monitoring tool. With the UI, you can now watch the build in real-time using the monitor tab. Enabling verbose shouldn't be necessary but is available for those who wish to use it.
|
||||
|
||||
# Post-Build Cleanup
|
||||
|
||||
## Cleanup Apps ISO
|
||||
|
||||
Controls the `-CleanupAppsISO` parameter. When checked, the Apps ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
|
||||
|
||||
During the build process, when apps are being installed, the script creates an `Apps.iso` file in the FFU Development Path (e.g., `.\FFUDevelopment\Apps.iso`). This ISO contains the contents of the `.\FFUDevelopment\Apps` folder—including application installers, Office deployment files, and orchestration scripts—and is mounted to the VM during the build to install applications.
|
||||
|
||||
### When to Disable
|
||||
|
||||
You may want to disable Cleanup Apps ISO in the following scenarios:
|
||||
|
||||
* **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the ISO contents
|
||||
* **Multiple builds with same apps**: If you're running consecutive builds with identical app configurations and want to reuse the existing ISO to save time (the script will recreate it if missing)
|
||||
* **Archival purposes**: When you need to retain a copy of the exact Apps ISO used for a specific FFU build
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> The Apps ISO is only created when applications are configured for installation. If no apps are being installed in the FFU, this option has no effect. Keeping this option enabled helps conserve disk space by removing temporary build artifacts.
|
||||
|
||||
## Cleanup Capture ISO
|
||||
|
||||
Controls the `-CleanupCaptureISO` parameter. When checked, the WinPE capture ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
|
||||
|
||||
It's recommended to keep this checked as each new build re-creates the local username account (e.g. `ffu_user`) and its password. If you were to retain the capture ISO from a previous build, it'd be using an old password and the capture would fail.
|
||||
|
||||
## Cleanup Deploy ISO
|
||||
|
||||
Controls the `-CleanupDeployISO` parameter. When checked, the WinPE deployment ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
|
||||
|
||||
During the build process, when **Build Deploy ISO** is enabled, the script creates a `WinPE_FFU_Deploy.iso` file (e.g., `.\FFUDevelopment\WinPE_FFU_Deploy.iso`). This ISO contains a customized Windows PE environment used to deploy captured FFU images to target devices. The deployment ISO is typically copied to a bootable USB drive along with the FFU files for field deployment.
|
||||
|
||||
### When to Disable
|
||||
|
||||
You may want to disable Cleanup Deploy ISO in the following scenarios:
|
||||
|
||||
* **Creating deployment media separately**: When you want to create USB deployment drives at a later time (e.g. using `.\FFUDevelopment\USBImagingToolCreator.ps1`)
|
||||
* **Testing in Hyper-V**: When deploying FFU images to Hyper-V VMs for testing, you can attach the deploy ISO directly to a VM as a DVD drive
|
||||
|
||||
## Cleanup Drivers
|
||||
|
||||
Controls the `-CleanupDrivers` parameter. When checked, the contents of the Drivers folder are automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
|
||||
|
||||
During the build process, when drivers are configured for installation, the script downloads and extracts driver packages into manufacturer-specific subfolders within the Drivers folder (e.g., `.\FFUDevelopment\Drivers\HP`, `.\FFUDevelopment\Drivers\Dell`). These drivers are then injected into the FFU during the build.
|
||||
|
||||
### When to Enable
|
||||
|
||||
You may want to enable Cleanup Drivers in the following scenarios:
|
||||
|
||||
* **Conserving disk space**: Driver packages can be large (several gigabytes per manufacturer), and removing them after a successful build frees up storage
|
||||
* **Ensuring fresh drivers**: When you want each build to download the latest available drivers rather than reusing previously downloaded versions
|
||||
* **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the driver files
|
||||
|
||||
### When to Disable
|
||||
|
||||
You may want to keep Cleanup Drivers disabled in the following scenarios:
|
||||
|
||||
* **Multiple builds with same drivers**: If you're running consecutive builds targeting the same hardware models, keeping drivers avoids re-downloading them each time
|
||||
* **Debugging driver issues**: When troubleshooting driver injection problems and you want to manually inspect the downloaded driver contents
|
||||
* **Offline builds**: When building in an environment with limited or no internet access, retaining drivers allows reuse across builds
|
||||
* **Bring Your Own Drivers:** When you download and bring your own set of drivers from another source and don't want FFU Builder to remove them
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> Only the contents within the Drivers folder are removed—the folder itself is preserved. If no drivers were downloaded during the build, this option has no effect.
|
||||
|
||||
## Remove FFU
|
||||
|
||||
Controls the `-RemoveFFU` parameter. When checked, all FFU files in the FFU Capture Location are automatically deleted after the build completes successfully. The default is **unchecked**.
|
||||
|
||||
During the build process, the captured FFU image is written to the FFU Capture Location (e.g., `.\FFUDevelopment\FFU`). This option removes all `.ffu` files from that folder after the build finishes, including any previously captured FFU files that may exist in the folder.
|
||||
|
||||
### When to Enable
|
||||
|
||||
You may want to enable Remove FFU in the following scenarios:
|
||||
|
||||
* **USB-only workflow**: When you're using **Build USB Drive** to copy the FFU directly to a USB drive and don't need to retain the FFU file on the host machine
|
||||
* **Conserving disk space**: FFU files can be very large depending on what you're installing, and removing them after copying to USB frees up storage
|
||||
* **Automated build pipelines**: When running automated builds where the FFU is immediately transferred to another location (such as a network share or deployment server) and no longer needed locally
|
||||
|
||||
### When to Disable
|
||||
|
||||
You may want to keep Remove FFU disabled in the following scenarios:
|
||||
|
||||
* **Archival purposes**: When you want to retain captured FFU images for future deployments or as a backup
|
||||
* **Multiple USB drives**: When you need to create additional USB deployment drives at a later time
|
||||
* **Testing and validation**: When you want to test the FFU in Hyper-V or other environments before deploying to physical hardware
|
||||
|
||||
{: .warning-title}
|
||||
|
||||
> Warning
|
||||
>
|
||||
> This option removes **all** FFU files in the FFU Capture Location folder, not just the FFU from the current build. If you have previously captured FFU files stored in this folder that you want to keep, do not enable this option or move those files to a different location before building.
|
||||
|
||||
## Remove Apps Folder Content
|
||||
|
||||
Controls the `-RemoveApps` parameter. When checked, application content in the Apps folder is automatically deleted after the FFU has been successfully captured. The default is **un****checked**.
|
||||
|
||||
During the build process, application content accumulates in several subfolders within the Apps folder (e.g., `.\FFUDevelopment\Apps`):
|
||||
|
||||
| Folder | Contents |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `Win32` | Winget source applications and Bring Your Own Apps content copied using the**Copy Apps** button or manually copied |
|
||||
| `MSStore` | Microsoft Store applications downloaded via Winget |
|
||||
| `Office` | Microsoft 365 Apps installer files downloaded by the Office Deployment Tool |
|
||||
|
||||
Additionally, the `WinGetWin32Apps.json` orchestration file in `.\FFUDevelopment\Apps\Orchestration` is removed. This file is automatically regenerated at build time based on downloaded applications.
|
||||
|
||||
When this option is enabled, the cleanup process removes:
|
||||
|
||||
* The entire `Win32` folder and its contents
|
||||
* The entire `MSStore` folder and its contents
|
||||
* The Office download subfolder (`Office\Office`) and the `setup.exe` file within the `Office` folder
|
||||
|
||||
### When to Enable
|
||||
|
||||
You may want to keep Remove Apps Folder Content enabled in the following scenarios:
|
||||
|
||||
* **Conserving disk space**: Downloaded application installers can consume significant storage, and removing them after a successful build frees up space
|
||||
* **Ensuring fresh downloads**: When you want each build to download the latest available application versions rather than reusing previously downloaded content
|
||||
* **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the application files
|
||||
|
||||
### When to Disable
|
||||
|
||||
You may want to disable Remove Apps Folder Content in the following scenarios:
|
||||
|
||||
* **Multiple builds with same apps**: If you're running consecutive builds with identical application configurations, keeping the downloaded content avoids re-downloading applications each time
|
||||
* **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the downloaded content
|
||||
* **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded applications allows reuse across builds
|
||||
* **Preserving Bring Your Own Apps**: When you've manually copied application content into the `Win32` folder and don't want FFU Builder to remove it
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> Only the application content subfolders are removed—the `Apps` folder itself and configuration files such as `AppList.json` and `UserAppList.json` are preserved. If no applications were configured for the build, this option has no effect.
|
||||
|
||||
## Remove Downloaded Update Files
|
||||
|
||||
Controls the `-RemoveUpdates` parameter. When checked, downloaded Windows updates and application update payloads are automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
|
||||
|
||||
During the build process, update files are downloaded to specific locations within the `FFUDevelopment` folder:
|
||||
|
||||
| Folder | Contents |
|
||||
| ----------------- | ---------------------------------------------------------- |
|
||||
| `KB` | Windows Cumulative Updates (CU) and .NET Framework updates |
|
||||
| `Apps\Defender` | Microsoft Defender definition updates |
|
||||
| `Apps\Edge` | Microsoft Edge browser installer |
|
||||
| `Apps\MSRT` | Malicious Software Removal Tool updates |
|
||||
| `Apps\OneDrive` | Microsoft OneDrive installer |
|
||||
|
||||
When this option is enabled, the cleanup process removes the entire `KB` folder and the specific update subfolders within the `Apps` directory.
|
||||
|
||||
### When to Enable
|
||||
|
||||
You may want to keep Remove Downloaded Update Files enabled in the following scenarios:
|
||||
|
||||
* **Conserving disk space**: Windows Cumulative Updates can be several gigabytes in size, and removing them after a successful build frees up significant storage
|
||||
* **Ensuring latest updates**: When you want each build to download the absolute latest available updates rather than potentially reusing older cached versions
|
||||
|
||||
### When to Disable
|
||||
|
||||
You may want to disable Remove Downloaded Update Files in the following scenarios:
|
||||
|
||||
* **Multiple builds**: If you're running consecutive builds, keeping the downloaded updates avoids re-downloading large Cumulative Update files each time
|
||||
* **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded updates allows reuse across builds
|
||||
* **Testing and validation**: When you want to manually inspect the update files that were included in the build
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> Only the update-specific subfolders are removed-the `Apps` folder itself and other application content (unless **Remove Apps Folder Content** is also selected) are preserved.
|
||||
|
||||
## Restore Defaults
|
||||
|
||||
Use this to restore FFU Builder to its default state. When clicked:
|
||||
|
||||
- A confirmation dialog lists what will be removed before anything is deleted.
|
||||
- Generated JSON files are removed (`config\FFUConfig.json`, `Apps\AppList.json`, `Apps\UserAppList.json`, `Drivers\Drivers.json`).
|
||||
- Capture, Deploy, and Apps ISO files are deleted.
|
||||
- Downloaded artifacts are cleared: Apps payloads (Win32, MSStore, Office downloads), update folders under Apps (Defender, Edge, MSRT, OneDrive), driver downloads, and all `.ffu` files in the FFU capture folder.
|
||||
- UI list views (drivers, apps, Winget search results, AppScript variables) are cleared and all controls are reset to their default values.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> VHDX cache and any custom config files in the `FFUDevelopment\config` folder, and `Drivers\DriverMapping.json` will remain. DriverMapping.json is retained because you may have made custom changes to it and we want to retain those.
|
||||
>
|
||||
> If you want to keep any content prior to restoring defaults, copy it out first.
|
||||
|
||||
## Save Config File
|
||||
|
||||
Saves all current UI selections to a JSON file so you can reload the same settings later or run `BuildFFUVM.ps1` from the command line with `-configFile` (e.g. `BuildFFUVM.ps1 -configFile C:\FFUDevelopment\config\FFUConfig.json`)
|
||||
|
||||
### How it works
|
||||
|
||||
- Collects the full UI state (paths, toggles, driver/app selections, build options) into a single JSON.
|
||||
- Defaults the save location to `FFUDevelopmentPath\config` and suggests `FFUConfig.json` as the file name. You can browse and pick a different file name or folder.
|
||||
- Creates the `config` folder if it does not exist and confirms the save when finished.
|
||||
|
||||
## Load Config File
|
||||
|
||||
Loads a previously saved configuration JSON and repopulates the UI.
|
||||
|
||||
### How it works
|
||||
|
||||
- Click **Load Config File** to browse for a JSON file (for example, `FFUDevelopment\config\FFUConfig.json`).
|
||||
- The UI updates with everything from the file: paths, checkboxes, build options, driver/app selections, and USB settings.
|
||||
- Supplemental files referenced in the config (Winget `AppList.json`, BYO `UserAppList.json`, `Drivers.json`) are also imported if they exist. Missing helper files are treated as optional and noted for you.
|
||||
- If the file is empty, unreadable, or invalid JSON, the load is stopped and an error message is shown.
|
||||
|
||||
## Build FFU
|
||||
|
||||
Use **Build FFU** to run `BuildFFUVM.ps1` with the current UI selections.
|
||||
|
||||
### What happens when you click Build FFU
|
||||
|
||||
- The UI gathers all current settings and saves them to `FFUDevelopment\config\FFUConfig.json`, and launches `BuildFFUVM.ps1 -configFile` pointing to that file in a background job. `FFUConfig.json` persists between builds and is read on each opening of `BuildFFUVM_UI.ps1` so you can continue where you left off on each new run.
|
||||
- The window switches to the **Monitor** tab so you can watch progress in real time.
|
||||
- The progress bar shows overall completion
|
||||
- When the job finishes, the button returns to **Build FFU** and the UI is ready for the next run.
|
||||
|
||||
### Cancelling a Build
|
||||
|
||||
The Build FFU button will change to Cancel while a build is running. Cancelling will do the following:
|
||||
|
||||
- The UI stops the background build job and kills any child processes so DISM, downloads, and other tools exit.
|
||||
- The in-progress download is always removed to avoid partial or corrupt content.
|
||||
- You’re prompted to decide whether to remove other items downloaded during this run. Selecting **Yes** removes only this run’s downloads. Any previously downloaded content stays in place.
|
||||
- When cleanup is finished, the Cancel button reverts to Build FFU and a new build can begin
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: Bring Your Own Applications
|
||||
nav_order: 6
|
||||
prev_url: /winget.html
|
||||
prev_label: Install Winget Applications
|
||||
next_url: /appsscriptvariables.html
|
||||
next_label: Apps Script Variables
|
||||
parent: Applications
|
||||
grand_parent: UI Overview
|
||||
---
|
||||
# Bring Your Own Applications
|
||||
|
||||

|
||||
|
||||
Bring Your Own Applications allows you to run any command line you want in the virtual machine to include in your FFU to install an application, run a script, etc. As the name implies, you'll provide the content, command line, arguments, and additional exit codes.
|
||||
|
||||
All applications are stored in the `$AppsPath` parent folder which defaults to `C:\FFUDevelopment\Apps`. Winget source applications and BYO Apps that you select Copy Apps are stored in `$AppsPath\Win32`. MSStore source apps from Winget are stored in `$AppsPath\MSStore`.
|
||||
|
||||
At build time, an `Apps.iso` file is created of the `$AppsPath` folder. This ISO gets mounted to the VM. It shows up in the VM as the `D:\` drive. When creating your command line or arguments, you must make sure to reference `D:\`.
|
||||
|
||||
## Name
|
||||
|
||||
The name of the application. The name is also used when selecting **Copy Apps** to copy apps from a source location to the `$AppsPath\Win32\<Name>` folder (e.g. `C:\FFUDevelopment\Apps\Win32\Google Chrome`)
|
||||
|
||||
## Command Line
|
||||
|
||||
This is the full path to the command line to install the application, script, or to run a command. If the content was included in the `$AppsPath` this should start with `D:\` (e.g. `D:\Win32\Mozilla Firefox\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe`)
|
||||
|
||||
For MSI applications, this should only include msiexec. The rest of the command line will be specified in arguments.
|
||||
|
||||
## Arguments
|
||||
|
||||
These are the command line arguments for the application. Using the Mozilla Firefox example above, the arguments would be `/S /PreventRebootRequired=true`.
|
||||
|
||||
For MSI applications, this will include `/i` and the full-path to the MSI file plus any additional command line parameters (e.g. `/i "D:\Win32\Google Chrome\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi" /quiet /norestart`)
|
||||
|
||||
## Source
|
||||
|
||||
This is an optional parameter. This is the local source to the content. It is used by the Copy Apps button to copy from the source location to the `$AppsPath\Win32\<Name>` folder. If you don't use the **Copy Apps** button, then you must put the conent in the `$AppsPath` folder manually.
|
||||
|
||||
## Additional Exit Codes
|
||||
|
||||
This is an optional parameter. Enter a comma-separated list of additional success exit codes if necessary.
|
||||
|
||||
## Ignore all non-zero exit codes
|
||||
|
||||
If checked, any non-zero exit code will be considered a success.
|
||||
|
||||
## Save UserAppList.json
|
||||
|
||||
When you're done adding your apps, you must save the `UserAppList.json` file to your `$AppsPath` folder. If you click **Copy Apps**, the `UserAppList.json` file is also saved. The `UserAppList.json` is used by the FFU Builder Orchestrator in the VM to know what to install and when based on the priority of the application.
|
||||
|
||||
Below is the `UserAppList.json` of Chrome and Firefox using the example above.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"Priority": 1,
|
||||
"Name": "Google Chrome",
|
||||
"CommandLine": "msiexec",
|
||||
"Arguments": "/i \"D:\\Win32\\Google Chrome\\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi\" /quiet /norestart",
|
||||
"Source": "C:\\temp\\source\\Google Chrome",
|
||||
"AdditionalExitCodes": "",
|
||||
"IgnoreNonZeroExitCodes": false
|
||||
},
|
||||
{
|
||||
"Priority": 2,
|
||||
"Name": "Mozilla Firefox",
|
||||
"CommandLine": "D:\\Win32\\Mozilla Firefox\\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe",
|
||||
"Arguments": "/S /PreventRebootRequired=true",
|
||||
"Source": "C:\\temp\\source\\Mozilla Firefox",
|
||||
"AdditionalExitCodes": "",
|
||||
"IgnoreNonZeroExitCodes": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Import UserAppList.json
|
||||
|
||||
You can import a saved `UserAppList.json`
|
||||
|
||||
## Edit Application
|
||||
|
||||
When you select a single application you can select the **Edit Application** button. This allows you to edit the application information and update the application.
|
||||
|
||||
## Copy Apps
|
||||
|
||||
If the application source is provided, click **Copy Apps** to copy the application content to the `$AppsPath\Win32` folder (e.g. `C:\FFUDevelopment\Apps\Win32\<Name>`). Network shares are supported. When clicking **Copy Apps** the `UserAppList.json` file is automatically created.
|
||||
|
||||
## Remove Selected
|
||||
|
||||
Removes the selected applications from the list view. Click **Save UserAppList.json** to save the application list.
|
||||
|
||||
## Clear List
|
||||
|
||||
The **Clear List** button will clear the list view of what’s currently in it. It will not clear the `UserAppList.json` file if it exists.
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,410 @@
|
||||
---
|
||||
title: Drivers
|
||||
nav_order: 9
|
||||
prev_url: /M365appsoffice.html
|
||||
prev_label: M365 Apps Office
|
||||
next_url: /build.html
|
||||
next_label: Build
|
||||
parent: UI Overview
|
||||
---
|
||||
# Drivers
|
||||
|
||||

|
||||
|
||||
FFU Builder supports adding drivers directly to the FFU file at build time, or adding them as folders on your USB drive which can be serviced offline after the FFU has been applied to your device.
|
||||
|
||||
The UI allows you to download the drivers prior to build and/or create a `Drivers.json` file which can be used to automatically download the drivers at build time. This allows for flexibility in downloading drivers whenever you need them. It supports downloading multiple driver models at once in parallel.
|
||||
|
||||
## Drivers Folder
|
||||
|
||||
This is the location where drivers are downloaded to, or where you'll manually copy drivers to. The default is `.\FFUDevelopment\Drivers`
|
||||
|
||||
## PE Drivers Folder
|
||||
|
||||
Path to the folder containing drivers to be injected into the WinPE deployment media. Default is `.\FFUDevelopment\PEDrivers`.
|
||||
|
||||
## Drivers.json Path
|
||||
|
||||
Path to a JSON file that specifies which drivers to download. Default is `.\FFUDevelopment\Drivers\Drivers.json`
|
||||
|
||||
## Download Drivers
|
||||
|
||||
FFU Builder can download drivers from the following OEMs:
|
||||
|
||||
* Dell
|
||||
* HP
|
||||
* Lenovo
|
||||
* Microsoft
|
||||
|
||||
Clicking the **Download Drivers** exposes a **Make:** drop down which lists the above four OEMs and a **Get Models** button
|
||||
|
||||
Clicking **Get Models** downloads the list of models from the selected OEM.
|
||||
|
||||
The **Model Filter** box allows you to type in a string to filter on the model. The filter should match on any portion of text in the model name.
|
||||
|
||||
The model column lists the model name and the System ID (for Dell and HP) or the Machine Type (for Lenovo) in parenthesis. The SystemID/Machine Type values are required to know exactly which set of drivers to download for your model. There typically is a lot of overlap, and sometimes the drivers for the various SystemID/MachineTypes for the same model might be exactly the same, it's still best to grab the SystemID/MachineType before downloading drivers.
|
||||
|
||||
To get the System ID:
|
||||
|
||||
**HP**
|
||||
|
||||
* BIOS/UEFI: Either under Main or System Information (it's going to be different depending on the model) you're looking for the **System Board ID** and it should be a four-character code.
|
||||
* PowerShell:`(Get-CimInstance -Namespace 'root\WMI' -Class MS_SystemInformation).BaseboardProduct`
|
||||
|
||||
**Dell**
|
||||
|
||||
* BIOS/UEFI: I'm not sure if it's possible to get the System ID from the BIOS/UEFI. I seem to recall in some BIOS screenshots that System SKU is listed in some BIOS/UEFI implementations, but it may not be consistent.
|
||||
* PowerShell: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemSku`
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
[string]$OEMString = Get-WmiObject -Class "Win32_ComputerSystem" | Select-Object -ExpandProperty OEMStringArray
|
||||
$ComputerDetails.FallbackSKU = [regex]::Matches($OEMString, '\[\S*]')[0].Value.TrimStart("[").TrimEnd("]")
|
||||
```
|
||||
|
||||
**Lenovo**
|
||||
|
||||
To find the Machine Type for Lenovo devices, check the bottom/back of the device for the MTM field and capture the first four characters.
|
||||
|
||||
* BIOS/UEFI: Look for MTM and grab the first four characters
|
||||
* PowerShell: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemProductName`
|
||||
|
||||
You can multi-select different models within the same make, or mix and match different makes. The screenshot below shows different Dell, HP, Lenovo, and Microsoft models selected
|
||||
|
||||

|
||||
|
||||
## Save Drivers.json
|
||||
|
||||
After selecting the drivers you want to download, clicking **Save Drivers.json** will prompt you for a location to save the `Drivers.json` file to. The `Drivers.json` file is responsible for telling `BuildFFUVM.ps1` what drivers to download during the build process.
|
||||
|
||||
Below is an example of `Drivers.json`:
|
||||
|
||||
```
|
||||
{
|
||||
"HP": {
|
||||
"Models": [
|
||||
{
|
||||
"Name": "HP EliteBook 865 16 inch G11 Notebook PC",
|
||||
"SystemId": "8d03"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Dell": {
|
||||
"Models": [
|
||||
{
|
||||
"Name": "Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250",
|
||||
"CabUrl": "https://downloads.dell.com/FOLDER13898125M/1/Dell_Pro_Max_Desktops_0D14.cab",
|
||||
"SystemId": "0D14"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Lenovo": {
|
||||
"Models": [
|
||||
{
|
||||
"Name": "Lenovo 300w Yoga Gen 4",
|
||||
"MachineType": "82VN"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Microsoft": {
|
||||
"Models": [
|
||||
{
|
||||
"Name": "Surface Pro for Business (11th Edition)",
|
||||
"Link": "https://www.microsoft.com/download/details.aspx?id=108013"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Import Drivers.json
|
||||
|
||||
Import Drivers.json allows you to import a previously saved Drivers.json file. The models in the Drivers.json file will show up in the list view pre-selected. This will allow you to select additional models and save an updated version of Drivers.json, or to download the selected models by clicking Download Selected.
|
||||
|
||||
## Download Selected
|
||||
|
||||
Download Selected will download the selected models to the Drivers Folder path (default .\FFUDevelopment\Drivers). Drivers will download the the .\FFUDevelopment\Drivers\Make\Model folder. Download select also interacts with the Compress Driver Model Folder to WIM checkbox which will download and compress the drivers to WIM.
|
||||
|
||||
If you've previously downloaded a driver model and want to compress it to a WIM, you can check he Compress Driver Model Folder to WIM checkbox and click Download Selected again. This will skip the download and compress the driver folder to a WIM file.
|
||||
|
||||
Download Selected leverages BITS and the BITS Priority can be controlled by the BITS Priority drop down on the Build tab. If driver downloads via the UI feel slow, change BITS Priority to Foreground to speed them up.
|
||||
|
||||
## Clear List
|
||||
|
||||
Clears the list view of the previous model list
|
||||
|
||||
## Install Drivers to FFU
|
||||
|
||||
Install Drivers to FFU will recursively add the drivers in the FFUDevelopment\Drivers folder to the FFU file.
|
||||
|
||||
It's recommended to only include a single model's drivers in the FFU. This is because dism will add the drivers to the drivers store in the FFU and any additional models that aren't necessary will bloat the drivers store, using up disk space.
|
||||
|
||||
If you're dealing with multiple models, it's recommended to select Copy Drivers to USB drive instead.
|
||||
|
||||
## Copy Drivers to USB drive
|
||||
|
||||
Copy Drivers to USB drive will copy the drivers to the .\Drivers folder on the deploy partition of the USB drive (e.g. D:\Drivers\Make\Model)
|
||||
|
||||
If you're manually copying drivers to the .\FFUDevelopment\Drivers folder, you must copy them to the FFUDevelopment\Drivers\Make\Model folder (e.g. FFUDevelopment\Drivers\Lenovo\Lenovo 300w). Prior releases referenced using just .\FFUDevelopment\Drivers\Model, however for better organization and consistency, the code has been updated to require the make folder.
|
||||
|
||||
## Compress Driver Model Folder to WIM
|
||||
|
||||
Enabling this checkbox compresses the driver model folder to a WIM file after each model finishes downloading (or when an existing model is detected). Every `Drivers\<Make>\<Model>` directory is captured into a single `<Drivers folder>\<Make>\<Model>.wim` using DISM with `Compress:Max`, which dramatically reduces the space required on your USB drive.
|
||||
|
||||
1. Select the models you need, check **Compress Driver Model Folder to WIM**, then click **Download Selected**. Fresh downloads are extracted as usual and immediately compressed into their companion `.wim`.
|
||||
2. If the model already exists, the download phase is skipped and only the compression runs, so you can rebuild the `.wim` whenever you refresh the folder contents.
|
||||
|
||||
By default the extracted folder is deleted after a successful capture so that the `.wim` becomes the canonical artifact. When **Use Drivers Folder as PE Drivers Source** is also checked, the UI keeps the folder in place, writes a `__PreservedForPEDrivers.txt` marker, and lets WinPE driver harvesting reuse the loose INF set.
|
||||
|
||||
Additional guidance:
|
||||
|
||||
- `DriverMapping.json` is updated to reference the `.wim`, so `Copy Drivers to USB drive`, `BuildFFUVM.ps1 -CopyDrivers`, and the WinPE `ApplyFFU.ps1` flow mount the compressed archive automatically.
|
||||
- Watch the Drivers tab status column or `FFUDevelopment_UI.log` for DISM progress and troubleshooting details per model.
|
||||
- Ensure the volume hosting `FFUDevelopment\Drivers` has enough free space for both the source folder and the resulting `.wim`.
|
||||
- Only applies to drivers from Dell, HP, Lenovo, or Microsoft that are specified in the Drivers.json file. It will not compress models you manually copy to the Drivers folder.
|
||||
|
||||
## Copy PE Drivers
|
||||
|
||||
When **Copy PE Drivers** is enabled, drivers will be injected into the WinPE deployment media. This ensures that WinPE has the necessary drivers to recognize hardware components like storage controllers, network adapters, and input devices during FFU deployment.
|
||||
|
||||
By default, drivers are sourced from the **PE Drivers Folder** (default `.\FFUDevelopment\PEDrivers`). You can manually place drivers in this folder, and they will be injected into the WinPE media during the build process.
|
||||
|
||||
### Use Drivers Folder as PE Drivers Source
|
||||
|
||||
When **Copy PE Drivers** is checked, an additional sub-option becomes visible: **Use Drivers Folder as PE Drivers Source** .
|
||||
|
||||
When this option is enabled, the script bypasses the PE Drivers Folder and instead dynamically builds the WinPE driver set from the main **Drivers Folder**. The script scans all available drivers in the Drivers folder, parses their INF files, and copies only the essential driver types needed for WinPE, including:
|
||||
|
||||
* System devices
|
||||
* SCSI, RAID, and NVMe controllers
|
||||
* Keyboards
|
||||
* Mice and other pointing devices
|
||||
* Human Interface Devices (HID) for touch support
|
||||
|
||||
This eliminates the need to maintain a separate, manually curated `PEDrivers` folder and ensures that WinPE has the necessary drivers based on what you've already downloaded for your target devices.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> If the PE Drivers folder already contains content when using this option, it will be cleared before the new driver set is copied in.
|
||||
>
|
||||
> Some drivers may fail to be added during injection, which is expected behavior and can be safely ignored.
|
||||
>
|
||||
> Network adapters are not included when using the drivers folder as PE drivers source, so if you're using WDS or another network-based solution to copy your FFU and you've modified ApplyFFU.ps1, it's best to not use the **Use Drivers Folder as PE Driver Source** option and just copy in your required PE Drivers to the PE Drivers folder.
|
||||
|
||||
## DriverMapping.json
|
||||
|
||||
`DriverMapping.json` is an automatically generated file that maps hardware identifiers (like System IDs or Machine Types) to specific driver packages. This file enables the WinPE deployment script (`ApplyFFU.ps1`) to automatically detect your device hardware and apply the correct drivers without manual intervention.
|
||||
|
||||
### How it gets created
|
||||
|
||||
`DriverMapping.json` is created and updated automatically when you download drivers using the **Download Selected** button on the Drivers tab of the UI, or when drivers are downloaded during the FFU build. Each time you successfully download drivers for a model, the file is updated with the mapping information for that model.
|
||||
|
||||
### Automatic Driver Selection During Deployment
|
||||
|
||||
When you deploy an FFU using the WinPE media, `ApplyFFU.ps1` looks for `DriverMapping.json` on the USB drive at `D:\Drivers\DriverMapping.json` (where D: is your USB deploy partition). If found, the script:
|
||||
|
||||
1. Detects the hardware identifiers of the current device (System ID, Machine Type, etc.)
|
||||
2. Searches `DriverMapping.json` for a matching entry
|
||||
3. Automatically selects and applies the correct driver package
|
||||
4. Falls back to manual driver selection if no match is found
|
||||
|
||||
### Required Fields by Manufacturer
|
||||
|
||||
Each entry in `DriverMapping.json` contains different required fields depending on the manufacturer:
|
||||
|
||||
**All Manufacturers:**
|
||||
|
||||
* **Manufacturer** – The OEM name (e.g., "Dell", "HP", "Lenovo", "Microsoft")
|
||||
* **Model** – The full model name as it appears in the driver download catalog
|
||||
* **DriverPath** – The relative path to the driver folder or WIM file on the USB drive under the Drivers folder (e.g., "Dell\\\Dell Latitude 7490" or "HP\\\HP EliteBook 865 16 inch G11 Notebook PC.wim").
|
||||
|
||||
Relative paths are used since we don't know the drive letter of the USB drive when the `DriverMapping.json` file is created. And since this uses json, the double backslash is intentional since the first slash is an escape character.
|
||||
|
||||
**Dell:**
|
||||
|
||||
* **SystemId** – The System SKU identifier (e.g., "0819", "0D14"). This is the primary matching field used during deployment. To find your Dell System SKU via PowerShell, run: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemSku`
|
||||
|
||||
**HP:**
|
||||
|
||||
* **SystemId** – The System Board ID, a four-character code (e.g., "8d03", "83D2"). This is the primary matching field used during deployment. To find your HP System Board ID via PowerShell, run: `(Get-CimInstance -Namespace 'root\WMI' -Class MS_SystemInformation).BaseboardProduct`
|
||||
|
||||
**Lenovo:**
|
||||
|
||||
* **MachineType** – The first four characters of the MTM (Machine Type Model) field (e.g., "82VN", "21JD"). This is the primary matching field used during deployment. To find your Lenovo Machine Type via PowerShell, run: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemProductName`
|
||||
|
||||
**Microsoft:**
|
||||
|
||||
* No additional fields required beyond Manufacturer, Model, and DriverPath. Matching is performed based on the normalized model name.
|
||||
|
||||
### Example DriverMapping.json
|
||||
|
||||
Below is an example of `DriverMapping.json` with entries for multiple manufacturers:
|
||||
|
||||
```
|
||||
[
|
||||
{
|
||||
"Manufacturer": "Dell",
|
||||
"Model": "Dell Latitude 7490",
|
||||
"DriverPath": "Dell\\Dell Latitude 7490",
|
||||
"SystemId": "0819"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Dell",
|
||||
"Model": "Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250",
|
||||
"DriverPath": "Dell\\Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250.wim",
|
||||
"SystemId": "0D14"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "HP",
|
||||
"Model": "HP EliteBook 865 16 inch G11 Notebook PC",
|
||||
"DriverPath": "HP\\HP EliteBook 865 16 inch G11 Notebook PC.wim",
|
||||
"SystemId": "8D03"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Lenovo",
|
||||
"Model": "Lenovo 300w Yoga Gen 4",
|
||||
"DriverPath": "Lenovo\\Lenovo 300w Yoga Gen 4",
|
||||
"MachineType": "82VN"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Microsoft",
|
||||
"Model": "Surface Pro for Business (11th Edition)",
|
||||
"DriverPath": "Microsoft\\Surface Pro for Business (11th Edition)"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Bring Your Own Drivers
|
||||
|
||||
If you manage models that aren't from Dell, HP, Lenovo or Microsoft, or you want to use different drivers from what FFU Builder downloads, you can copy your own drivers to the `.\FFUDevelopment\Drivers` folder using the `.\FFUDevelopment\Drivers\Make\Model` format, or simply change the Drivers Folder path to the location of your drivers content.
|
||||
|
||||
You can also manually create your own DriverMapping.json file for the following makes/manufacturers
|
||||
|
||||
| Manufacturer | Match Field | WMI Class | Property |
|
||||
| ------------------------------- | ----------- | ------------------------------------------- | ----------------------------------------------- |
|
||||
| **Dell** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `SystemSku` |
|
||||
| **Dell** (fallback) | SystemId | `Win32_ComputerSystem` | `OEMStringArray` (parsed for bracketed value) |
|
||||
| **HP** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
|
||||
| **Lenovo** | MachineType | `Win32_ComputerSystem` | `Model` |
|
||||
| **Microsoft** | Model | `Win32_ComputerSystem` | `Model` |
|
||||
| **Panasonic Corporation** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
|
||||
| **Viglen** | SystemId | `Win32_BaseBoard` | `SKU` |
|
||||
| **AZW** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
|
||||
| **Fujitsu** | SystemId | `Win32_BaseBoard` | `SKU` |
|
||||
| **Getac** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
|
||||
| **Intel** | Model | `Win32_ComputerSystem` | `Model` |
|
||||
| **ByteSpeed** | Model | `Win32_ComputerSystem` | `Model` |
|
||||
| **Other** (default) | Model | `Win32_ComputerSystem` | `Model` |
|
||||
|
||||
**Notes:**
|
||||
|
||||
* Match Field is the name of the field in the `DriverMapping.json` file (e.g. SystemID, MachineType, Model)
|
||||
* SystemId is a catch-all term for a unique identifier, however each manufacturer calls this something different and stores them in different places within WMI
|
||||
* The Dell (fallback) is used for models where the systemSKU isn't available and the OEMStringArray is parsed via Win32_ComputerSystem
|
||||
* The `MS_SystemInformation` class is queried from the `root\WMI` namespace
|
||||
* Unless noted, the other WMI classes use the `root\cimv2` namespace
|
||||
* All identifiers are normalized to uppercase for matching
|
||||
* ByteSpeed systems with "NUC" in the model name are re-mapped to Intel and use `BaseBoardProduct` instead
|
||||
* For manufacturers that aren't listed, the default behavior is to use the `Win32_ComputerSystem` `model` string
|
||||
|
||||
Below is an example `DriverMapping.json` that includes the additional manufacturers. Note that the model and systemID information is made up and is used only as an example to show how to format the file. You'll need to collect the model or system ID from the locations in the table above and include it in your custom `DriverMapping.json` file. Each entry includes both a WIM and drivers folder for each manufacturer. If you want to include driver WIM files for manufacturers other than Dell, HP, Lenovo, or Microsoft, you'll need to manually compress the drivers folder to a WIM file.
|
||||
|
||||
```
|
||||
[
|
||||
{
|
||||
"Manufacturer": "Panasonic",
|
||||
"Model": "Toughbook CF-33",
|
||||
"SystemId": "CF-33LEHAGT1",
|
||||
"DriverPath": "Panasonic\\CF-33.wim"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Panasonic",
|
||||
"Model": "Toughbook FZ-55",
|
||||
"SystemId": "FZ-55DZ0KVM",
|
||||
"DriverPath": "Panasonic\\FZ-55"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Viglen",
|
||||
"Model": "Genie Desktop Pro",
|
||||
"SystemId": "VGN-GDP-2024",
|
||||
"DriverPath": "Viglen\\GeniePro"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Viglen",
|
||||
"Model": "Omnino Mini",
|
||||
"SystemId": "VGN-OMN-M1",
|
||||
"DriverPath": "Viglen\\OmninoMini.wim"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "AZW",
|
||||
"Model": "SER5 Pro",
|
||||
"SystemId": "SER5-5800H",
|
||||
"DriverPath": "AZW\\SER5Pro.wim"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "AZW",
|
||||
"Model": "U59 Mini PC",
|
||||
"SystemId": "U59-N5095",
|
||||
"DriverPath": "AZW\\U59"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Fujitsu",
|
||||
"Model": "LIFEBOOK U9312",
|
||||
"SystemId": "FPCM52921",
|
||||
"DriverPath": "Fujitsu\\LIFEBOOK-U9312"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Fujitsu",
|
||||
"Model": "ESPRIMO D7010",
|
||||
"SystemId": "D3644-A1",
|
||||
"DriverPath": "Fujitsu\\D7010.wim"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Getac",
|
||||
"Model": "F110 G6",
|
||||
"SystemId": "F110G6",
|
||||
"DriverPath": "Getac\\F110G6.wim"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Getac",
|
||||
"Model": "B360 Pro",
|
||||
"SystemId": "B360PRO",
|
||||
"DriverPath": "Getac\\B360Pro"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Intel",
|
||||
"Model": "NUC13ANHi7",
|
||||
"DriverPath": "Intel\\NUC13"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Intel",
|
||||
"Model": "NUC12WSHi5",
|
||||
"DriverPath": "Intel\\NUC12.wim"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "ByteSpeed",
|
||||
"Model": "Tera 2450",
|
||||
"DriverPath": "ByteSpeed\\Tera2450.wim"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "ByteSpeed",
|
||||
"Model": "Celeritas X1",
|
||||
"DriverPath": "ByteSpeed\\CeleritasX1"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "Acer",
|
||||
"Model": "TravelMate P214-53",
|
||||
"DriverPath": "Acer\\TMP214"
|
||||
},
|
||||
{
|
||||
"Manufacturer": "ASUS",
|
||||
"Model": "ExpertBook B5402CVA",
|
||||
"DriverPath": "ASUS\\B5402.wim"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Hyper-V Settings
|
||||
nav_order: 1
|
||||
prev_url: /ui_overview.html
|
||||
prev_label: UI Overview
|
||||
next_url: /windows_settings.html
|
||||
next_label: Windows Settings
|
||||
parent: UI Overview
|
||||
---
|
||||
# Hyper-V Settings
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
## VM Host IP Address
|
||||
|
||||
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)
|
||||
|
||||
Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk. You may want to increase the size if you're installing many apps.
|
||||
|
||||
## Memory (GB)
|
||||
|
||||
Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Default is 4GB.
|
||||
|
||||
## Processors
|
||||
|
||||
Number of virtual processors for the virtual machine. Recommended to use at least 4. Default is 4.
|
||||
|
||||
## VM Location
|
||||
|
||||
Default is `$FFUDevelopmentPath\VM`. This is the location of the VHDX that gets created where Windows will be installed to.
|
||||
|
||||
## VM Name Prefix
|
||||
|
||||
Prefix for the generated VM. Default is _FFU.
|
||||
|
||||
## Logical Sector Size
|
||||
|
||||
Uint32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512.
|
||||
|
||||
There is some error-handling in the script that will call out mismatch issues with logical sector size. Unfortunately you will need to create a new FFU with the correct logical sector size as you can't convert a previously created FFU. Most should be fine with 512, but lower-end devices that used to ship with eMMC drives have now shifted to using UFS.
|
||||
|
||||
{% include page_nav.html %}
|
||||
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: Home
|
||||
nav_order: 0
|
||||
next_url: /prerequisites.html
|
||||
next_label: Prerequisites
|
||||
---
|
||||
# Using Full Flash Update (FFU) files to speed up Windows deployment
|
||||
|
||||
What if you could have a Windows image (Windows 10/11 or Server) that has:
|
||||
|
||||
- The latest Windows cumulative update
|
||||
- The latest .NET cumulative update
|
||||
- The latest Windows Defender Platform and Definition Updates
|
||||
- The latest version of Microsoft Edge
|
||||
- The latest version of OneDrive (Per-Machine)
|
||||
- The latest version of Microsoft 365 Apps/Office
|
||||
- The latest drivers from any of the major OEMs (Dell, HP, Lenovo, Microsoft) (yes, the latest, not some out of date enterprise CAB file from years ago)
|
||||
- Winget support so you can integrate any app available from Winget directly in your image
|
||||
- ARM64 support for the latest Copilot+ PCs
|
||||
- The ability to bring your own drivers and apps if necessary
|
||||
- Custom WinRE support
|
||||
|
||||
And the best part: it takes **less than two minutes to apply the image**, even with all of these updates added to the media. After setting Windows up and going through Autopilot or a provisioning package, total elapsed time ~10 minutes (depending on what Intune or your device management tool is deploying).
|
||||
|
||||
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.
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Prerequisites
|
||||
nav_order: 1
|
||||
prev_url: /
|
||||
prev_label: Home
|
||||
next_url: /quickstart.html
|
||||
next_label: Quick Start
|
||||
---
|
||||
# Prerequisites
|
||||
|
||||
## Recommendations
|
||||
|
||||
If possible, use an unmanaged Windows 11 or Windows Server machine. In some environments we see security software (AV, EDR, Firewall, etc.) get in the way and cause issues with FFU Builder successfully completing a build.
|
||||
|
||||
### Disk space
|
||||
|
||||
FFU Builder creates a 50GB dynamic VHDX disk by default which can be configured larger if that's not big enough. When the latest updated Windows media is installed to the VHDX, the VHDX size by itself will be about 15GB or so. If you service the media with the latest CU, that could grow the VHDX by double (~30GB in this case). If you install Office, additional applications, drivers, etc. the VHDX can get large quick. The FFU capture process will compress the size significantly, but between the VHDX size, the Windows media, the application and driver source content, the captured FFU, WinPE, etc. you can easily have over 100GB of used space.
|
||||
|
||||
So err on the side of having more free disk space. **Recommended to have at least 100GB free disk space**.
|
||||
|
||||
## Enable Hyper-V
|
||||
|
||||
Follow the guide linked below to install Hyper-V on Windows client or Server
|
||||
|
||||
[Install Hyper-V in Windows and Windows Server \| Microsoft Learn](https://learn.microsoft.com/en-us/windows-server/virtualization/hyper-v/get-started/Install-Hyper-V?tabs=gui&pivots=windows)
|
||||
|
||||
## Install PowerShell 7
|
||||
|
||||
PowerShell 7 is required as of releases 2507+ onward.
|
||||
|
||||
[Installing PowerShell on Windows - PowerShell \| Microsoft Learn
|
||||
](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows)
|
||||
|
||||
Recommended to use winget to install
|
||||
|
||||
`winget install --id Microsoft.PowerShell --source winget`
|
||||
|
||||
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)
|
||||
|
||||
**Do not** use the Windows Store version as it has some [known limitations](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows?view=powershell-7.5#known-limitations)
|
||||
|
||||
## Create Hyper-V Switch
|
||||
|
||||
Once Hyper-V has been enabled and you have rebooted, create either an external or internal switch. An external switch is preferred, but an internal switch can be used.
|
||||
|
||||
## Download and Extract the Latest Release
|
||||
|
||||
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`).
|
||||
|
||||
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:
|
||||
|
||||
`dir "C:\FFUDevelopment" -Recurse | Unblock-File`
|
||||
|
||||
Replace `C:\FFUDevelopment` with the path you extracted the files to.
|
||||
|
||||
## 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`
|
||||
|
||||
If all went well, you should see the FFU Builder UI
|
||||
|
||||

|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,276 @@
|
||||
---
|
||||
title: Quick Start
|
||||
nav_order: 2
|
||||
prev_url: /prerequisites.html
|
||||
prev_label: Prerequisites
|
||||
next_url: /ui_overview.html
|
||||
next_label: UI Overview
|
||||
---
|
||||
# Quick Start
|
||||
|
||||
This is the quick start guide to getting started with FFU Builder. If you're new, start here to build your first FFU.
|
||||
|
||||
After following this guide, you will have a USB drive with an FFU that contains the following:
|
||||
|
||||
* Windows 11 25H2 (with your choice of architecture, language, SKU, and media type)
|
||||
* The latest
|
||||
* Windows and .NET Cumulative Updates
|
||||
* Defender definitions, platform updates, and Windows Security Center application update
|
||||
* Microsoft Edge
|
||||
* Microsoft OneDrive
|
||||
* Malicious Software Removal Tool (MSRT)
|
||||
* Microsoft 365 Apps (Current Channel)
|
||||
* Winget Applications (Optional)
|
||||
* Company Portal
|
||||
* 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
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Follow the [prerequisites](/FFU/prerequisites.html) documentation before getting started.
|
||||
|
||||
## Hyper-V Settings
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Windows Settings
|
||||
|
||||
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.
|
||||
|
||||
Change the Windows language to the one of your choosing.
|
||||
|
||||
Change the Windows SKU to either Home or Pro depending on what your physical device(s) shipped with. This is due to how activation works by Windows. If you match the media type and SKU with what the device shipped with, then Windows will activate using the key in the firmware. In most scenarios, selecting Pro and Consumer will be what you want.
|
||||
|
||||
Leave the other settings as is. There should be no need to provide a product key unless you're providing your own Windows ISO.
|
||||
|
||||
## Updates
|
||||
|
||||
Keep the defaults
|
||||
|
||||
## Applications (optional)
|
||||
|
||||
Click the Applications tab
|
||||
|
||||
In this quick start we'll be installing the winget published version of the Company Portal application. This is optional, but it's a common application that IT admins like to install that use Intune to manage their devices. If you want to use something else, feel free to replace Company Portal with another app.
|
||||
|
||||
You can also provide your own applications instead of, or in conjunction with, winget applications, however this guide will keep things simple and opt for using winget.
|
||||
|
||||
Check Install Winget Applications
|
||||
|
||||
Click Check Winget Status
|
||||
|
||||
In the Winget Search box search for Company Portal
|
||||
|
||||
If you see multiple applications, select the msstore source version of the Company Portal application (FFU Builder doesn't allow the winget source application to install due to how the application is packaged). Make sure to click the check box.
|
||||
|
||||
Click the Save AppList.json button
|
||||
|
||||
This will save an AppList.json file in your C:\FFUDevelopment\Apps folder and should look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"name": "Company Portal",
|
||||
"id": "9WZDNCRFJ3PZ",
|
||||
"source": "msstore",
|
||||
"architecture": "NA",
|
||||
"AdditionalExitCodes": "",
|
||||
"IgnoreNonZeroExitCodes": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> The AppList.json file is what controls winget application downloads during the FFU build process. You might also notice that you can download the application by clicking the Download Selected button. That will do a point in time download of the selected applications via winget and will also update the AppList.json file. At build time the BuildFFUVM.ps1 script will check the AppList.json file and will check if the apps exist. If they do, it will skip downloading the applications.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> If your build machine isn't joined to Microsoft Entra, it will prompt you to authenticate twice to download any msstore source application. First for the application, and the second for the license file. This happens for each msstore source application. If this becomes annoying, you can run the FFUBuilder UI on an Entra joined machine (or hybrid joined) and download the apps you need and copy the Apps folder into the Apps folder on your build machine.
|
||||
|
||||
{: .tip-title}
|
||||
|
||||
> Tip
|
||||
>
|
||||
> To download winget applications with the msstore source, [you need one of the following rights on your account: Global Administrator, User Administrator, or License Administrator](https://learn.microsoft.com/en-us/windows/package-manager/winget/download#:~:text=The%20EntraID%20account%20used%20for%20authentication%20to%20generate%20and%20retrieve%20a%20Microsoft%20Store%20packaged%20app%20license%20file%20must%20be%20a%20member%20of%20one%20of%20the%20following%20three%20Azure%20roles%3A%20Global%20Administrator%2C%20User%20Administrator%2C%20or%20License%20Administrator.)
|
||||
|
||||
## M365 Apps/Office
|
||||
|
||||
FFU Builder uses the Office Deployment Tool to download and install Microsoft 365 Apps.
|
||||
|
||||
The default configuration will install the 64-bit current channel version of Office with Word, Excel, Powerpoint. It will download the currently installed language of the operating system (i.e. uses MatchOS for language), which is fine for most people, however if you need to specify the correct language, you'll need to make some modifications to the XML files.
|
||||
|
||||
Check the [M365 Apps/Office UI Overview page](/FFU/M365appsoffice.html) that explains the XMLs used in detail.
|
||||
|
||||
## Drivers
|
||||
|
||||
FFU Builder makes it easy to add drivers from the four major OEMs (Dell, HP, Lenovo, Microsoft). The difference between FFU Builder and other solutions is that FFU Builder tries to use the latest drivers from the OEM, not out-dated driver packages/CABs. This makes things somewhat more complicated to develop, but should make for a better experience where you have the most secure, up to date set of drivers available. It won't handle firmware/BIOS updates as Windows doesn't allow for servicing those types of updates.
|
||||
|
||||
**Bring Your Own Drivers**
|
||||
|
||||
You're also free to bring your own drivers instead of relying on using the drivers provided by FFU Builder. If you prefer the OEM cabs, you can download them and copy them to the `C:\FFUDevelopment\Drivers\<Make>\<Model>` folder where make is the name of the OEM and Model is the name of the model.
|
||||
|
||||
**WinPE Drivers**
|
||||
|
||||
FFU Builder also supports adding PE drivers. You can either copy your PE Drivers to the `C:\FFUDevelopment\PEDrivers` folder, or use the Drivers Folder as the PE drivers source. If you use the Drivers folder as the PE drivers source, the build script will find the appropriate driver class GUIDs for WinPE from the `C:\FFUDevelopment\Drivers` folder and copy them into the `C:\FFUDevelopment\PEDrivers` folder, overwriting what's currently in the PEDrivers folder.
|
||||
|
||||
For the purposes of this quick start, we'll use an HP EliteBook 850 G8 as the example model.
|
||||
|
||||
Click the Drivers tab and click the Download Drivers checkbox.
|
||||
|
||||
In the Make drop down, select HP and click Get Models. This may take 10-30 seconds to download the HP PlatformList.cab file and parse it. Once the file has been downloaded and parsed, the model listview will be populated with all of the HP models. Dell has a similar experience with downloading and parsing its Catalog XML file. Microsoft parses some webpages to generate a SurfaceDriverIndex.json
|
||||
|
||||
In the Model Filter text box, enter 850. This will filter down to all models that contain 850 in the model name. The model name column will list the model name and the system ID in parenthesis. The system ID is important because it's that value that is used at deployment time to automatically select which drivers to install if you've chosen to copy drivers to the USB drive instead of including them in the FFU file.
|
||||
|
||||
Select **HP EliteBook 850 G8 Notebook PC (8846)**
|
||||
|
||||
{: .tip-title}
|
||||
|
||||
> Tip
|
||||
>
|
||||
> If you need to manage multiple models, it's best to select **Copy Drivers to USB drive.** This will keep the FFU file small and allow FFU Builder to automatically select the right drivers at deployment time (based on the system information queried via WMI).
|
||||
>
|
||||
> If you select Install Drivers to FFU, you bloat the driver store with drivers you don't need, creating a larger FFU file and wasting disk space on the physical device. It's best to use this option when building an FFU for a single model.
|
||||
|
||||
To find the SystemID for HP, you can check BIOS/UEFI, or you can use PowerShell:
|
||||
|
||||
```powershell
|
||||
(Get-CimInstance -Namespace 'root\WMI' -Class MS_SystemInformation).BaseboardProduct
|
||||
```
|
||||
|
||||
Check **Copy Drivers to USB drive** (even though we're doing a single model in this quick start, most people will likely be managing many different models. The expectation is that most should be using **Copy Drivers to USB drive** in their workflows)
|
||||
|
||||
Your view should look like this:
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
For this quick start, click **Save Drivers.json** and save the file to the `C:\FFUDevelopment\Drivers` folder.
|
||||
|
||||
## Build
|
||||
|
||||
Click the Build tab
|
||||
|
||||
On the Build tab, click **Build USB Drive.**
|
||||
|
||||
Depending on your USB drive, it might be a removable drive or external hard disk media. If you're using a fast USB SSD, it's likely that Windows treats that drive as an external hard disk. If that's the case, you might need to click **Allow External Hard Disk Media.** If you do, you may also want to uncheck **Prompt for External Hard Disk Media.** This option is in place to prevent external hard disks from automatically being formatted when building the USB drive. If you don't have any external hard disks connected to your build machine, then it's safe to uncheck the **Prompt for External Hard Disk Media** option.
|
||||
|
||||
Another safety measure is **Select Specific USB Drives**. When you check **Select Specific USB Drives** a list view will pop up with a **Check USB drives** button. Clicking the **Check USB Drives** button will show all connected USB drives (removable or external hard disks). Select which drives you want.
|
||||
|
||||
**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:
|
||||
|
||||
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.
|
||||
|
||||
**Prompt for 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.
|
||||
|
||||
**Specifying Multiple Name Prefixes**
|
||||
|
||||
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).
|
||||
|
||||
{: .warning-title}
|
||||
|
||||
> Warning
|
||||
>
|
||||
> If using a provisioning package or autopilot json file, DO NOT specify a name in either of these. They will overwrite the name you have specified in the unattend.xml.
|
||||
|
||||
**Post Build Cleanup**
|
||||
|
||||
Leave the Post Build Cleanup section at the defaults
|
||||
|
||||
Your Build tab should look something like this:
|
||||
|
||||

|
||||
|
||||
Click **Build FFU**
|
||||
|
||||
Depending on your internet speed, speed of your build machine, etc. this will take some time (probably at least 20 minutes). After clicking Build FFU, you'll be automatically moved to the Monitor tab.
|
||||
|
||||
## Monitor
|
||||
|
||||

|
||||
|
||||
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-.**
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> Don't interact with the VM (e.g. don't click into the PowerShell window that's orchestrating the install of the updates, apps, etc). The whole process should be completly automated with no user interaction necessary. If you click into the PowerShell window while it's working, you may get PowerShell into "select" mode. If this happens, the PowerShell window will look like it's "stuck." That's because clicking into a cmd/PowerShell window while something is in process and you're in select mode waits for you to exit select by hitting Enter.
|
||||
|
||||
## Post Build
|
||||
|
||||
Once the build is complete, you should have a USB drive with two partitions: Boot and Deploy.
|
||||
|
||||
The Boot partition is the WinPE Deployment media. It'll look like this:
|
||||
|
||||

|
||||
|
||||
The Deploy partition is where the FFU file, the Drivers folder, and Unattend folders should be.
|
||||
|
||||

|
||||
|
||||
The Drivers folder will have an HP folder, a DriverMapping.json and Drivers.json file.
|
||||
|
||||

|
||||
|
||||
The DriverMapping.json file is what's used to do automatic driver matching during deployment. FFU Builder will read this file and match on the SystemID to know which driver folder to apply. Since there's only a single driver folder in this example, the file is fairly simple to understand. When you have multiple models and OEMs, the file gets a bit more complex.
|
||||
|
||||
The DriverMapping.json should look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"Manufacturer": "HP",
|
||||
"Model": "HP EliteBook 850 G8 Notebook PC (8846)",
|
||||
"DriverPath": "HP\\HP EliteBook 850 G8 Notebook PC (8846)",
|
||||
"SystemId": "8846"
|
||||
}
|
||||
```
|
||||
|
||||
The HP EliteBook 850 driver folder should be populated with the various driver categories:
|
||||
|
||||

|
||||
|
||||
And the Unattend folder should have an unattend.xml file with the following content:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||
<settings pass="specialize">
|
||||
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
|
||||
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<ComputerName>MyComputer</ComputerName>
|
||||
</component>
|
||||
<!--Place addtional Components Elements and settings below here. -->
|
||||
</settings>
|
||||
</unattend>
|
||||
```
|
||||
|
||||
Now you're ready to deploy the FFU to your device.
|
||||
|
||||
## Deployment
|
||||
|
||||
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.
|
||||
|
||||
If you have any questions or run into any issues, [open a discussion in the Github repo](https://github.com/rbalsleyMSFT/FFU/discussions).
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: UI Overview
|
||||
nav_order: 3
|
||||
prev_url: /prerequisites.html
|
||||
prev_label: Prerequisites
|
||||
next_url: /hyperv_settings.html
|
||||
next_label: Hyper-V Settings
|
||||
has_toc: false
|
||||
---
|
||||
# UI Overview
|
||||
|
||||

|
||||
|
||||
The user interface has 9 distinct tabs for easy navigation.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: Updates
|
||||
nav_order: 3
|
||||
prev_url: /windows_settings.html
|
||||
prev_label: Windows Settings
|
||||
next_url: /applications.html
|
||||
next_label: Applications
|
||||
parent: UI Overview
|
||||
---
|
||||
# Updates
|
||||
|
||||

|
||||
|
||||
## Update Latest Cumulative Update
|
||||
|
||||
Controls the `-UpdateLatestCU` parameter. When set to `$true`, will download and install the latest cumulative update for Windows.
|
||||
|
||||
## Update .NET
|
||||
|
||||
Controls the `-UpdateLatestNet` parameter. When set to `$true`, will download and install the latest .NET framework update for Windows.
|
||||
|
||||
## Update Defender
|
||||
|
||||
Controls the `-UpdateLatestDefender` parameter. When set to `$true`, will download and install the latest Windows Defender definitions, Defender platform, and Windows Security app update.
|
||||
|
||||
## Update Edge
|
||||
|
||||
Controls the `-UpdateEdge` parameter. When set to `$true`, will download and install the latest Microsoft Edge browser.
|
||||
|
||||
## Update OneDrive (per-machine)
|
||||
|
||||
Controls the `-UpdateOneDrive` parameter. When set to `$true`, will download and install the latest OneDrive and install it as per-machine instead of per-user.
|
||||
|
||||
## Update Microsoft Software Removal Tool (MSRT)
|
||||
|
||||
Controls the `-UpdateLatestMSRT` parameter. When set to `$true`, will download and install the latest Windows Malicious Software Removal Tool.
|
||||
|
||||
## Update Latest Microcode (for LTSC/Server 2016/2019)
|
||||
|
||||
Controls the `-UpdateLatestMicrocode` parameter. 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.
|
||||
|
||||
## Update Preview Cumulative Update
|
||||
|
||||
Controls the `-UpdatePreviewCU` parameter. When set to `$true`, will download and install the latest preview cumulative update for Windows.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> The UI will only allow one of **Update Latest CU** or **Update Preview CU** to be checked to prevent both being applied.
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: Windows Settings
|
||||
nav_order: 2
|
||||
prev_url: /hyperv_settings.html
|
||||
prev_label: Hyper-V Settings
|
||||
next_url: /updates.html
|
||||
next_label: Updates
|
||||
parent: UI Overview
|
||||
---
|
||||
# Windows Settings
|
||||
|
||||

|
||||
|
||||
## Windows ISO Path
|
||||
|
||||
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.
|
||||
|
||||
{: .tip-title}
|
||||
|
||||
> Tip
|
||||
>
|
||||
> 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.
|
||||
|
||||
## Windows Release
|
||||
|
||||
Integer value of 10 or 11. This is used to identify which release of Windows to download. Default is 11.
|
||||
|
||||
## Windows Version
|
||||
|
||||
String value of the Windows version. Default is `25H2`. If an ISO is not specified, this drop down is disabled. If an ISO is specified, you can select from the drop down which version of Windows you're installing. The Windows version is used in quite a few different scenarios (HP driver downloads, VHDXCaching, MCT media downloads, FFU file naming, and cumulative update downloads), so it's important you specify the correct Windows Version.
|
||||
|
||||
## Windows Architecture
|
||||
|
||||
String value of `x86`, `x64`, or `arm64`. Depending on the Windows release and Windows version, the UI will only specify the supported architecture types for a specific Release/Version combo.
|
||||
|
||||
## 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`.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> These are the supported languages that can be used with the `-WindowsLang` parameter when downloading the Windows MCT\ESD file
|
||||
>
|
||||
> ar-sa
|
||||
> bg-bg
|
||||
> cs-cz
|
||||
> da-dk
|
||||
> de-de
|
||||
> el-gr
|
||||
> en-gb
|
||||
> en-us
|
||||
> es-es
|
||||
> es-mx
|
||||
> et-ee
|
||||
> fi-fi
|
||||
> fr-ca
|
||||
> fr-fr
|
||||
> he-il
|
||||
> hr-hr
|
||||
> hu-hu
|
||||
> it-it
|
||||
> ja-jp
|
||||
> ko-kr
|
||||
> lt-lt
|
||||
> lv-lv
|
||||
> nb-no
|
||||
> nl-nl
|
||||
> pl-pl
|
||||
> pt-br
|
||||
> pt-pt
|
||||
> ro-ro
|
||||
> ru-ru
|
||||
> sk-sk
|
||||
> sl-si
|
||||
> sr-latn-rs
|
||||
> sv-se
|
||||
> th-th
|
||||
> tr-tr
|
||||
> uk-ua
|
||||
> zh-cn
|
||||
> zh-tw
|
||||
|
||||
## Windows SKU
|
||||
|
||||
Edition of Windows 10/11/LTSC/Server to be installed.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> The following SKUs are supported
|
||||
>
|
||||
> 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)
|
||||
|
||||
## Media Type
|
||||
|
||||
String value of either `business` or `consumer`. This is used to identify which media type to download if not providing an ISO. Default is `consumer`
|
||||
|
||||
{: .tip-title}
|
||||
|
||||
> Tip
|
||||
>
|
||||
> Recommendation on media type to use
|
||||
>
|
||||
> Windows media comes in two types: business or consumer.
|
||||
>
|
||||
> Windows media can be obtained from a few different sources: Volume Licensing Service Center (VLSC – now available at admin.microsoft.com), Microsoft Visual Studio Downloads, or the Windows Media Creation Tool.
|
||||
>
|
||||
> The `BuildFFUVM.ps1` script will allow you to pass whichever type of media you want from whatever source you want using the -ISOPath parameter; however, **it’s recommended that you use consumer media**, not business/VL. This is because Subscription Activation will fail if the media is mismatched from the key in the firmware.
|
||||
>
|
||||
> If you plan on using a MAK or KMS to activate, you can use media from VLSC, but if you expect the device to activate automatically and upgrade to Enterprise or Education SKUs via Subscription Activation, you must use consumer media. To use a MAK/KMS key to activate, you must provide the -`ProductKey XXXXX-XXXXX-XXXXX-XXXXX-XXXXX` parameter.
|
||||
|
||||
## 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.
|
||||
|
||||
## Optional Features
|
||||
|
||||
A list of optional features that you can enable for the version of Windows you're installing (e.g. netfx3; TFTP).
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: Install Winget Applications
|
||||
nav_order: 5
|
||||
prev_url: /applications.html
|
||||
prev_label: Applications
|
||||
next_url: /byoapps.html
|
||||
next_label: BYO Applications
|
||||
parent: Applications
|
||||
grand_parent: UI Overview
|
||||
---
|
||||
# Install Winget Applications
|
||||
|
||||

|
||||
|
||||
## Check Winget Status
|
||||
|
||||
Installing Winget applications requires that both the winget CLI and Microsoft.Winget.Client PowerShell module to be installed. Minimum required version of both the CLI and PowerShell module is 1.8.1911.
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
You can also change the architecture, add additional exit codes, or ignore exit codes completely.
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
## Additional Exit Codes
|
||||
|
||||
You can provide a comma separated list of additional exit codes if your application doesn't exit with 0. Some apps may exit with a non-zero exit code.
|
||||
|
||||
## Ignore Exit Codes
|
||||
|
||||
If you know your application exits with some random exit code or simply don't care to populate a list of approved exit codes, check the box to ignore exit codes and FFU Builder will ignore the exit code and continue on.
|
||||
|
||||
## Download Status
|
||||
|
||||
FFU Builder allows you to download applications prior to deployment. When clicking the Download Selected button, the Download Status column tracks the status of the download and outputs success, or in the case of an error, the reason why the download may have failed.
|
||||
|
||||
## Save AppList.json
|
||||
|
||||
FFU Builder leverages a number of json files to tell the `BuildFFUVM.ps1` script what to do during deployment time. `AppList.json` controls the Winget application download and installation.
|
||||
|
||||
The `AppList.json` file gets created when clicking **Download Selected**, or clicking the **Save AppList.json** file. The default path for the `AppList.json` file is `$AppsPath\AppList.json`
|
||||
|
||||
An example of the `AppList.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"name": "Windows App",
|
||||
"id": "Microsoft.WindowsApp",
|
||||
"source": "winget",
|
||||
"architecture": "x64",
|
||||
"AdditionalExitCodes": "",
|
||||
"IgnoreNonZeroExitCodes": false
|
||||
},
|
||||
{
|
||||
"name": "VLC media player",
|
||||
"id": "VideoLAN.VLC",
|
||||
"source": "winget",
|
||||
"architecture": "x64",
|
||||
"AdditionalExitCodes": "",
|
||||
"IgnoreNonZeroExitCodes": false
|
||||
},
|
||||
{
|
||||
"name": "Snagit 2025",
|
||||
"id": "TechSmith.Snagit.2025",
|
||||
"source": "winget",
|
||||
"architecture": "x64",
|
||||
"AdditionalExitCodes": "",
|
||||
"IgnoreNonZeroExitCodes": false
|
||||
},
|
||||
{
|
||||
"name": "Company Portal",
|
||||
"id": "9WZDNCRFJ3PZ",
|
||||
"source": "msstore",
|
||||
"architecture": "NA",
|
||||
"AdditionalExitCodes": "",
|
||||
"IgnoreNonZeroExitCodes": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Import AppList.json
|
||||
|
||||
If you have a previously saved `AppList.json` you want to use or modify, you can import an `AppList.json` file.
|
||||
|
||||
## Download Selected
|
||||
|
||||
As mentioned in the Download Selected section above, FFU Builder allows you to download applications prior to deployment. When clicking the Download Selected button, the Download Status column tracks the status of the download and outputs success, or in the case of an error, the reason why the download may have failed. By default it will download five applications at a time. This is controlled by the
|
||||
|
||||
Apps are downloaded to `.\FFUDevelopment\Apps\Win32\<AppName>` or `.\FFUDevelopment\Apps\MSStore\<AppName>` depending on the winget source value. Each application will have the app installation files and a yaml manifest file. For Win32 applications, FFU Builder parses the yaml file to grab the silent install switches needed for silent application installation at build time.
|
||||
|
||||
{: .tip-title}
|
||||
|
||||
> Tip
|
||||
>
|
||||
> When downloading msstore source applications, Microsoft requires applications to be downloaded with a license file (the Winget PowerShell module doesn't allow the option to skip downloading the license like the winget CLI does). This requires authentication via Entra ID. If using a device joined to Entra and signed in with your Entra ID, SSO will bypass the need to re-authenticate to download the app and license file. If the machine you are running FFU Builder on is not joined to Entra ID, you will be prompted twice to download the application and the license file.
|
||||
>
|
||||
> It's recommended that if you are downloading a lot of msstore source applications, do it from a machine that's joined to Entra ID.
|
||||
|
||||
## Clear List
|
||||
|
||||
The Clear List button will clear the list view of what's currently in it. It will not clear the AppList.json file if it exists.
|
||||
|
||||
{% include page_nav.html %}
|
||||