Compare commits
456 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| e1aac7ba9d | |||
| 2e7ab9a052 | |||
| 0a9de96d03 | |||
| 50c61dd328 | |||
| 39a919bada | |||
| 5600b2fbbd | |||
| 1d26781dc1 | |||
| f29e3c4349 | |||
| bd8d0efd66 | |||
| 5616082275 | |||
| 8100df3d24 | |||
| a5c38fd09b | |||
| 88ef8f70a1 | |||
| 74fd71161b | |||
| 8aa79dd134 | |||
| e4e499e796 | |||
| f6c3c0b6c3 | |||
| 60ac2e4af0 | |||
| ddf9c1f986 | |||
| ab58b27a1d | |||
| 191c30dd65 | |||
| 1a444d8e0f | |||
| f7f52903a4 | |||
| a9afba9185 | |||
| ecf3794f92 | |||
| c0bcfd8bf2 | |||
| 98babb3ad2 | |||
| ab0b7f67ec | |||
| 6d85e3ef62 | |||
| a91a417a08 | |||
| 67cc8c1225 | |||
| 3f0377fbf9 | |||
| 325413de13 | |||
| 174c16ecb6 | |||
| 146c1601bd | |||
| 8c897e93fe | |||
| 2423814cc2 | |||
| c39c30c970 | |||
| 95a4664b26 | |||
| 0e6d65bf2f | |||
| 0010c8ad81 | |||
| d16acce0ab | |||
| dd20eceb55 | |||
| c30aa90e8b | |||
| bd4e3a1913 | |||
| 7a0dd3435c | |||
| df33e89e37 | |||
| 4af808c939 | |||
| 205b58aaa7 | |||
| 1729eaddd6 | |||
| cafff0b484 | |||
| 5c77b171f1 | |||
| 36f5350f12 | |||
| 45a2c0c29d | |||
| 6cfe41f963 | |||
| 3d13774ee4 | |||
| 21c5fa931f | |||
| 7214b1b8c2 | |||
| ef24b59a0c | |||
| 910918b421 | |||
| 30685dbced | |||
| 7c8e09d4e8 | |||
| e04ed8cf1c | |||
| c8362d972b | |||
| 32ae035d49 | |||
| d74f8451d5 | |||
| 8ee7cf022c | |||
| f931a75636 | |||
| 63617897f3 | |||
| f10433d575 | |||
| fd951ea52d | |||
| 6eca29a506 | |||
| 181eed8f12 | |||
| 08e354ad17 | |||
| 21ebbdf9c9 | |||
| e8ba334732 | |||
| ff46c10d79 | |||
| 4d9e1c1f88 | |||
| d5b81bc482 | |||
| 8f81e69159 | |||
| 4932777f4f | |||
| 12edabf213 | |||
| 1978736133 | |||
| a3faa89ada | |||
| fc8648eb65 | |||
| 49a9fd49c1 | |||
| 3f4836b478 | |||
| 1921809c30 | |||
| 56f3e9d856 | |||
| ae59183a19 | |||
| bfa1ea7d9f | |||
| 5564473c3b | |||
| 393de977f2 | |||
| c051963ed5 |
@@ -0,0 +1,557 @@
|
|||||||
|
# Change Log
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
|
||||||
|
* Initial ARM64 support
|
||||||
|
* Winget support
|
||||||
|
|
||||||
|
### ARM64 Support
|
||||||
|
|
||||||
|
To support the newly released Copilot+ PCs, we now support the creation and deployment of FFUs created with ARM64 media. There are some caveats to this:
|
||||||
|
|
||||||
|
* The -WindowsArch parameter must be set to ARM64 (by default this parameter is set to x64)
|
||||||
|
* If you do not pass -ISOPath with a path to the ARM64 ISO, it will download an ARM64 ESD file from the Media Creation Tool (which is about 7-8 months old now). ARM64 ISOs are available via VLSC, but are not available via Visual Studio Downloads (Yet - unknown if they will ever be made available).
|
||||||
|
* 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 - 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.
|
||||||
|
|
||||||
|
### Winget Support
|
||||||
|
|
||||||
|
Big thanks to [Zehadi Alam](https://github.com/zehadialam) for his contributions to get this added to the project. You can now add any application in the msstore or winget source via the Winget command line utility. In the 1.8 Winget release the ability to download apps from the msstore source was added, which means being able to download apps like the Company Portal. For those of you that have been asking for Company Portal to be inbox in Windows, this is the next best thing. The script will check if Winget 1.8 is installed and if not, it'll install it.
|
||||||
|
|
||||||
|
The way this works is if `-InstallApps $true` and the FFUDevelopment\Apps\AppList.json file exists, whatever apps defined in that json file will be downloaded via Winget and will be installed in the FFU VM prior to capture. We've included two files: AppList_InboxAppsSample.json and AppList_Sample.json. The AppList_InboxAppsSample.json contains all of the apps that are installed in Windows by default and are searchable via `winget search AppID` . Some of these apps do not download and we're investigating why they come up via search, but fail to download. The AppList_Sample.json has Company Portal and New Teams.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
In sticking with the idea of having the most up to date Windows build, inbox store/UWP apps are notoriously out of date and use a lot of bandwidth. By updating all of the UWP apps, bandwidth reductions of ~70% can be achieved.
|
||||||
|
|
||||||
|
| | Total Data usage before updating store apps | Total Data usage after updating store apps | Total Data usage after updating Windows Update |
|
||||||
|
| --------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------ | ---------------------------------------------- |
|
||||||
|
| July 2024 Windows 11 23H2 Stock ISO Captured as FFU (7.5GB FFU) | 261MB | 1.82GB | 2.09GB |
|
||||||
|
| July 2024 Windows 11 23H2 Updated FFU (10.5GB) | 13MB | 558MB | 646MB |
|
||||||
|
|
||||||
|
Updated means latest .NET, Defender (definition and platform updates), Edge, OneDrive, and all updates available via Winget for Store Apps have been provisioned in the FFU. The numbers in the table are cumulative, meaning the FFU was laid down, store apps were updated via running Get Apps from the Microsoft Store app and data usage was gathered from Settings, then Windows Update was manually kicked off via Settings and data usage was gathered.
|
||||||
|
|
||||||
|
In order to get apps to help build your AppList.json file, just run `winget search "AppName"`
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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)
|
||||||
|
* [MKellyCBSD](https://github.com/MKellyCBSD) submitted a PR for a stand-alone USBImagingToolCreator.ps1 script which will create USB drives separate from the main BuildFUVM.ps1 script. This is helpful if you have technicans that need to build USB drives, or would like to make concurrent USB drives at the same time instead of one at a time. [His PR has all the details.](https://github.com/rbalsleyMSFT/FFU/pull/36)
|
||||||
|
* The WinPE_FFU_Deploy.iso will now work on VMs. This made ARM64 testing a lot easier :) If you're looking to test your FFU on a VM, you'll want to build a new VHDX and add your FFU to it and boot from the WinPE_FFU_Deploy.iso. Make sure to eject the VHDX before adding/booting the new VM. When attaching the new VHDX with your FFU on it, make sure it's not the first SCSI device (it should be 1 or 2, most likely 2 as 0 should be the hard drive you want to install Windows to, and 1 will be the DVD drive). By default the WinPE_FFU_Deploy.iso file is removed after the script completes. Make sure to set `-CreateDeploymentMedia $true` and `-CleanupDeployISO $false` so the ISO remains in the FFUDevelopment folder after the script completes.
|
||||||
|
|
||||||
|
The below screenshot should help in understanding what the SCSI config should look like.
|
||||||
|
|
||||||
|

|
||||||
|
* Cleaned up some old commented code from the ApplyFFU.ps1 file and other files.
|
||||||
|
|
||||||
|
## **2406.1**
|
||||||
|
|
||||||
|
This is a major release that includes the ability to download drivers from the 4 major OEMs (Microsoft, Dell, HP, Lenovo) by simply passing the -Make and -Model parameters to the command line.
|
||||||
|
|
||||||
|
For Dell, HP, and Lenovo, the script leverages a similar process to their corresponding tools that automate driver downloads (Dell SupportAssist, HP Image Assistant, Lenovo System Update/Update Retriever). For Microsoft Surface, it scrapes the Surface Downloads page for the appropriate MSI file to download. Using this method, the drivers that are downloaded will be the latest provided by the OEM, unlike other tools that download out of date enteprise CAB files that are made for ConfigMgr.
|
||||||
|
|
||||||
|
The script supports lookups using the -model parameter. For example, if you want to download the drivers for a Surface Laptop Go 3, but don't know the exact model name, you could set -Make 'Microsoft' -Model 'Laptop Go' and it'll give you a list of Surface devices to pick from. If you know the exact name, it'll use that and not prompt.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The goal here is to make it easy to discover the drivers you want to download without having to know the exact model names.
|
||||||
|
|
||||||
|
There are likely going to be bugs with this, but in my testing things seem to work well for the makes and models that I've tried. If you notice something, please fill out an issue in the repro and I'll take a look. If you want to fix whatever issue you're running into, submit a pull request.
|
||||||
|
|
||||||
|
### New parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| -------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Make | String | Used for automatically downloading drivers. Valid values are 'Microsoft', 'Dell', 'HP', 'Lenovo'. The script will throw an error if any other string value is used. |
|
||||||
|
| Model | String | Used for automatically downloading drivers with the Make parameter. |
|
||||||
|
| DriversFolder | String | Location where Drivers will either be downloaded, and/or the location of the drivers you wish to be added to the FFU, or copied to the deploy partition of the USB drive. The default location is $FFUDevelopmentPath\Drivers (e.g. C:\FFUDevelopmentPath\Drivers |
|
||||||
|
| CleanupDrivers | Bool | Used to delete the drivers folders underneath the `$DriversFolder` path (e.g. C:\FFUDevelopmentPath\Drivers\HP) after the FFU has been built. Default is `$true`true |
|
||||||
|
| UserAgent | String | The useragent string is used when invoking Invoke-Webrequest or Invoke-RestMethod. This has been helpful when interacting with the Microsoft Download Center and preventing intermittent errors. Default is Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0 |
|
||||||
|
| Headers | Hashtable | This hash table is used in conjunction with the Useragent when invoking Invoke-Webrequest or Invoke-RestMethod. This has been helpful when interacting with the Microsoft Download Center and preventing intermittent errors. If interested in the default value, reference the script itself. |
|
||||||
|
|
||||||
|
### New Functions
|
||||||
|
|
||||||
|
### `Test-URL`
|
||||||
|
|
||||||
|
Simple function that accepts \$URL parameter to test if a URL is accessible.
|
||||||
|
|
||||||
|
### `Start-BitsTransferWithRetry`
|
||||||
|
|
||||||
|
This is simply Start-BITSTransfer with some retry logic and setting `$VerbosePreference` and `$ProgressPreference` to SilentlyContinue. The retry logic was needed due to certain driver files randomly failing to download. The function is hardcoded to retry 3 times before failing and will wait 5 seconds between each retry attempt.
|
||||||
|
|
||||||
|
### `Get-MicrosoftDrivers`
|
||||||
|
|
||||||
|
For Microsoft Surface, the driver files are hosted on the Microsoft download center. The script will scrape and parse the [Download Surface Drivers and Firmware](https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120) page to get the latest list of Surface devices.
|
||||||
|
|
||||||
|
This function accepts -Make, -Model, and -WindowsRelease parameters. Make and Model are both string parameters and WindowsRelease is an integer parameter. If the model parameter doesn't contain an exact match of a known Surface model, it'll give you a list of Surface models to pick from.
|
||||||
|
|
||||||
|
The following command line says that we want to download the drivers for a Microsoft Laptop Go for Windows 10.
|
||||||
|
|
||||||
|
`.\BuildFFUVM.ps1 -make 'Microsoft' -model 'Laptop Go' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'external' -VMHostIPAddress '192.168.1.158' -CreateDeploymentMedia $true -BuildUSBDrive $true -UpdateLatestCU $true -UpdateLatestNet $true -UpdateLatestDefender $true -UpdateEdge $true -UpdateOneDrive $true -verbose -RemoveFFU $true -WindowsRelease 10`
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
If you want to build an FFU for Surface Laptop Go 3, enter 18 and it'll download the MSI and extract the drivers to the .\FFUDevelopment\Drivers\Microsoft\Surface Laptop Go 3 folder.
|
||||||
|
|
||||||
|
If you would have provided the exact model string instead of just Laptop Go (e.g. -Model 'Surface Laptop Go 3'), the script wouldn't prompt you to enter a valid model.
|
||||||
|
|
||||||
|
### `Get-HPDrivers`
|
||||||
|
|
||||||
|
For HP, the script uses the same process as the HP Image Assistant tool to automate the downloading of drivers. This function accepts the -Make, -Model, -WindowsArch, -WindowsRelease, and -WindowsVersion parameters. HP is the only vendor that uses -WindowsVersion (e.g. 23h2) for its drivers. This is because their XML files contain the -WindowsVersion value in the file name. By default, the script uses 23h2 for the -WindowsVersion parameter. You can override that for whatever -WindowsVersion you wish to use.
|
||||||
|
|
||||||
|
The following command line says that we want to download HP x360 drivers for Windows 10 version 22h2.
|
||||||
|
|
||||||
|
`.\BuildFFUVM.ps1 -make 'HP' -model 'x360' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'external' -VMHostIPAddress '192.168.1.158' -CreateDeploymentMedia $true -CreateCaptureMedia $true -BuildUSBDrive $true -UpdateLatestCU $true -UpdateLatestNet $true -UpdateLatestDefender $true -UpdateEdge $true -UpdateOneDrive $true -RemoveFFU $true -WindowsRelease 10 -WindowsVersion '22h2' -Verbose`
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
HP has 40 models that contain the string x360 in the model name. I want to select the HP ProBook x360 11 G7 Education Edition Notebook PC which is number 25. The below screenshot shows the output of selecting the HP ProBook x360 11 G7 Education Edition Notebook PC
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
If you were to enter the exact model name (e.g. -model 'HP ProBook x360 11 G7 Education Edition Notebook PC'), the script wouldn't prompt you to select from a list of models.
|
||||||
|
|
||||||
|
### `Get-LenovoDrivers`
|
||||||
|
|
||||||
|
For Lenovo, the script uses the same process Lenovo System Update/Update Retriever use. It uses the Get-LenovoDrivers function which accepts -Model, -WindowsArch, -WindowsRelease parameters.
|
||||||
|
|
||||||
|
Lenovo as a company doesn't use model like other companies do. Lenovo prefers to use a Machine Type value instead of Model number. The Machine Type value can be found on the bottom of your device as the first four characters of the MTM: value. Since most people don't know what the machine type value is, when passing the -model parameter, you can pass either the machine type or the "friendly" model number.
|
||||||
|
|
||||||
|
The following command line says that we want to download Lenovo 500w drivers for Windows 10.
|
||||||
|
|
||||||
|
`.\BuildFFUVM.ps1 -make 'Lenovo' -model '500w' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'external' -VMHostIPAddress '192.168.1.158' -CreateDeploymentMedia $true -BuildUSBDrive $true -UpdateLatestCU $true -UpdateLatestNet $true -UpdateLatestDefender $true -UpdateEdge $true -UpdateOneDrive $true -RemoveFFU $true -WindowsRelease 10 -Verbose`
|
||||||
|
|
||||||
|
The script will go out to the [Lenovo PSREF](https://psref.lenovo.com/search) page to figure out the Machine Type value and if multiple Machine Types are found (there are usually multiples found for different configuration types).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The Machine Type is the value in parenthesis. On the bottom of my device, the MTM value is MTM:**82VR**ZAKXXX. I would want to pick number 4 from the list since it includes (82VR). The below screenshot shows the script downloading the appropriate drivers for a Lenovo 500w.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
If you use the Machine Type value for the -Model parameter (e.g. -model '82VR') the script will automatically download the drivers without prompting you to select the model.
|
||||||
|
|
||||||
|
### `Get-DellDrivers`
|
||||||
|
|
||||||
|
For Dell, the script uses the [Dell CatalogPC Cab file](http://downloads.dell.com/catalog/CatalogPC.cab) which is used in Dell Support Assist and possibly other Dell tools to download drivers. The cab consists of an XML file that the script parses to search for drivers applicable for the model you wish to create a FFU for.
|
||||||
|
|
||||||
|
The script calls the Get-DellDrivers function which accepts the -Model and -WindowsArch parameters.
|
||||||
|
|
||||||
|
Unlike Microsoft Surface drivers, Dell doesn't give a list to pick from when the -model parameter isn't an exact match. This is due to how the CatalogPC XML file lists drivers. It treats the driver as the primary element and lists what models that driver can be installed on.
|
||||||
|
|
||||||
|
The following command line says that we want to download Dell 3190 drivers for Windows 10.
|
||||||
|
|
||||||
|
`.\BuildFFUVM.ps1 -make 'Dell' -model '3190' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'external' -VMHostIPAddress '192.168.1.158' -CreateDeploymentMedia $true -CreateCaptureMedia $true -BuildUSBDrive $true -UpdateLatestCU $true -UpdateLatestNet $true -UpdateLatestDefender $true -UpdateEdge $true -UpdateOneDrive $true -RemoveFFU $true -WindowsRelease 10 -Verbose`
|
||||||
|
|
||||||
|
The script will find every driver that is tagged with 3190 and download the latest available version. It strips out any firmware or other non-driver file types. You may notice that it will download multiple video or audio drivers. This is due to each model having variants with different video cards or other hardware. This would make the FFU a bit larger, but not excessively so.
|
||||||
|
|
||||||
|
Below is a screenshot of what the verbose output of the script looks like when downloading the drivers for a Dell 3190.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
* Added -Headers \$Headers -UserAgent \$UserAgent to most Invoke-Webrequest or Invoke-RestMethod commands to solve for intermittent download failures when downloading drivers or Office
|
||||||
|
* Fixed some minor logging issues
|
||||||
|
* Updated the BuildDeployFFU.docx with new driver information and cleaned up some sections that were out of date
|
||||||
|
* Added Changelog.md to keep track of changes and not clutter up the readme.md
|
||||||
|
|
||||||
|
## **2405.1**
|
||||||
|
|
||||||
|
- Moved the resetbase command from within the VM to after servicing the VHDX. This will make it so the FFU size is smaller after the latest CU or .NET framework are installed. (Thanks to Mike Kelly for the PR [Commit](https://github.com/rbalsleyMSFT/FFU/pull/24))
|
||||||
|
- Some additional FFU size reduction enhancements (Thanks Zehadi Alam [Commit](https://github.com/rbalsleyMSFT/FFU/pull/25)):
|
||||||
|
- Disk cleanup is now run before sysprep to help reduce FFU file size
|
||||||
|
- Before FFU capture, Optimize-FFU is run to defrag and slabconsolidate the VHDX
|
||||||
|
|
||||||
|
## **2404.3**
|
||||||
|
|
||||||
|
- Fixed an issue where the latest Windows CU wasn't downloading properly [Commit](https://github.com/rbalsleyMSFT/FFU/commit/ae59183a199f39b310c79b31c9b4980fafdeb79b)
|
||||||
|
|
||||||
|
## **2404.2**
|
||||||
|
|
||||||
|
- If setting `-installdrivers to $true` and `-logicalsectorsizebytes to 4096`, the script will now set `$copyDrivers to $true`. This will create a drivers folder on the deploy partition of the USB drive with the drivers that were supposed to be added to the FFU. There's currently a bug with servicing FFUs with 4096 logical sector byte sizes. Prior to this fix, the script would tell the user to manually set `-copydrivers to $true` as workaround. This fix just does the workaround automatically.
|
||||||
|
|
||||||
|
## **2404.1**
|
||||||
|
|
||||||
|
There's a big change with this release related to the ADK. The ADK will now be automatically updated to the latest ADK release. This is required in order to fix an issue with optimized FFUs not applying due to an issue with DISM/FFUProvider.dll. The FFUProvider.dll fix was added to the Sept 2023 ADK. Since we now have the ability to auto upgrade the ADK, I'm more confident in having the BuildFFUVM script creating a complete FFU now (prior it was only creating 3 partitions instead of 4 with the recovery partition - at deployment time, the ApplyFFU.ps1 script would create an empty recovery partition and Windows would populate it on first boot). Please open an issue if this creates a problem for you. I do realize that any new ADK release can have it's own challenges and issues and I do suspect we'll see a new ADK released later this year.
|
||||||
|
|
||||||
|
- Allow for ISOs with single index WIMs to work [Issue 10](https://github.com/rbalsleyMSFT/FFU/issues/10) - [Commit](https://github.com/rbalsleyMSFT/FFU/commit/9e2da741d53652e6e600ca19cfd38f507bd01fde)
|
||||||
|
- Added more robust ADK handling. Will now check for the latest ADK and download it if not installed. Thanks to [Zehadi Alam](https://github.com/zehadialam) [PR 18](https://github.com/rbalsleyMSFT/FFU/pull/18)
|
||||||
|
- Revert code back to allow optimized FFUs to be applied via ApplyFFU.ps1 now that Sept 2023 ADK release has FFUProvider.dll fix. [Commit](https://github.com/rbalsleyMSFT/FFU/commit/79364e334d6d09ff150e70dab7bfb2637d0ad8a8)
|
||||||
|
- Changed how the script searches for the latest CU. Instead of relying on the Windows release info page to grab the KB number, will just use the MU Catalog, the same as what we do for the .NET Framework. Windows release info page is updated manually and is unknown as to when it will be updated. [Commit](https://github.com/rbalsleyMSFT/FFU/commit/6fd5a4a41fd9ce2f842f43dc3a69bda264c29fa6)
|
||||||
|
- Added fix to not allow computer names with spaces. Thanks to [JoeMama54 (Rob)](https://github.com/JoeMama54) [PR 20](https://github.com/rbalsleyMSFT/FFU/pull/20)
|
||||||
|
|
||||||
|
## **2403.1**
|
||||||
|
|
||||||
|
Fixed an issue with the SecurityHealthSetup.exe file giving an error when building the VM if -UpdateLatestDefender was set to $true. A new update for this came out on 3/21 which included a x64 and ARM64 binary. This file doesn't have an architecture designation to it, so it's impossible to know which file is for which architecture. Investigating to see if we can fix this in the Microsoft Update catalog. There is a web site to pull this from, but the support article is out of date.
|
||||||
|
|
||||||
|
Included ADK functions from Zehadi Alam [Introduce Automated ADK Retrieval and Installation Functions #14](https://github.com/rbalsleyMSFT/FFU/pull/14) to automate the installation of the ADK if it's not present. Thanks, Zehadi!
|
||||||
|
|
||||||
|
## **2402.1**
|
||||||
|
|
||||||
|
**New functionality**
|
||||||
|
|
||||||
|
* If -BuildUSBDrve $true, script will now check for USB drive before continuing. If not present, script exits
|
||||||
|
* Added a number of new parameters.
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| -------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| CopyPEDrivers | Bool | When set to\$true, will copy the drivers from the \$FFUDevelopmentPath\PEDrivers folder to the WinPE deployment media. Default is \$false. |
|
||||||
|
| RemoveFFU | Bool | When set to\$true, will remove the FFU file from the\$FFUDevelopmentPath\FFU folder after it has been copied to the USB drive. Default is \$false. |
|
||||||
|
| UpdateLatestCU | Bool | When set to\$true, will download and install the latest cumulative update for Windows 10/11. Default is \$false. |
|
||||||
|
| UpdateLatestNet | Bool | When set to\$true, will download and install the latest .NET Framework for Windows 10/11. Default is \$false. |
|
||||||
|
| UpdateLatestDefender | Bool | When set to\$true, will download and install the latest Windows Defender definitions and Defender platform update. Default is \$false. |
|
||||||
|
| UpdateEdge | Bool | When set to\$true, will download and install the latest Microsoft Edge for Windows 10/11. Default is \$false. |
|
||||||
|
| UpdateOneDrive | Bool | 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. |
|
||||||
|
| CopyPPKG | Bool | When set to\$true, will copy the provisioning package from the \$FFUDevelopmentPath\PPKG folder to the Deployment partition of the USB drive. Default is \$false. |
|
||||||
|
| CopyUnattend | Bool | When set to\$true, will copy the \$FFUDevelopmentPath\Unattend folder to the Deployment partition of the USB drive. Default is \$false. |
|
||||||
|
| CopyAutopilot | Bool | When set to\$true, will copy the \$FFUDevelopmentPath\Autopilot folder to the Deployment partition of the USB drive. Default is \$false. |
|
||||||
|
| CompactOS | Bool | When set to\$true, will compact the OS when building the FFU. Default is \$true. |
|
||||||
|
| CleanupCaptureISO | Bool | When set to\$true, will remove the WinPE capture ISO after the FFU has been captured. Default is \$true. |
|
||||||
|
| CleanupDeployISO | Bool | When set to\$true, will remove the WinPE deployment ISO after the FFU has been captured. Default is \$true. |
|
||||||
|
| CleanupAppsISO | Bool | When set to\$true, will remove the Apps ISO after the FFU has been captured. Default is \$true. |
|
||||||
|
|
||||||
|
* Updated the docs with the new variables and made some minor modifications.
|
||||||
|
* Changed version variable to 2402.1
|
||||||
|
|
||||||
|
## **2401.1**
|
||||||
|
|
||||||
|
- Added -CopyDrivers boolean parameter to control the ability to copy drivers to the USB drive in the deploy partition drivers folder.
|
||||||
|
- Changed version varaible to 2401.1
|
||||||
|
- When creating the scratch VHDX, switched it to create a dynamic VHDX instead of fixed
|
||||||
|
- Fixed an issue where adding drivers to the FFU would sometimes fail and would cause the script to exit unexpectedly
|
||||||
|
- Added -optimize boolean parameter to control whether the FFU is optimized or not. This defaults to $true and in most cases should be left this way.
|
||||||
|
- Fixed an issue where if the script failed to create the FFU and the old VM was left behind, it wouldn't clean it up if the VM was in the running state. Will now turn off any running VM with a name prefix of _FFU- and then remove any VMs with a name _FFU- if the environment is flagged as dirty.
|
||||||
|
- Fixed an issue where devices that ship with UFS drives were unable to image due to the script setting a LogicalSectorSizeBytes value of 512. If you're creating a FFU for devices that have UFS drives, you'll need to set -LogicalSectorSizeBytes 4096.
|
||||||
|
- There's a known issue where adding drivers to a FFU that has a LogicalSectorSizeBytes value of 4096. Added some code to prevent allowing this to happen. Please use -copydrivers $true as a workaround for now. We're investigating whether this is a bug or not.
|
||||||
|
- Fixed an issue where VHDX only captures (i.e. where -installapps $false) would not install Windows updates.
|
||||||
|
- Changed Office deployment to use Current channel instead of Monthly enterprise. If you want to change to Monthly Enterprise channel, it's recommended to leverage Intune.
|
||||||
|
|
||||||
|
## **2309.2**
|
||||||
|
|
||||||
|
New Features
|
||||||
|
|
||||||
|
**Multiple USB Drive Support**
|
||||||
|
|
||||||
|
You can now plug in multiple USB drives (even using a USB hub) to create multiple USB drives for deployment. This is great for partners or customers who need to provide USB drives to their employees to image a large number of devices. It will copy the content to one USB drive at a time. The most USB drives we've seen created so far is 23 via a USB hub. Open an issue if you see any problems with this.
|
||||||
|
|
||||||
|
**Robocopy support**
|
||||||
|
|
||||||
|
Replaced Copy-Item with Robocopy when copying content to the USB drive(s). Copy-Item uses buffered IO, which can take a long time to copy large files. Robocopy with the /J switch allows for unbuffered IO support, which reduces the amount of time to copy.
|
||||||
|
|
||||||
|
**Better error handling**
|
||||||
|
|
||||||
|
Prior to 2309.2, if the script failed or you manually killed the script (ctrl+c, or closing the PowerShell window), the environment would end up in a bad state and you had to do a number of things to manually clean up the environment. Added a new function called Get-FFUEnvironment and a new text file called dirty.txt that gets created in the FFUDevelopment folder. When the script starts, it checks for the dirty.txt file and if it sees it, Get-FFUEnvironment runs and cleans out a number of things to help ensure the next run will complete successfully. Open an issue if you still see problems when the script fails and the next run of the script fails.
|
||||||
|
|
||||||
|
Bug Fixes
|
||||||
|
|
||||||
|
- In 2309.1, added a 15 second sleep to allow for the registry to unload to fix a Critical Process Died error on deployment. In this build, increased that to 60 seconds.
|
||||||
|
- Fixed an issue where the script was incorrectly detecting the USB drive boot and deploy drive letters which caused issues when attempting to copy the WinPE files to the boot partition.
|
||||||
|
|
||||||
|
## **2309.1**
|
||||||
|
|
||||||
|
- Fixed an issue with a Critical Process Died BSOD that would happen when using -installapps $false. More detailed information in the [commit](https://github.com/rbalsleyMSFT/FFU/pull/2/commits/34efbda7ec56dc7cb43ac42b058725d56c8b8899)
|
||||||
|
|
||||||
|
## **2306.1.2**
|
||||||
|
|
||||||
|
- Fixed an issue where manually entering a name wouldn't name the computer as expected
|
||||||
|
|
||||||
|
## **2306.1.1**
|
||||||
|
|
||||||
|
- Included some better error handling if defining optionalfeatures that require source folders (netfx3). ESD files don't have source folders like ISO media, which means installing .net 3.5 as an optional feature would fail. Also cleaned up some formatting.
|
||||||
|
|
||||||
|
## **2306.1**
|
||||||
|
|
||||||
|
- Added support to automatically download the latest Windows 10 or 11 media via the media creation tool (thanks to [Michael](https://oofhours.com/2022/09/14/want-your-own-windows-11-21h2-arm64-isos/) for the idea). This also allows for different architecture, language, and media type support. If you omit the -ISOPath, the script will download the Windows 11 x64 English (US) consumer media.
|
||||||
|
|
||||||
|
An example command to download Windows 11 Pro x64 English (US) consumer media with Office and install drivers (it won't download drivers, you'll put those in your c:\FFUDevelopment\Drivers folder)
|
||||||
|
|
||||||
|
.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
|
||||||
|
|
||||||
|
An example command to download Windows 11 Pro x64 French (CA) consumer media with Office and install drivers
|
||||||
|
|
||||||
|
.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -WindowsRelease 11 -WindowsArch 'x64' -WindowsLang 'fr-ca' -MediaType 'consumer' -verbose
|
||||||
|
- Changed default size of System/EFI partition to 260MB from 256MB to accomodate 4Kn drives. 4Kn support needs more testing. I'm not confident yet that this can be done with VMs and FFUs.
|
||||||
|
- Added versioning with a new version parameter. Using YYMM as the format followed by a point release.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "Windows Notepad",
|
||||||
|
"id": "9MSMLRH6LZF3",
|
||||||
|
"source": "msstore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Windows Client Web Experience",
|
||||||
|
"id": "9MSSGKG348SP",
|
||||||
|
"source": "msstore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Screen Sketch",
|
||||||
|
"id": "9MZ95KL8MR0L",
|
||||||
|
"source": "msstore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Windows Terminal",
|
||||||
|
"id": "9N0DX20HK701",
|
||||||
|
"source": "msstore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VP9 Video Extensions",
|
||||||
|
"id": "9N4D0MSMP0PT",
|
||||||
|
"source": "msstore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "Your Phone",
|
||||||
|
"id": "9NMPJ99VJBWV",
|
||||||
|
"source": "msstore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "Windows Sound Recorder",
|
||||||
|
"id": "9WZDNCRFHWKN",
|
||||||
|
"source": "msstore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "Company Portal",
|
||||||
|
"id": "9WZDNCRFJ3PZ",
|
||||||
|
"source": "msstore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Microsoft Teams",
|
||||||
|
"id": "Microsoft.Teams",
|
||||||
|
"source": "winget"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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 Add additional apps below here
|
|
||||||
REM Contoso App (Example)
|
|
||||||
REM msiexec /i d:\Contoso\setup.msi /qn /norestart
|
|
||||||
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 Component Cleanup since dism /online /cleanup-image /analyzecomponentcleanup recommends it
|
|
||||||
REM If adding latest CU, definitely need to do this to keep FFU size smaller
|
|
||||||
dism /online /cleanup-image /startcomponentcleanup /resetbase
|
|
||||||
REM Sysprep/Generalize
|
|
||||||
c:\windows\system32\sysprep\sysprep.exe /quiet /generalize /oobe
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"VMWareTools": true,
|
||||||
|
"foo": "bar"
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
|
||||||
|
# --- CONFIGURATION ---
|
||||||
|
# Base path where application folders are located. Each subfolder represents one application.
|
||||||
|
$basePath = "D:\MSStore"
|
||||||
|
# Path for temporary files (e.g., for extracting archives). This will be created and cleaned up automatically.
|
||||||
|
$tempBasePath = Join-Path -Path $env:TEMP -ChildPath "StoreAppInstall"
|
||||||
|
|
||||||
|
# --- SCRIPT ---
|
||||||
|
|
||||||
|
# Helper function to clean up temporary files on exit or error
|
||||||
|
function Remove-TemporaryFiles {
|
||||||
|
if (Test-Path -Path $tempBasePath) {
|
||||||
|
Write-Host "Cleaning up temporary directory: $tempBasePath"
|
||||||
|
Remove-Item -Path $tempBasePath -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure temp directory is clean before starting
|
||||||
|
Remove-TemporaryFiles
|
||||||
|
New-Item -Path $tempBasePath -ItemType Directory -Force | Out-Null
|
||||||
|
|
||||||
|
# 1. Determine applicable dependency architectures based on the OS architecture
|
||||||
|
$osArchitecture = $env:PROCESSOR_ARCHITECTURE
|
||||||
|
$applicableArchitectures = switch ($osArchitecture) {
|
||||||
|
"AMD64" { 'x64', 'x86' }
|
||||||
|
"x86" { 'x86' }
|
||||||
|
"ARM64" { 'arm64', 'arm' }
|
||||||
|
default { $osArchitecture.ToLower() }
|
||||||
|
}
|
||||||
|
Write-Host "Installing Store Apps: Detected OS Architecture: $osArchitecture."
|
||||||
|
Write-Host "Applicable dependency architectures: $($applicableArchitectures -join ', ')"
|
||||||
|
|
||||||
|
# Check if the base path exists
|
||||||
|
if (-not (Test-Path -Path $basePath)) {
|
||||||
|
Write-Host "Installing Store Apps: Base path '$basePath' does not exist. Exiting."
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
Write-Host "Installing Store Apps: Base path '$basePath' exists."
|
||||||
|
|
||||||
|
# 2. Process and install each main application
|
||||||
|
Write-Host "Starting main application installation process..."
|
||||||
|
foreach ($appFolder in Get-ChildItem -Path $basePath -Directory) {
|
||||||
|
Write-Host "--- Processing application in folder: $($appFolder.Name) ---"
|
||||||
|
|
||||||
|
# Find the main application package (.appx/.msix/.appxbundle) in the app's root folder
|
||||||
|
$mainPackage = Get-ChildItem -Path $appFolder.FullName -File |
|
||||||
|
Where-Object { $_.Extension -in '.appx', '.msix', '.appxbundle', '.msixbundle' } |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if (-not $mainPackage) {
|
||||||
|
Write-Warning "No main application package found in '$($appFolder.Name)'. Skipping."
|
||||||
|
Write-Output ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Write-Host "Found main package: $($mainPackage.Name)"
|
||||||
|
|
||||||
|
# Extract and parse AppxManifest.xml from the main package
|
||||||
|
$manifestTempPath = Join-Path -Path $tempBasePath -ChildPath "AppxManifest.xml"
|
||||||
|
if (Test-Path $manifestTempPath) { Remove-Item $manifestTempPath -Force }
|
||||||
|
|
||||||
|
$requiredDependencies = $null
|
||||||
|
try {
|
||||||
|
[System.Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") | Out-Null
|
||||||
|
|
||||||
|
# Logic for handling bundles vs. single packages
|
||||||
|
if ($mainPackage.Extension -in '.appxbundle', '.msixbundle') {
|
||||||
|
Write-Host "Processing bundle. Searching for architecture-specific package..."
|
||||||
|
$bundleArchive = [System.IO.Compression.ZipFile]::OpenRead($mainPackage.FullName)
|
||||||
|
try {
|
||||||
|
# Find the best matching .appx/.msix package inside the bundle
|
||||||
|
$primaryArch = if ($osArchitecture -eq 'AMD64') { 'x64' } else { $osArchitecture.ToLower() }
|
||||||
|
$packageEntries = $bundleArchive.Entries | Where-Object { ($_.Name.EndsWith('.appx') -or $_.Name.EndsWith('.msix')) -and $_.Name -notlike "*_language-*" }
|
||||||
|
|
||||||
|
# Prioritize the primary architecture, then x86 (on x64), then neutral
|
||||||
|
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]${primaryArch}\.(appx|msix)$" } | Select-Object -First 1
|
||||||
|
if (-not $bestPackageEntry -and $primaryArch -eq 'x64') {
|
||||||
|
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]x86\.(appx|msix)$" } | Select-Object -First 1
|
||||||
|
}
|
||||||
|
if (-not $bestPackageEntry) {
|
||||||
|
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]neutral\.(appx|msix)$" } | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bestPackageEntry) {
|
||||||
|
Write-Host "Found inner package: $($bestPackageEntry.Name). Extracting to read its manifest."
|
||||||
|
$innerPackageTempPath = Join-Path -Path $tempBasePath -ChildPath $bestPackageEntry.Name
|
||||||
|
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($bestPackageEntry, $innerPackageTempPath, $true)
|
||||||
|
|
||||||
|
$innerPackageArchive = [System.IO.Compression.ZipFile]::OpenRead($innerPackageTempPath)
|
||||||
|
try {
|
||||||
|
$manifestEntry = $innerPackageArchive.Entries | Where-Object { $_.Name -eq 'AppxManifest.xml' } | Select-Object -First 1
|
||||||
|
if ($manifestEntry) {
|
||||||
|
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($manifestEntry, $manifestTempPath, $true)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$innerPackageArchive.Dispose()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Error "Could not find a suitable architecture-specific package inside '$($mainPackage.Name)'."
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$bundleArchive.Dispose()
|
||||||
|
}
|
||||||
|
} else { # It's a regular .appx or .msix
|
||||||
|
$zipArchive = [System.IO.Compression.ZipFile]::OpenRead($mainPackage.FullName)
|
||||||
|
try {
|
||||||
|
$manifestEntry = $zipArchive.Entries | Where-Object { $_.Name -eq 'AppxManifest.xml' } | Select-Object -First 1
|
||||||
|
if ($manifestEntry) {
|
||||||
|
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($manifestEntry, $manifestTempPath, $true)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$zipArchive.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Common manifest parsing logic
|
||||||
|
if (Test-Path $manifestTempPath) {
|
||||||
|
[xml]$manifest = Get-Content -Path $manifestTempPath
|
||||||
|
$nsm = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable)
|
||||||
|
$nsm.AddNamespace("def", "http://schemas.microsoft.com/appx/manifest/foundation/windows10")
|
||||||
|
|
||||||
|
$dependenciesNode = $manifest.SelectSingleNode("//def:Dependencies", $nsm)
|
||||||
|
if ($dependenciesNode) {
|
||||||
|
$requiredDependencies = $dependenciesNode.SelectNodes("def:PackageDependency", $nsm)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Error "Could not find or extract AppxManifest.xml from '$($mainPackage.FullName)'."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to read or parse manifest from '$($mainPackage.FullName)'. Error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scan for and resolve dependencies only if the manifest lists actual package dependencies.
|
||||||
|
$resolvedDependencyPaths = [System.Collections.Generic.List[string]]::new()
|
||||||
|
|
||||||
|
if ($null -ne $requiredDependencies -and $requiredDependencies.Count -gt 0) {
|
||||||
|
$appDependenciesPath = Join-Path -Path $appFolder.FullName -ChildPath "Dependencies"
|
||||||
|
|
||||||
|
if (Test-Path -Path $appDependenciesPath) {
|
||||||
|
Write-Host "Scanning for dependencies in '$appDependenciesPath'..."
|
||||||
|
$appSpecificDependencies = @{}
|
||||||
|
$dependencyFoldersToScan = [System.Collections.Generic.List[string]]::new()
|
||||||
|
$dependencyFoldersToScan.Add($appDependenciesPath)
|
||||||
|
|
||||||
|
# Handle zipped dependencies by extracting them to a temp location
|
||||||
|
Get-ChildItem -Path $appDependenciesPath -Filter "*.zip" -File | ForEach-Object {
|
||||||
|
$zipFile = $_
|
||||||
|
# Ensure unique extract path per app to avoid conflicts
|
||||||
|
$extractPath = Join-Path -Path $tempBasePath -ChildPath "$($appFolder.Name)_$($zipFile.BaseName)"
|
||||||
|
Write-Host "Extracting zipped dependencies from '$($zipFile.FullName)' to '$extractPath'..."
|
||||||
|
try {
|
||||||
|
Expand-Archive -Path $zipFile.FullName -DestinationPath $extractPath -Force
|
||||||
|
$dependencyFoldersToScan.Add($extractPath)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "Failed to extract '$($zipFile.FullName)'. Error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Regex to parse package filenames
|
||||||
|
$packageFileRegex = '^(?<Name>.+?)_(?<Version>(?:\d+\.){2,3}\d+)_(?:[^_]+_)*(?<Arch>x64|x86|arm|arm64|neutral)(?:__.*)?$'
|
||||||
|
|
||||||
|
# Catalog all package files found in the dependency folders for this app
|
||||||
|
foreach ($folder in $dependencyFoldersToScan.ToArray() | Select-Object -Unique) {
|
||||||
|
Get-ChildItem -Path $folder -Recurse -File | Where-Object { $_.Extension -in '.appx', '.msix', '.appxbundle' } | ForEach-Object {
|
||||||
|
$file = $_
|
||||||
|
$match = $file.BaseName -imatch $packageFileRegex
|
||||||
|
if ($match) {
|
||||||
|
$dependencyName = $matches.Name
|
||||||
|
try {
|
||||||
|
$dependencyVersion = [System.Version]$matches.Version
|
||||||
|
$dependencyArch = $matches.Arch
|
||||||
|
|
||||||
|
if (-not $appSpecificDependencies.ContainsKey($dependencyName)) {
|
||||||
|
$appSpecificDependencies[$dependencyName] = [System.Collections.Generic.List[object]]::new()
|
||||||
|
}
|
||||||
|
$appSpecificDependencies[$dependencyName].Add([pscustomobject]@{
|
||||||
|
Name = $dependencyName
|
||||||
|
Version = $dependencyVersion
|
||||||
|
Arch = $dependencyArch
|
||||||
|
Path = $file.FullName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not parse version for file '$($file.Name)'. Skipping."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host "Dependency scan for '$($appFolder.Name)' complete."
|
||||||
|
|
||||||
|
# Resolve all required dependencies using the app-specific catalog
|
||||||
|
foreach ($req in $requiredDependencies) {
|
||||||
|
$reqName = $req.Name
|
||||||
|
$reqMinVersion = [System.Version]$req.MinVersion
|
||||||
|
Write-Host "Resolving dependency: $reqName (MinVersion: $reqMinVersion)"
|
||||||
|
|
||||||
|
if ($appSpecificDependencies.ContainsKey($reqName)) {
|
||||||
|
# Find all available packages that meet the minimum version and architecture requirements
|
||||||
|
$candidates = $appSpecificDependencies[$reqName] | Where-Object {
|
||||||
|
$_.Version -ge $reqMinVersion -and
|
||||||
|
$_.Arch -in ($applicableArchitectures + 'neutral')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($candidates) {
|
||||||
|
# Group by architecture and find the single latest version for each applicable arch
|
||||||
|
$bestCandidates = $candidates | Group-Object -Property Arch | ForEach-Object {
|
||||||
|
$_.Group | Sort-Object -Property Version -Descending | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach($best in $bestCandidates) {
|
||||||
|
Write-Host " - Found best match: $($best.Path.Replace($basePath, '...'))"
|
||||||
|
$resolvedDependencyPaths.Add($best.Path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning " - No suitable package found for dependency '$reqName' with MinVersion '$reqMinVersion' for applicable architectures."
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning " - Dependency '$reqName' not found in this app's dependency folder."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Warning "Dependencies are required by manifest, but no 'Dependencies' folder found for '$($appFolder.Name)'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "No actual package dependencies listed in manifest for '$($appFolder.Name)'. Proceeding without dependency resolution."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the DISM command
|
||||||
|
$dismParams = @(
|
||||||
|
"/Online"
|
||||||
|
"/Add-ProvisionedAppxPackage"
|
||||||
|
"/PackagePath:`"$($mainPackage.FullName)`""
|
||||||
|
"/Region:all"
|
||||||
|
# "/StubPackageOption:installfull"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add resolved dependencies, ensuring no duplicates
|
||||||
|
$resolvedDependencyPaths.ToArray() | Select-Object -Unique | ForEach-Object {
|
||||||
|
$dismParams += "/DependencyPackagePath:`"$_`""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find and add the license file, or skip if not found
|
||||||
|
$licenseFile = Get-ChildItem -Path $appFolder.FullName -Filter "*.xml" -File | Select-Object -First 1
|
||||||
|
if ($licenseFile) {
|
||||||
|
$dismParams += "/LicensePath:`"$($licenseFile.FullName)`""
|
||||||
|
} else {
|
||||||
|
$dismParams += "/SkipLicense"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute the DISM command
|
||||||
|
$dismCommand = "DISM.exe " + ($dismParams -join " ")
|
||||||
|
Write-Host "Constructed DISM command:"
|
||||||
|
Write-Output $dismCommand
|
||||||
|
|
||||||
|
try {
|
||||||
|
Invoke-Expression -Command $dismCommand -ErrorAction Stop
|
||||||
|
Write-Host "Successfully installed $($mainPackage.Name)."
|
||||||
|
} catch {
|
||||||
|
Write-Error "DISM command failed for $($mainPackage.Name). Error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
Write-Output ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Final cleanup
|
||||||
|
Write-Host "Installation process finished."
|
||||||
|
Remove-TemporaryFiles
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
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
|
||||||
|
Write-Host $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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Construct the argument list properly, handling potential array vs string
|
||||||
|
$argumentsToPass = if ($app.Arguments -is [array]) { $app.Arguments } else { @($app.Arguments) }
|
||||||
|
|
||||||
|
Write-Host "Running command: $($app.CommandLine) $($argumentsToPass -join ' ')"
|
||||||
|
$result = Invoke-Process -FilePath $($app.CommandLine) -ArgumentList $argumentsToPass
|
||||||
|
Write-Host "$($app.Name) exited with exit code: $($result.ExitCode)`r`n"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Error occurred while installing $($app.Name): $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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,59 @@
|
|||||||
|
<#
|
||||||
|
.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
|
||||||
|
|
||||||
|
# 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 'foo' is set to boolean $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,14 @@
|
|||||||
|
#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
|
||||||
|
# Convert these commands to native powershell
|
||||||
|
# del c:\windows\panther\unattend\unattend.xml /F /Q
|
||||||
|
# del c:\windows\panther\unattend.xml /F /Q
|
||||||
|
# taskkill /IM sysprep.exe
|
||||||
|
# timeout /t 10
|
||||||
|
# & c:\windows\system32\sysprep\sysprep.exe /quiet /generalize /oobe
|
||||||
|
|
||||||
|
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
|
||||||
|
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||||
|
<settings pass="auditUser">
|
||||||
|
<component name="Microsoft-Windows-Deployment" 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">
|
||||||
|
<RunAsynchronous>
|
||||||
|
<RunAsynchronousCommand wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<Path>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File "d:\orchestration\orchestrator.ps1"</Path>
|
||||||
|
</RunAsynchronousCommand>
|
||||||
|
</RunAsynchronous>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
<settings pass="oobeSystem">
|
||||||
|
<component name="Microsoft-Windows-Deployment" 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">
|
||||||
|
<Reseal>
|
||||||
|
<Mode>Audit</Mode>
|
||||||
|
</Reseal>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
<cpi:offlineImage cpi:source="wim:c:/wimtoffu/win11_22h2_feb2023_consumer.wim#Windows 11 Pro" xmlns:cpi="urn:schemas-microsoft-com:cpi" />
|
||||||
|
</unattend>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<RunAsynchronous>
|
<RunAsynchronous>
|
||||||
<RunAsynchronousCommand wcm:action="add">
|
<RunAsynchronousCommand wcm:action="add">
|
||||||
<Order>1</Order>
|
<Order>1</Order>
|
||||||
<Path>d:\InstallAppsandSysprep.cmd</Path>
|
<Path>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File "d:\orchestration\orchestrator.ps1"</Path>
|
||||||
</RunAsynchronousCommand>
|
</RunAsynchronousCommand>
|
||||||
</RunAsynchronous>
|
</RunAsynchronous>
|
||||||
</component>
|
</component>
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Launches the FFU Development UI, a WPF application for configuring and running the FFU build process.
|
||||||
|
.DESCRIPTION
|
||||||
|
The BuildFFUVM_UI.ps1 script is the main entry point for the FFU Development user interface. It initializes and displays a WPF-based graphical interface defined in BuildFFUVM_UI.xaml.
|
||||||
|
|
||||||
|
The script is responsible for:
|
||||||
|
- Initializing a global state object to manage UI controls, data, and application flags.
|
||||||
|
- Importing the required FFU.Common and FFUUI.Core modules which contain the business logic.
|
||||||
|
- Ensuring system prerequisites, such as PowerShell 7 and Long Path Support, are met.
|
||||||
|
- Loading the XAML window, initializing UI controls with default values, and registering all event handlers.
|
||||||
|
- Launching the core build script (BuildFFUVM.ps1) in a background job when the user initiates a build.
|
||||||
|
- Providing real-time feedback by monitoring the build log file and updating the UI's progress bar and log viewer.
|
||||||
|
- Handling cleanup operations, such as reverting system settings, when the application is closed.
|
||||||
|
|
||||||
|
This script acts as the primary host for the UI, connecting the user interface with the underlying build and logic modules.
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
[System.STAThread()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
# Check PowerShell Version
|
||||||
|
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||||
|
Write-Error "PowerShell 7 or later is required to run this script."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Creating custom state object to hold UI state and data
|
||||||
|
$FFUDevelopmentPath = $PSScriptRoot
|
||||||
|
|
||||||
|
$script:uiState = [PSCustomObject]@{
|
||||||
|
FFUDevelopmentPath = $FFUDevelopmentPath;
|
||||||
|
Window = $null;
|
||||||
|
Controls = @{
|
||||||
|
featureCheckBoxes = @{};
|
||||||
|
UpdateInstallAppsBasedOnUpdates = $null
|
||||||
|
};
|
||||||
|
Data = @{
|
||||||
|
allDriverModels = [System.Collections.Generic.List[PSCustomObject]]::new();
|
||||||
|
appsScriptVariablesDataList = [System.Collections.Generic.List[PSCustomObject]]::new();
|
||||||
|
versionData = $null;
|
||||||
|
vmSwitchMap = @{};
|
||||||
|
logData = $null;
|
||||||
|
logStreamReader = $null;
|
||||||
|
pollTimer = $null
|
||||||
|
};
|
||||||
|
Flags = @{
|
||||||
|
installAppsForcedByUpdates = $false;
|
||||||
|
prevInstallAppsStateBeforeUpdates = $null;
|
||||||
|
installAppsCheckedByOffice = $false;
|
||||||
|
lastSortProperty = $null;
|
||||||
|
lastSortAscending = $true
|
||||||
|
};
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# 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 {
|
||||||
|
# Disable button to prevent multiple clicks
|
||||||
|
$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
|
||||||
|
$configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json"
|
||||||
|
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $configFilePath -Encoding UTF8
|
||||||
|
|
||||||
|
if ($config.InstallOffice -and $config.OfficeConfigXMLFile) {
|
||||||
|
Copy-Item -Path $config.OfficeConfigXMLFile -Destination $config.OfficePath -Force
|
||||||
|
WriteLog "Office Configuration XML file copied successfully."
|
||||||
|
}
|
||||||
|
|
||||||
|
$txtStatus.Text = "Executing BuildFFUVM.ps1 in the background..."
|
||||||
|
WriteLog "Executing BuildFFUVM.ps1 in the background..."
|
||||||
|
|
||||||
|
# Prepare parameters for splatting
|
||||||
|
$buildParams = @{
|
||||||
|
ConfigFile = $configFilePath
|
||||||
|
}
|
||||||
|
if ($config.Verbose) {
|
||||||
|
$buildParams['Verbose'] = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Define the script block to run in the background job
|
||||||
|
$scriptBlock = {
|
||||||
|
param($buildParams, $PSScriptRoot)
|
||||||
|
|
||||||
|
# This script runs in a new process. BuildFFUVM.ps1 is expected to handle its own module imports.
|
||||||
|
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams
|
||||||
|
}
|
||||||
|
|
||||||
|
# Delete the old log file before starting the build job to ensure we don't read stale content.
|
||||||
|
$mainLogPath = Join-Path $config.FFUDevelopmentPath "FFUDevelopment.log"
|
||||||
|
if (Test-Path $mainLogPath) {
|
||||||
|
WriteLog "Removing old FFUDevelopment.log file."
|
||||||
|
Remove-Item -Path $mainLogPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start the job and store it in the shared state object
|
||||||
|
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $scriptBlock -ArgumentList @($buildParams, $PSScriptRoot)
|
||||||
|
|
||||||
|
# Wait for the new log file to be created by the background job.
|
||||||
|
$logWaitTimeout = 15 # seconds
|
||||||
|
$watch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
$watch.Stop()
|
||||||
|
|
||||||
|
# Open a stream reader to the main log file
|
||||||
|
if (Test-Path $mainLogPath) {
|
||||||
|
$fileStream = [System.IO.File]::Open($mainLogPath, 'Open', 'Read', 'ReadWrite')
|
||||||
|
$script:uiState.Data.logStreamReader = [System.IO.StreamReader]::new($fileStream)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create a timer to poll the job status from the UI thread
|
||||||
|
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
|
||||||
|
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
|
||||||
|
|
||||||
|
# Add the Tick event handler
|
||||||
|
$script:uiState.Data.pollTimer.Add_Tick({
|
||||||
|
param($sender, $e)
|
||||||
|
# This scriptblock runs on the UI thread, so it can safely access script-scoped variables
|
||||||
|
$currentJob = $script:uiState.Data.currentBuildJob
|
||||||
|
|
||||||
|
# Read from log stream
|
||||||
|
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||||
|
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
|
||||||
|
# Add the full line to the log view first to maintain consistency
|
||||||
|
$script:uiState.Data.logData.Add($line)
|
||||||
|
if ($script:uiState.Flags.autoScrollLog) {
|
||||||
|
$script:uiState.Controls.lstLogOutput.ScrollIntoView($line)
|
||||||
|
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Now, check if it's a progress line and update the UI accordingly
|
||||||
|
if ($line -match '\[PROGRESS\] (\d{1,3}) \| (.*)') {
|
||||||
|
$percentage = [double]$matches[1]
|
||||||
|
$message = $matches[2]
|
||||||
|
|
||||||
|
# Update progress bar and status text
|
||||||
|
$script:uiState.Controls.pbOverallProgress.Value = $percentage
|
||||||
|
$script:uiState.Controls.txtStatus.Text = $message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# If job is somehow null or the timer has been nulled out, stop the timer
|
||||||
|
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) {
|
||||||
|
if ($null -ne $sender) {
|
||||||
|
$sender.Stop()
|
||||||
|
}
|
||||||
|
$script:uiState.Data.pollTimer = $null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if the job has reached a terminal state
|
||||||
|
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') {
|
||||||
|
# Stop the timer, we're done polling
|
||||||
|
if ($null -ne $sender) {
|
||||||
|
$sender.Stop()
|
||||||
|
}
|
||||||
|
$script:uiState.Data.pollTimer = $null
|
||||||
|
|
||||||
|
# Final read of the log stream
|
||||||
|
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||||
|
$lastLine = $null
|
||||||
|
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
|
||||||
|
# Add the full line to the log view first
|
||||||
|
$script:uiState.Data.logData.Add($line)
|
||||||
|
$lastLine = $line
|
||||||
|
|
||||||
|
# Now, check if it's a progress line and update the UI accordingly
|
||||||
|
if ($line -match '\[PROGRESS\] (\d{1,3}) \| (.*)') {
|
||||||
|
$percentage = [double]$matches[1]
|
||||||
|
$message = $matches[2]
|
||||||
|
|
||||||
|
$script:uiState.Controls.pbOverallProgress.Value = $percentage
|
||||||
|
$script:uiState.Controls.txtStatus.Text = $message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# After the final read, scroll to the last line if autoscroll is enabled
|
||||||
|
if ($script:uiState.Flags.autoScrollLog -and $null -ne $lastLine) {
|
||||||
|
$script:uiState.Controls.lstLogOutput.ScrollIntoView($lastLine)
|
||||||
|
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:uiState.Data.logStreamReader.Close()
|
||||||
|
$script:uiState.Data.logStreamReader.Dispose()
|
||||||
|
$script:uiState.Data.logStreamReader = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$finalStatusText = "FFU build completed successfully."
|
||||||
|
if ($currentJob.State -eq 'Failed') {
|
||||||
|
$reason = $null
|
||||||
|
|
||||||
|
# Use Receive-Job with -ErrorVariable to reliably capture the error stream from the job,
|
||||||
|
# as suggested by the research on handling job errors.
|
||||||
|
Receive-Job -Job $currentJob -Keep -ErrorVariable jobErrors -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
|
||||||
|
if ($null -ne $jobErrors -and $jobErrors.Count -gt 0) {
|
||||||
|
# The terminating error is typically the last one in the stream.
|
||||||
|
$reason = ($jobErrors | Select-Object -Last 1).ToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
# If Receive-Job didn't surface an error, fall back to the JobStateInfo.Reason property.
|
||||||
|
if ([string]::IsNullOrWhiteSpace($reason) -and $currentJob.JobStateInfo.Reason) {
|
||||||
|
$reason = $currentJob.JobStateInfo.Reason.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
# Final fallback if no specific reason can be found.
|
||||||
|
if ([string]::IsNullOrWhiteSpace($reason)) {
|
||||||
|
$reason = "An unknown error occurred. The job failed without a specific reason."
|
||||||
|
}
|
||||||
|
|
||||||
|
$finalStatusText = "FFU build failed. Check FFUDevelopment.log for details."
|
||||||
|
WriteLog "BuildFFUVM.ps1 job failed. Reason: $reason"
|
||||||
|
[System.Windows.MessageBox]::Show("The build process failed. Please check the $FFUDevelopmentPath\FFUDevelopment.log file for details.`n`nError: $reason", "Build Error", "OK", "Error") | Out-Null
|
||||||
|
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "BuildFFUVM.ps1 job completed successfully."
|
||||||
|
$script:uiState.Controls.pbOverallProgress.Value = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update UI elements
|
||||||
|
$script:uiState.Controls.txtStatus.Text = $finalStatusText
|
||||||
|
$script:uiState.Controls.btnRun.IsEnabled = $true
|
||||||
|
|
||||||
|
# Clean up the job object
|
||||||
|
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
Remove-Job -Job $currentJob -Force
|
||||||
|
|
||||||
|
# Clear the job from the state
|
||||||
|
$script:uiState.Data.currentBuildJob = $null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Start the timer
|
||||||
|
$script:uiState.Data.pollTimer.Start()
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add handler for Remove button clicks
|
||||||
|
$window.Add_SourceInitialized({
|
||||||
|
$listView = $window.FindName('lstApplications')
|
||||||
|
$listView.AddHandler(
|
||||||
|
[System.Windows.Controls.Button]::ClickEvent,
|
||||||
|
[System.Windows.RoutedEventHandler] {
|
||||||
|
param($buttonSender, $clickEventArgs)
|
||||||
|
if ($clickEventArgs.OriginalSource -is [System.Windows.Controls.Button] -and $clickEventArgs.OriginalSource.Content -eq "Remove") {
|
||||||
|
Remove-Application -priority $clickEventArgs.OriginalSource.Tag -State $script:uiState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Register cleanup to reclaim memory and revert LongPathsEnabled setting when the UI window closes
|
||||||
|
$window.Add_Closed({
|
||||||
|
# Stop any running build job if the window is closed
|
||||||
|
if ($null -ne $script:uiState.Data.currentBuildJob) {
|
||||||
|
WriteLog "UI closing, stopping background build job."
|
||||||
|
|
||||||
|
# Stop the timer
|
||||||
|
if ($null -ne $script:uiState.Data.pollTimer) {
|
||||||
|
$script:uiState.Data.pollTimer.Stop()
|
||||||
|
$script:uiState.Data.pollTimer = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Close the log stream
|
||||||
|
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||||
|
$script:uiState.Data.logStreamReader.Close()
|
||||||
|
$script:uiState.Data.logStreamReader.Dispose()
|
||||||
|
$script:uiState.Data.logStreamReader = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop and remove the job
|
||||||
|
$jobToStop = $script:uiState.Data.currentBuildJob
|
||||||
|
$script:uiState.Data.currentBuildJob = $null # Clear it from state first
|
||||||
|
|
||||||
|
try {
|
||||||
|
Stop-Job -Job $jobToStop
|
||||||
|
Remove-Job -Job $jobToStop
|
||||||
|
WriteLog "Background job stopped and removed."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error stopping or removing background job: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Revert LongPathsEnabled registry setting if it was changed by this script
|
||||||
|
if ($script:uiState.Flags.originalLongPathsValue -ne 1) {
|
||||||
|
# Only revert if we changed it from something other than 1
|
||||||
|
try {
|
||||||
|
$currentValue = Get-ItemPropertyValue -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -ErrorAction SilentlyContinue
|
||||||
|
if ($currentValue -eq 1) {
|
||||||
|
# Double-check it's still 1 before reverting
|
||||||
|
$revertValue = if ($null -eq $script:uiState.Flags.originalLongPathsValue) { 0 } else { $script:uiState.Flags.originalLongPathsValue } # Revert to original or 0 if it didn't exist
|
||||||
|
WriteLog "Reverting LongPathsEnabled registry key back to original value ($revertValue)."
|
||||||
|
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value $revertValue -Force
|
||||||
|
WriteLog "LongPathsEnabled reverted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error reverting LongPathsEnabled registry key: $($_.Exception.Message)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# # Garbage collection
|
||||||
|
# [System.GC]::Collect()
|
||||||
|
# [System.GC]::WaitForPendingFinalizers()
|
||||||
|
})
|
||||||
|
|
||||||
|
[void]$window.ShowDialog()
|
||||||
@@ -0,0 +1,828 @@
|
|||||||
|
<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 30GB dynamic disk."/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBox x:Name="txtDiskSize" Grid.Row="3" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Text="30" ToolTip="Size of the virtual hard disk for the virtual machine. Default is a 30GB 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"/>
|
||||||
|
<TextBox x:Name="txtOptionalFeatures" IsReadOnly="True" Width="350" Margin="0,0,0,10" ToolTip="Provide a semicolon-separated list of Windows optional features you want to include in the FFU (e.g., netfx3;TFTP)."/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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.View>
|
||||||
|
<GridView>
|
||||||
|
<GridViewColumn Header="Priority" DisplayMemberBinding="{Binding Priority}" Width="60"/>
|
||||||
|
<GridViewColumn Header="Name" DisplayMemberBinding="{Binding Name}" Width="150"/>
|
||||||
|
<GridViewColumn Header="Command Line" DisplayMemberBinding="{Binding CommandLine}" Width="200"/>
|
||||||
|
<GridViewColumn Header="Arguments" DisplayMemberBinding="{Binding Arguments}" Width="200"/>
|
||||||
|
<GridViewColumn Header="Source" DisplayMemberBinding="{Binding Source}" Width="150"/>
|
||||||
|
<GridViewColumn Header="Copy Status" DisplayMemberBinding="{Binding CopyStatus}" Width="150"/>
|
||||||
|
<GridViewColumn Header="Action" Width="85">
|
||||||
|
<GridViewColumn.CellTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid HorizontalAlignment="Stretch">
|
||||||
|
<Button Content="Remove" Tag="{Binding Priority}" Width="70" HorizontalAlignment="Center" ToolTip="Remove this application from the list and reorder priorities"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</GridViewColumn.CellTemplate>
|
||||||
|
</GridViewColumn>
|
||||||
|
</GridView>
|
||||||
|
</ListView.View>
|
||||||
|
</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="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="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: Copy PE Drivers Checkbox -->
|
||||||
|
<StackPanel Grid.Row="12" Orientation="Horizontal" Margin="5">
|
||||||
|
<CheckBox x:Name="chkCopyPEDrivers" Content="Copy PE Drivers" Margin="0,0,5,0" ToolTip="When set to $true, will copy the drivers from the $FFUDevelopmentPath\PEDrivers folder to the WinPE deployment media. Default is $false."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</ScrollViewer>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<!-- TAB: Build -->
|
||||||
|
<TabItem Header="Build" Padding="20">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<Grid Margin="10">
|
||||||
|
<!-- Define 10 rows for the Build tab -->
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<!-- Row 0: Header -->
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<!-- Row 1: FFU Development Path -->
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<!-- Row 2: Custom FFU Name Template -->
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<!-- Row 3: FFU Capture Location -->
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<!-- Row 4: Share Name -->
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<!-- Row 5: Username -->
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<!-- Row 6: Threads -->
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<!-- Row 7: General Build Options Header -->
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<!-- Row 8: General Build Options Checkboxes -->
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<!-- Row 9: Build USB Drive Section -->
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<!-- Row 10: Post-Build Cleanup -->
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<!-- Row 0: Header -->
|
||||||
|
<TextBlock Grid.Row="0" Text="FFU Build Settings" FontWeight="Bold" FontSize="16" Margin="0,0,0,10"/>
|
||||||
|
<!-- Row 1: FFU Development Path -->
|
||||||
|
<Grid Grid.Row="1" Margin="0,5">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="200"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="FFU Development Path" VerticalAlignment="Center" ToolTip="Path to the FFU development folder."/>
|
||||||
|
<TextBox x:Name="txtFFUDevPath" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Path to the FFU development folder."/>
|
||||||
|
<Button x:Name="btnBrowseFFUDevPath" Grid.Column="2" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
<!-- Row 2: Custom FFU Name Template -->
|
||||||
|
<Grid Grid.Row="2" Margin="0,5">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="200"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="Custom FFU Name Template" VerticalAlignment="Center" ToolTip="Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}."/>
|
||||||
|
<TextBox x:Name="txtCustomFFUNameTemplate" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}."/>
|
||||||
|
</Grid>
|
||||||
|
<!-- Row 3: FFU Capture Location -->
|
||||||
|
<Grid Grid.Row="3" Margin="0,5">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="200"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="FFU Capture Location" VerticalAlignment="Center" ToolTip="Path to the folder where the captured FFU will be stored."/>
|
||||||
|
<TextBox x:Name="txtFFUCaptureLocation" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Path to the folder where the captured FFU will be stored."/>
|
||||||
|
<Button x:Name="btnBrowseFFUCaptureLocation" Grid.Column="2" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
<!-- Row 4: Share Name -->
|
||||||
|
<Grid Grid.Row="4" Margin="0,5">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="200"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="Share Name" VerticalAlignment="Center" ToolTip="Name of the shared folder for FFU capture. The default is FFUCaptureShare. This share will be created with rights for the user account. When finished, the share will be removed."/>
|
||||||
|
<TextBox x:Name="txtShareName" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Name of the shared folder for FFU capture. The default is FFUCaptureShare. This share will be created with rights for the user account. When finished, the share will be removed."/>
|
||||||
|
</Grid>
|
||||||
|
<!-- Row 5: Username -->
|
||||||
|
<Grid Grid.Row="5" Margin="0,5">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="200"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="Username" VerticalAlignment="Center" ToolTip="Username for accessing the shared folder. The default is ffu_user. The script will auto-create the account and password. When finished, it will remove the account."/>
|
||||||
|
<TextBox x:Name="txtUsername" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Username for accessing the shared folder. The default is ffu_user. The script will auto-create the account and password. When finished, it will remove the account."/>
|
||||||
|
</Grid>
|
||||||
|
<!-- Row 6: Threads -->
|
||||||
|
<Grid Grid.Row="6" Margin="0,5">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="200"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="Threads" VerticalAlignment="Center" ToolTip="Controls the number of parallel threads used by ForEach-Object -Parallel and sets the value of the -ThrottleLimit parameter. Default is 5. Used in Winget, Application Copy, and driver downloads"/>
|
||||||
|
<TextBox x:Name="txtThreads" Grid.Column="1" Margin="5" VerticalAlignment="Center" Width="50" HorizontalAlignment="Left" Text="5" ToolTip="Controls the number of parallel threads used by ForEach-Object -Parallel and sets the value of the -ThrottleLimit parameter. Default is 5. Used in Winget, Application Copy, and driver downloads"/>
|
||||||
|
</Grid>
|
||||||
|
<!-- Row 7: General Build Options Header -->
|
||||||
|
<TextBlock Grid.Row="7" Text="General Build Options" FontWeight="Bold" FontSize="16" Margin="0,10,0,5"/>
|
||||||
|
|
||||||
|
<!-- Row 8: General Build Options Checkboxes -->
|
||||||
|
<WrapPanel Grid.Row="8" Margin="0,5">
|
||||||
|
<CheckBox x:Name="chkBuildUSBDriveEnable" Content="Build USB Drive" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will partition and format a USB drive and copy the captured FFU to the drive."/>
|
||||||
|
<CheckBox x:Name="chkCompactOS" Content="Compact OS" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will compact the OS when building the FFU."/>
|
||||||
|
<CheckBox x:Name="chkUpdateADK" Content="Update ADK" Margin="5" VerticalAlignment="Center" Tag="When set to $true, the script will check for and install/update to the latest Windows ADK and WinPE add-on."/>
|
||||||
|
<CheckBox x:Name="chkOptimize" Content="Optimize" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will optimize the OS when building the FFU."/>
|
||||||
|
<CheckBox x:Name="chkAllowVHDXCaching" Content="Allow VHDX Caching" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will cache the VHDX file to cache folder and create a config json file to track Windows build information."/>
|
||||||
|
<CheckBox x:Name="chkCreateCaptureMedia" Content="Create Capture Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, this will create WinPE capture media for use when InstallApps is set to $true."/>
|
||||||
|
<CheckBox x:Name="chkCreateDeploymentMedia" Content="Create Deployment Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, this will create WinPE deployment media for use when deploying to a physical device."/>
|
||||||
|
<CheckBox x:Name="chkVerbose" Content="Verbose" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will enable write-verbose output to the console for the build script."/>
|
||||||
|
</WrapPanel>
|
||||||
|
|
||||||
|
<!-- Row 9: Build USB Drive Section -->
|
||||||
|
<StackPanel Grid.Row="9" Margin="0,10,0,5" x:Name="usbDriveSection" Visibility="Collapsed">
|
||||||
|
<TextBlock Text="Build USB Drive Settings" FontWeight="Bold" FontSize="16" Margin="0,0,0,10"/>
|
||||||
|
<StackPanel Margin="5,0,0,10">
|
||||||
|
<CheckBox x:Name="chkAllowExternalHardDiskMedia" Content="Allow External Hard Disk Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will allow the use of external hard disk media."/>
|
||||||
|
<CheckBox x:Name="chkPromptExternalHardDiskMedia" Content="Prompt for External Hard Disk Media" Margin="5,5,5,5" IsEnabled="False" VerticalAlignment="Center" Tag="When set to $true, will prompt before using external hard disk media."/>
|
||||||
|
<CheckBox x:Name="chkSelectSpecificUSBDrives" Content="Select Specific USB Drives" Margin="5" VerticalAlignment="Center" Tag="Enable to select specific USB drives for building"/>
|
||||||
|
<!-- Added Missing Checkboxes -->
|
||||||
|
<CheckBox x:Name="chkCopyAutopilot" Content="Copy Autopilot Profile" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the Autopilot profile to the USB drive."/>
|
||||||
|
<CheckBox x:Name="chkCopyUnattend" Content="Copy Unattend.xml" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the Unattend.xml file to the USB drive."/>
|
||||||
|
<CheckBox x:Name="chkCopyPPKG" Content="Copy Provisioning Package" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the provisioning package to the USB drive."/>
|
||||||
|
|
||||||
|
<!-- USB Drive Selection Section -->
|
||||||
|
<Grid x:Name="usbDriveSelectionPanel" Margin="5,0,0,0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<!-- Button and Select All row -->
|
||||||
|
<DockPanel Grid.Row="0" Margin="0,5" LastChildFill="False">
|
||||||
|
<Button x:Name="btnCheckUSBDrives" Content="Check USB drives" DockPanel.Dock="Left" Padding="10,5"/>
|
||||||
|
</DockPanel>
|
||||||
|
<!-- ListView row -->
|
||||||
|
<ListView x:Name="lstUSBDrives" Grid.Row="1" Margin="0,5" Height="150">
|
||||||
|
<ListView.View>
|
||||||
|
<GridView>
|
||||||
|
|
||||||
|
<GridViewColumn Header="Model" DisplayMemberBinding="{Binding Model}" Width="200"/>
|
||||||
|
<GridViewColumn Header="Serial Number" DisplayMemberBinding="{Binding SerialNumber}" Width="150"/>
|
||||||
|
<GridViewColumn Header="Size (GB)" DisplayMemberBinding="{Binding Size}" Width="80"/>
|
||||||
|
</GridView>
|
||||||
|
</ListView.View>
|
||||||
|
</ListView>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Row 10: Post-Build Cleanup -->
|
||||||
|
<StackPanel Grid.Row="10" Margin="0,10,0,5">
|
||||||
|
<TextBlock Text="Post-Build Cleanup" FontWeight="Bold" FontSize="16" Margin="0,0,0,5"/>
|
||||||
|
<CheckBox x:Name="chkCleanupAppsISO" Content="Cleanup Apps ISO" Margin="5" VerticalAlignment="Center" Tag="Remove Apps ISO after FFU capture."/>
|
||||||
|
<CheckBox x:Name="chkCleanupCaptureISO" Content="Cleanup Capture ISO" Margin="5" VerticalAlignment="Center" Tag="Remove WinPE capture ISO after FFU capture."/>
|
||||||
|
<CheckBox x:Name="chkCleanupDeployISO" Content="Cleanup Deploy ISO" Margin="5" VerticalAlignment="Center" Tag="Remove WinPE deployment ISO after FFU capture."/>
|
||||||
|
<CheckBox x:Name="chkCleanupDrivers" Content="Cleanup Drivers" Margin="5" VerticalAlignment="Center" Tag="Remove drivers folder after FFU capture."/>
|
||||||
|
<CheckBox x:Name="chkRemoveFFU" Content="Remove FFU" Margin="5" VerticalAlignment="Center" Tag="Remove FFU after copying to USB drive."/>
|
||||||
|
<CheckBox x:Name="chkRemoveApps" Content="Remove Apps Folder Content" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will remove the application content in the Apps folder after the FFU has been captured."/>
|
||||||
|
<CheckBox x:Name="chkRemoveUpdates" Content="Remove Downloaded Update Files" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will remove downloaded CU, .NET, MSRT, Defender, Edge, and OneDrive files after being applied/included."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</ScrollViewer>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<!-- TAB: Monitor -->
|
||||||
|
<TabItem Header="Monitor" x:Name="MonitorTab" Padding="20">
|
||||||
|
<Grid Margin="10">
|
||||||
|
<ListBox x:Name="lstLogOutput" SelectionMode="Extended" VirtualizingStackPanel.IsVirtualizing="True" VirtualizingStackPanel.VirtualizationMode="Recycling" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto" HorizontalContentAlignment="Stretch"/>
|
||||||
|
</Grid>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<ProgressBar x:Name="progressBar" Height="20" Margin="0,10,0,0" Grid.Row="1" Visibility="Collapsed"/>
|
||||||
|
<!-- Status Text -->
|
||||||
|
<TextBlock x:Name="txtStatus" Grid.Row="2" Margin="0,5,0,0"/>
|
||||||
|
<!-- Buttons (Build Config File / Load Config File / Build FFU) -->
|
||||||
|
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,20,20">
|
||||||
|
<Button x:Name="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,197 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Provides core, shared functions for logging, process execution, and resilient file transfers used across the FFU project.
|
||||||
|
.DESCRIPTION
|
||||||
|
This module is a central component of the FFU project, offering a set of robust, reusable functions.
|
||||||
|
It includes a centralized logging mechanism (WriteLog), a wrapper for running external processes with error handling (Invoke-Process),
|
||||||
|
a retry-aware BITS transfer function for reliable downloads (Start-BitsTransferWithRetry), and a progress reporting helper.
|
||||||
|
This module is designed to be imported by other scripts and modules within the project to ensure consistent behavior for common tasks.
|
||||||
|
#>
|
||||||
|
# Script-scoped variable for the log file path
|
||||||
|
$script:CommonCoreLogFilePath = $null
|
||||||
|
# Mutex for log file access
|
||||||
|
$script:commonCoreLogMutexName = "Global\FFUCommonCoreLogMutex" # Unique name
|
||||||
|
$script:commonCoreLogMutex = New-Object System.Threading.Mutex($false, $script:commonCoreLogMutexName)
|
||||||
|
|
||||||
|
# Function to set the log file path for this module
|
||||||
|
function Set-CommonCoreLogPath {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Path
|
||||||
|
)
|
||||||
|
$script:CommonCoreLogFilePath = $Path
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($script:CommonCoreLogFilePath)) {
|
||||||
|
# This initial WriteLog confirms the path is set and the logger is working.
|
||||||
|
WriteLog "CommonCoreLogPath set to: $script:CommonCoreLogFilePath"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# This Write-Warning will appear on console if path is bad, but won't go to log file yet.
|
||||||
|
Write-Warning "Set-CommonCoreLogPath called with an empty or null path."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Centralized WriteLog function
|
||||||
|
function WriteLog {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$LogText
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if the log file path has been set
|
||||||
|
if ([string]::IsNullOrWhiteSpace($script:CommonCoreLogFilePath)) {
|
||||||
|
Write-Warning "CommonCoreLogFilePath not set. Message: $LogText"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$logEntry = "$((Get-Date).ToString()) $LogText"
|
||||||
|
$streamWriter = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
$script:commonCoreLogMutex.WaitOne() | Out-Null
|
||||||
|
# Ensure directory exists before writing
|
||||||
|
$logDir = Split-Path -Path $script:CommonCoreLogFilePath -Parent
|
||||||
|
if (-not (Test-Path -Path $logDir -PathType Container)) {
|
||||||
|
New-Item -Path $logDir -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
}
|
||||||
|
$streamWriter = New-Object System.IO.StreamWriter($script:CommonCoreLogFilePath, $true, [System.Text.Encoding]::UTF8)
|
||||||
|
$streamWriter.WriteLine($logEntry)
|
||||||
|
|
||||||
|
Write-Verbose $LogText
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Use Write-Host for console visibility as Write-Warning might also try to log
|
||||||
|
Write-Host "WARNING: Error writing to log file '$($script:CommonCoreLogFilePath)': $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($null -ne $streamWriter) {
|
||||||
|
$streamWriter.Dispose()
|
||||||
|
}
|
||||||
|
$script:commonCoreLogMutex.ReleaseMutex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Process {
|
||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param
|
||||||
|
(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]$FilePath,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string[]]$ArgumentList,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[bool]$Wait = $true
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
||||||
|
$stdErrTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
||||||
|
|
||||||
|
$startProcessParams = @{
|
||||||
|
FilePath = $FilePath
|
||||||
|
ArgumentList = $ArgumentList
|
||||||
|
RedirectStandardError = $stdErrTempFile
|
||||||
|
RedirectStandardOutput = $stdOutTempFile
|
||||||
|
Wait = $($Wait);
|
||||||
|
PassThru = $true;
|
||||||
|
NoNewWindow = $true;
|
||||||
|
}
|
||||||
|
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
|
||||||
|
$cmd = Start-Process @startProcessParams
|
||||||
|
$cmdOutput = Get-Content -Path $stdOutTempFile -Raw
|
||||||
|
$cmdError = Get-Content -Path $stdErrTempFile -Raw
|
||||||
|
if ($cmd.ExitCode -ne 0 -and $wait -eq $true) {
|
||||||
|
if ($cmdError) {
|
||||||
|
throw $cmdError.Trim()
|
||||||
|
}
|
||||||
|
if ($cmdOutput) {
|
||||||
|
throw $cmdOutput.Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
|
||||||
|
WriteLog $cmdOutput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
#$PSCmdlet.ThrowTerminatingError($_)
|
||||||
|
WriteLog $_
|
||||||
|
# Write-Host "Script failed - $Logfile for more info"
|
||||||
|
throw $_
|
||||||
|
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
|
||||||
|
}
|
||||||
|
return $cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to download a file using BITS with retry and error handling
|
||||||
|
function Start-BitsTransferWithRetry {
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Source,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Destination,
|
||||||
|
[int]$Retries = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
$attempt = 0
|
||||||
|
$lastError = $null
|
||||||
|
|
||||||
|
while ($attempt -lt $Retries) {
|
||||||
|
$OriginalVerbosePreference = $VerbosePreference
|
||||||
|
$OriginalProgressPreference = $ProgressPreference
|
||||||
|
try {
|
||||||
|
$VerbosePreference = 'SilentlyContinue'
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
|
||||||
|
Start-BitsTransfer -Source $Source -Destination $Destination -ErrorAction Stop
|
||||||
|
|
||||||
|
$ProgressPreference = $OriginalProgressPreference
|
||||||
|
$VerbosePreference = $OriginalVerbosePreference
|
||||||
|
WriteLog "Successfully transferred $Source to $Destination."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$lastError = $_
|
||||||
|
$attempt++
|
||||||
|
WriteLog "Attempt $attempt of $Retries failed to download $Source. Error: $($lastError.Exception.Message)."
|
||||||
|
Start-Sleep -Seconds (1 * $attempt)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Get-Variable -Name 'OriginalProgressPreference' -ErrorAction SilentlyContinue) {
|
||||||
|
$ProgressPreference = $OriginalProgressPreference
|
||||||
|
}
|
||||||
|
if (Get-Variable -Name 'OriginalVerbosePreference' -ErrorAction SilentlyContinue) {
|
||||||
|
$VerbosePreference = $OriginalVerbosePreference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog "Failed to download $Source after $Retries attempts. Last Error: $($lastError.Exception.Message)"
|
||||||
|
throw $lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-Progress {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[int]$Percentage,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Message
|
||||||
|
)
|
||||||
|
WriteLog "[PROGRESS] $Percentage | $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function *
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
<#
|
||||||
|
.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
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
WriteLog "Removing source driver folder: $SourceFolderPath"
|
||||||
|
try {
|
||||||
|
Remove-Item -Path $SourceFolderPath -Recurse -Force -ErrorAction Stop
|
||||||
|
WriteLog "Successfully removed source folder '$SourceFolderPath'."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Warning: Failed to remove source folder '$SourceFolderPath'. Error: $($_.Exception.Message)"
|
||||||
|
# Do not fail the whole operation, just log a warning.
|
||||||
|
}
|
||||||
|
|
||||||
|
return $true # Indicate success
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Failed to compress folder '$SourceFolderPath' to WIM '$DestinationWimPath' using dism.exe."
|
||||||
|
WriteLog "Error details: $($_.Exception.Message)"
|
||||||
|
# Check if the error message contains details about the DISM log (dism.exe output might be in the exception)
|
||||||
|
if ($_.Exception.Message -match 'DISM log file can be found at (.*)') {
|
||||||
|
$dismLogPath = $matches[1].Trim()
|
||||||
|
WriteLog "Check the DISM log for more details: $dismLogPath"
|
||||||
|
}
|
||||||
|
return $false # Indicate failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Compression operation skipped due to -WhatIf."
|
||||||
|
return $false # Indicate skipped operation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# SECTION: Driver Mapping Function
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Update-DriverMappingJson {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[array]$DownloadedDrivers, # Array of PSCustomObjects with Make, Model, DriverPath
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$DriversFolder # Base drivers folder (e.g., C:\FFUDevelopment\Drivers)
|
||||||
|
)
|
||||||
|
|
||||||
|
$mappingFilePath = Join-Path -Path $DriversFolder -ChildPath "DriverMapping.json"
|
||||||
|
WriteLog "Updating driver mapping file at: $mappingFilePath"
|
||||||
|
|
||||||
|
# Load existing mapping file or create a new list
|
||||||
|
$mappingList = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
if (Test-Path -Path $mappingFilePath -PathType Leaf) {
|
||||||
|
try {
|
||||||
|
$existingJson = Get-Content -Path $mappingFilePath -Raw | ConvertFrom-Json
|
||||||
|
# Ensure it's a collection before adding to the list
|
||||||
|
if ($existingJson -is [array]) {
|
||||||
|
# Iterate through the array to avoid type conversion issues with AddRange
|
||||||
|
foreach ($item in $existingJson) {
|
||||||
|
$mappingList.Add($item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$mappingList.Add($existingJson)
|
||||||
|
}
|
||||||
|
WriteLog "Loaded $($mappingList.Count) existing entries from $mappingFilePath"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Warning: Could not read or parse existing DriverMapping.json. A new file will be created. Error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$updatedCount = 0
|
||||||
|
$addedCount = 0
|
||||||
|
|
||||||
|
foreach ($driver in $DownloadedDrivers) {
|
||||||
|
# Skip if any required property is missing or null
|
||||||
|
if (-not $driver.PSObject.Properties['Make'] -or -not $driver.PSObject.Properties['Model'] -or -not $driver.PSObject.Properties['DriverPath'] -or [string]::IsNullOrWhiteSpace($driver.DriverPath)) {
|
||||||
|
WriteLog "Skipping driver entry due to missing or empty Make, Model, or DriverPath. Details: $(($driver | ConvertTo-Json -Compress -Depth 3))"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find existing entry
|
||||||
|
$existingEntry = $mappingList | Where-Object { $_.Manufacturer -eq $driver.Make -and $_.Model -eq $driver.Model } | Select-Object -First 1
|
||||||
|
|
||||||
|
if ($null -ne $existingEntry) {
|
||||||
|
# Update existing entry if the path is different
|
||||||
|
if ($existingEntry.DriverPath -ne $driver.DriverPath) {
|
||||||
|
WriteLog "Updating driver path for '$($driver.Make) - $($driver.Model)' from '$($existingEntry.DriverPath)' to '$($driver.DriverPath)'."
|
||||||
|
$existingEntry.DriverPath = $driver.DriverPath
|
||||||
|
$updatedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Add new entry
|
||||||
|
$newEntry = [PSCustomObject]@{
|
||||||
|
Manufacturer = $driver.Make
|
||||||
|
Model = $driver.Model
|
||||||
|
DriverPath = $driver.DriverPath
|
||||||
|
}
|
||||||
|
$mappingList.Add($newEntry)
|
||||||
|
WriteLog "Adding new mapping for '$($driver.Make) - $($driver.Model)' with path '$($driver.DriverPath)'."
|
||||||
|
$addedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($updatedCount -gt 0 -or $addedCount -gt 0) {
|
||||||
|
try {
|
||||||
|
# Sort the list for consistency before saving
|
||||||
|
$sortedList = $mappingList | Sort-Object -Property Manufacturer, Model
|
||||||
|
$sortedList | ConvertTo-Json -Depth 5 | Set-Content -Path $mappingFilePath -Encoding UTF8
|
||||||
|
WriteLog "Successfully saved DriverMapping.json with $addedCount new entries and $updatedCount updated entries."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error saving updated DriverMapping.json: $($_.Exception.Message)"
|
||||||
|
throw "Failed to save driver mapping file."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "No changes needed for DriverMapping.json."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# SECTION: Driver Existence Check Function
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
function Test-ExistingDriver {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Make,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Model,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$DriversFolder,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Identifier,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null
|
||||||
|
)
|
||||||
|
|
||||||
|
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
|
||||||
|
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $Model
|
||||||
|
$driverRelativePath = Join-Path -Path $Make -ChildPath $Model
|
||||||
|
|
||||||
|
# Check for WIM file first
|
||||||
|
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($Model).wim"
|
||||||
|
if (Test-Path -Path $wimFilePath -PathType Leaf) {
|
||||||
|
$status = "Already downloaded (WIM)"
|
||||||
|
WriteLog "Driver WIM for '$Identifier' already exists at '$wimFilePath'."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $Identifier -Status $status }
|
||||||
|
$wimRelativePath = Join-Path -Path $Make -ChildPath "$($Model).wim"
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Model = $Identifier # Return original identifier
|
||||||
|
Status = $status
|
||||||
|
Success = $true
|
||||||
|
DriverPath = $wimRelativePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for existing driver folder
|
||||||
|
if (Test-Path -Path $modelPath -PathType Container) {
|
||||||
|
$folderSize = (Get-ChildItem -Path $modelPath -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||||
|
if ($folderSize -gt 1MB) {
|
||||||
|
$status = "Already downloaded"
|
||||||
|
WriteLog "Drivers for '$Identifier' already exist in '$modelPath'."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $Identifier -Status $status }
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Model = $Identifier # Return original identifier
|
||||||
|
Status = $status
|
||||||
|
Success = $true
|
||||||
|
DriverPath = $driverRelativePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Driver folder '$modelPath' for '$Identifier' exists but is empty or very small. Re-downloading."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# If neither WIM nor a valid folder exists, return null
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
function Get-LenovoPSREFToken {
|
||||||
|
|
||||||
|
<#
|
||||||
|
.DESCRIPTION
|
||||||
|
Retrieves the Lenovo PSREF token from the Edge browser's local storage.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
|
||||||
|
Lenovo's PSREF site creates a cookie/token via javascript when navigating to the PSREF site. This cookie only needs
|
||||||
|
to be retrieved once on a single machine, and every machine within the same network will be able to access the PSREF API.
|
||||||
|
|
||||||
|
Using Invoke-Webrequest with sessionvariable or websession doesn't work because the token is created by javascript.
|
||||||
|
Using edge in headless mode with remote debugging enabled allows for the retrieval of the token via the DevTools protocol.
|
||||||
|
|
||||||
|
You couldn't be more unhappy about this solution than I am, but it works.
|
||||||
|
|
||||||
|
Why use PSREF and not catalogv2.xml? Catalogv2.xml doesn't include all models. PSREF provides an API that can be used to retrieve
|
||||||
|
the friendly model and machine type information for both business and consumer models. Many EDU devices are deemed consumer.
|
||||||
|
|
||||||
|
System Update and other tools rely on the user to input machine type and model information, but finding the machine type is difficult for some.
|
||||||
|
Our solution makes it easier to simply type the model name and you can match the machine type to the model name.
|
||||||
|
|
||||||
|
If you have a better solution, please submit a PR or open a discussion on Github. Happy to consider alternatives. An easy way to test
|
||||||
|
if your alternative works is to see if you can retrieve 100e, 300w, 500w, etc. These don't show up in catalogv2.xml, but they do in PSREF.
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Path to Edge
|
||||||
|
$edgeExe = "$Env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe"
|
||||||
|
|
||||||
|
# Any free port works. 9222 is common.
|
||||||
|
$port = 9222
|
||||||
|
$uri = 'https://psref.lenovo.com'
|
||||||
|
|
||||||
|
# Headless run with remote debugging.
|
||||||
|
$flags = "--headless=new --disable-gpu --remote-debugging-port=$port $uri"
|
||||||
|
$edge = Start-Process -FilePath $edgeExe -ArgumentList $flags -PassThru
|
||||||
|
Writelog "Edge process started with PID: $($edge.Id)."
|
||||||
|
|
||||||
|
# Wait a short moment so the target appears.
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
|
||||||
|
# Find the first page target.
|
||||||
|
$targets = Invoke-RestMethod "http://localhost:$port/json"
|
||||||
|
$wsUrl = ($targets | Where-Object type -eq 'page')[0].webSocketDebuggerUrl
|
||||||
|
|
||||||
|
# Connect to that WebSocket.
|
||||||
|
$socket = [System.Net.WebSockets.ClientWebSocket]::new()
|
||||||
|
$socket.ConnectAsync($wsUrl, [Threading.CancellationToken]::None).Wait()
|
||||||
|
|
||||||
|
# Helper to send a DevTools command.
|
||||||
|
function Send-DevToolsCommand {
|
||||||
|
param([int]$id, [string]$method, [hashtable]$params = @{})
|
||||||
|
$cmd = @{ id = $id; method = $method; params = $params } |
|
||||||
|
ConvertTo-Json -Compress
|
||||||
|
$data = [Text.Encoding]::UTF8.GetBytes($cmd)
|
||||||
|
$socket.SendAsync([ArraySegment[byte]]$data, 'Text', $true,
|
||||||
|
[Threading.CancellationToken]::None).Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ask the page to return localStorage['asut'].
|
||||||
|
Send-DevToolsCommand -id 1 -method 'Runtime.evaluate' -params @{
|
||||||
|
expression = "localStorage.getItem('asut')"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Receive frames until the whole message arrives.
|
||||||
|
$ms = New-Object System.IO.MemoryStream
|
||||||
|
$buf = New-Object byte[] 8192
|
||||||
|
do {
|
||||||
|
$seg = [ArraySegment[byte]]::new($buf)
|
||||||
|
$res = $socket.ReceiveAsync($seg,
|
||||||
|
[Threading.CancellationToken]::None).Result
|
||||||
|
$ms.Write($buf, 0, $res.Count)
|
||||||
|
} until ($res.EndOfMessage)
|
||||||
|
|
||||||
|
$ms.Position = 0
|
||||||
|
$json = ([System.IO.StreamReader]::new($ms, [Text.Encoding]::UTF8)).ReadToEnd() |
|
||||||
|
ConvertFrom-Json
|
||||||
|
|
||||||
|
$token = $json.result.result.value
|
||||||
|
# Concatenate the token value with X-PSREF-USER-TOKEN=
|
||||||
|
$token = "X-PSREF-USER-TOKEN=$token"
|
||||||
|
WriteLog "Retrieved Lenovo PSREF token: $token"
|
||||||
|
|
||||||
|
# Clean up.
|
||||||
|
$socket.Dispose()
|
||||||
|
|
||||||
|
if ($null -ne $socket) {
|
||||||
|
$socket.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find the PID listening on the debugging port for reliable termination.
|
||||||
|
$listeningPid = $null
|
||||||
|
try {
|
||||||
|
# Find the process listening on the specific port. The regex now looks for the local address and port, followed by anything, then LISTENING.
|
||||||
|
# Dots are escaped for literal matching.
|
||||||
|
$netstatOutput = netstat -ano -p TCP | Where-Object { $_ -match "127\.0\.0\.1:$port.*LISTENING" }
|
||||||
|
if ($netstatOutput) {
|
||||||
|
# The last number in the line is the PID
|
||||||
|
$listeningPid = ($netstatOutput -split '\s+')[-1]
|
||||||
|
WriteLog "Found Edge process PID $listeningPid listening on port $port. This is the process we will terminate."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Could not find any process listening on port $port."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Could not run netstat to find listening PID. Error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine the correct PID to kill. Prioritize the one found via netstat.
|
||||||
|
$pidToKill = $null
|
||||||
|
if ($listeningPid) {
|
||||||
|
$pidToKill = $listeningPid
|
||||||
|
}
|
||||||
|
elseif ($null -ne $edgeProcess -and -not $edgeProcess.HasExited) {
|
||||||
|
$pidToKill = $edgeProcess.Id
|
||||||
|
WriteLog "Could not find listening process via netstat. Falling back to initial Edge process PID $($pidToKill) for termination."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pidToKill) {
|
||||||
|
WriteLog "Attempting to terminate Edge process tree with PID: $pidToKill"
|
||||||
|
try {
|
||||||
|
taskkill /PID $pidToKill /T /F | Out-Null
|
||||||
|
WriteLog "Successfully issued termination command for Edge process tree with PID: $pidToKill."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Failed to terminate Edge process tree with PID: $pidToKill. It may have already closed. Error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "No active Edge process found to terminate."
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# SECTION: Module Export
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Compress-DriverFolderToWim, Update-DriverMappingJson, Test-ExistingDriver, Get-LenovoPSREFToken
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Manages and executes multiple background tasks in parallel, with support for updating a WPF UI with progress.
|
||||||
|
.DESCRIPTION
|
||||||
|
This function provides a generic framework for running tasks in parallel using PowerShell's ForEach-Object -Parallel.
|
||||||
|
It is designed to process an array of items, executing a specific task for each one. It can operate in two modes: UI mode and non-UI mode.
|
||||||
|
|
||||||
|
In UI mode, it updates a specified ListView control in a WPF window with the status of each item as it's being processed
|
||||||
|
(e.g., Queued, Downloading, Completed, Error). It uses a dispatcher to ensure UI updates are thread-safe.
|
||||||
|
|
||||||
|
In non-UI mode, it runs the tasks and logs the status to the FFUDevelopment.log file.
|
||||||
|
|
||||||
|
The function determines the task to run via the -TaskType parameter and passes necessary arguments using -TaskArguments.
|
||||||
|
It handles module imports and log file setup within each parallel runspace to ensure tasks have the necessary dependencies and logging capabilities.
|
||||||
|
.PARAMETER ItemsToProcess
|
||||||
|
An array of objects, where each object represents an item to be processed by a parallel task. This is a mandatory parameter.
|
||||||
|
.PARAMETER ListViewControl
|
||||||
|
(UI Mode) The WPF ListView control that the function will update with the status of each item. Defaults to $null.
|
||||||
|
.PARAMETER IdentifierProperty
|
||||||
|
The name of the property on the item objects that serves as a unique identifier (e.g., 'Name', 'Id').
|
||||||
|
This is used to find and update the correct row in the ListView.
|
||||||
|
.PARAMETER StatusProperty
|
||||||
|
The name of the property on the item objects that holds the status string. This property will be updated with progress messages.
|
||||||
|
.PARAMETER TaskType
|
||||||
|
A string specifying which task to execute for each item. This is mandatory.
|
||||||
|
Valid values are:
|
||||||
|
- 'WingetDownload': Downloads a Winget application.
|
||||||
|
- 'CopyBYO': Copies a user-provided application.
|
||||||
|
- 'DownloadDriverByMake': Downloads drivers for a specific manufacturer.
|
||||||
|
.PARAMETER TaskArguments
|
||||||
|
A hashtable containing arguments required by the specific task being run (e.g., paths, API keys, configuration settings).
|
||||||
|
.PARAMETER CompletedStatusText
|
||||||
|
The status text to display when an item is processed successfully.
|
||||||
|
.PARAMETER ErrorStatusPrefix
|
||||||
|
A prefix for status messages when an error occurs.
|
||||||
|
.PARAMETER WindowObject
|
||||||
|
(UI Mode) The main WPF Window object, used to access the UI dispatcher for safe UI updates from background threads.
|
||||||
|
.PARAMETER MainThreadLogPath
|
||||||
|
The file path for the log file that should be used by all parallel threads. This ensures consistent logging.
|
||||||
|
.PARAMETER ThrottleLimit
|
||||||
|
The maximum number of parallel jobs to run concurrently. The default is 5.
|
||||||
|
.NOTES
|
||||||
|
This function relies on ForEach-Object -Parallel, which was introduced in PowerShell 7.
|
||||||
|
When running in UI mode, both -WindowObject and -ListViewControl must be provided.
|
||||||
|
The function dynamically imports required modules ('FFU.Common' and 'FFUUI.Core') into each parallel runspace.
|
||||||
|
It uses a concurrent queue to manage intermediate progress updates from threads to the main UI thread, preventing UI blocking and providing more granular feedback.
|
||||||
|
#>
|
||||||
|
function Invoke-ParallelProcessing {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[array]$ItemsToProcess,
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[object]$ListViewControl = $null,
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$IdentifierProperty = 'Identifier',
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$StatusProperty = 'Status',
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[ValidateSet('WingetDownload', 'CopyBYO', 'DownloadDriverByMake')]
|
||||||
|
[string]$TaskType,
|
||||||
|
[Parameter()]
|
||||||
|
[hashtable]$TaskArguments = @{},
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$CompletedStatusText = "Completed",
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$ErrorStatusPrefix = "Error: ",
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[object]$WindowObject = $null,
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$MainThreadLogPath = $null,
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[int]$ThrottleLimit = 5
|
||||||
|
)
|
||||||
|
# Check if running in UI mode by verifying the types of the passed objects
|
||||||
|
$isUiMode = ($null -ne $WindowObject -and $WindowObject -is [System.Windows.Window] -and $null -ne $ListViewControl -and $ListViewControl -is [System.Windows.Controls.ListView])
|
||||||
|
|
||||||
|
if ($isUiMode) {
|
||||||
|
WriteLog "Invoke-ParallelProcessing started for $($ItemsToProcess.Count) items in ListView '$($ListViewControl.Name)'."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Invoke-ParallelProcessing started for $($ItemsToProcess.Count) items (non-UI mode)."
|
||||||
|
}
|
||||||
|
$resultsCollection = [System.Collections.Generic.List[object]]::new()
|
||||||
|
$jobs = @()
|
||||||
|
$totalItems = $ItemsToProcess.Count
|
||||||
|
$processedCount = 0
|
||||||
|
$completedIdentifiers = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||||
|
|
||||||
|
# Create a thread-safe queue for intermediate progress updates
|
||||||
|
$progressQueue = New-Object System.Collections.Concurrent.ConcurrentQueue[hashtable]
|
||||||
|
|
||||||
|
# Define common paths locally within this function's scope
|
||||||
|
$coreModulePath = $MyInvocation.MyCommand.Module.Path
|
||||||
|
$coreModuleDirectory = Split-Path -Path $coreModulePath -Parent
|
||||||
|
$ffuDevelopmentRoot = Split-Path -Path $coreModuleDirectory -Parent
|
||||||
|
|
||||||
|
# Paths to the module DIRECTORIES needed by the parallel threads
|
||||||
|
$commonModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "FFU.Common"
|
||||||
|
$uiCoreModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "FFUUI.Core"
|
||||||
|
|
||||||
|
# Use the explicitly passed MainThreadLogPath for the parallel jobs.
|
||||||
|
# If not provided (e.g., older calls or direct module use without this param), it might be null.
|
||||||
|
# The parallel job's Set-CommonCoreLogPath will handle null/empty paths by warning.
|
||||||
|
$currentLogFilePathForJob = $MainThreadLogPath
|
||||||
|
|
||||||
|
$jobScopeVariables = $TaskArguments.Clone()
|
||||||
|
$jobScopeVariables['_commonModulePath'] = $commonModulePathForJob
|
||||||
|
$jobScopeVariables['_uiCoreModulePath'] = $uiCoreModulePathForJob
|
||||||
|
$jobScopeVariables['_currentLogFilePathForJob'] = $currentLogFilePathForJob
|
||||||
|
$jobScopeVariables['_progressQueue'] = $progressQueue
|
||||||
|
|
||||||
|
# Initial UI update needs to happen *before* starting the jobs
|
||||||
|
# Update all items to a static "Processing..." status
|
||||||
|
if ($isUiMode) {
|
||||||
|
# Use the new $isUiMode flag
|
||||||
|
foreach ($item in $ItemsToProcess) {
|
||||||
|
$identifierValue = $item.$IdentifierProperty
|
||||||
|
$initialStaticStatus = "Queued..."
|
||||||
|
try {
|
||||||
|
# Update the UI on the main thread to show the item is being queued for processing
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
|
||||||
|
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $identifierValue -StatusProperty $StatusProperty -StatusValue $initialStaticStatus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error setting initial status for item '$identifierValue': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Queue items and start jobs using the pipeline and $using:
|
||||||
|
try {
|
||||||
|
# $jobScopeVariables and $TaskType are local here
|
||||||
|
# Inside the -Parallel scriptblock, we access them with $using:
|
||||||
|
$jobs = $ItemsToProcess | ForEach-Object -Parallel {
|
||||||
|
$currentItem = $_
|
||||||
|
$localJobArgs = $using:jobScopeVariables
|
||||||
|
$localTaskType = $using:TaskType
|
||||||
|
$localProgressQueue = $localJobArgs['_progressQueue']
|
||||||
|
|
||||||
|
# Initialize result hashtable
|
||||||
|
$taskResult = $null
|
||||||
|
$resultIdentifier = $null
|
||||||
|
$resultStatus = "Error: Task type '$localTaskType' not recognized"
|
||||||
|
$resultCode = 1 # Default to error
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Import modules needed for the task
|
||||||
|
Import-Module $localJobArgs['_commonModulePath'] -Force
|
||||||
|
Import-Module $localJobArgs['_uiCoreModulePath'] -Force
|
||||||
|
|
||||||
|
# Set the log path for this parallel thread
|
||||||
|
Set-CommonCoreLogPath -Path $localJobArgs['_currentLogFilePathForJob']
|
||||||
|
|
||||||
|
# Execute the appropriate background task based on $localTaskType
|
||||||
|
switch ($localTaskType) {
|
||||||
|
'WingetDownload' {
|
||||||
|
# Pass the progress queue to the task function
|
||||||
|
$wingetTaskArgs = @{
|
||||||
|
ApplicationItemData = $currentItem
|
||||||
|
AppListJsonPath = $localJobArgs['AppListJsonPath']
|
||||||
|
AppsPath = $localJobArgs['AppsPath']
|
||||||
|
OrchestrationPath = $localJobArgs['OrchestrationPath']
|
||||||
|
ProgressQueue = $localProgressQueue
|
||||||
|
}
|
||||||
|
$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']
|
||||||
|
}
|
||||||
|
'Dell' {
|
||||||
|
$taskResult = Save-DellDriversTask -DriverItemData $currentItem `
|
||||||
|
-DriversFolder $localJobArgs['DriversFolder'] `
|
||||||
|
-WindowsArch $localJobArgs['WindowsArch'] `
|
||||||
|
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||||
|
-ProgressQueue $localProgressQueue `
|
||||||
|
-CompressToWim $localJobArgs['CompressToWim']
|
||||||
|
}
|
||||||
|
'HP' {
|
||||||
|
$taskResult = Save-HPDriversTask -DriverItemData $currentItem `
|
||||||
|
-DriversFolder $localJobArgs['DriversFolder'] `
|
||||||
|
-WindowsArch $localJobArgs['WindowsArch'] `
|
||||||
|
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||||
|
-WindowsVersion $localJobArgs['WindowsVersion'] `
|
||||||
|
-ProgressQueue $localProgressQueue `
|
||||||
|
-CompressToWim $localJobArgs['CompressToWim']
|
||||||
|
}
|
||||||
|
'Lenovo' {
|
||||||
|
$taskResult = Save-LenovoDriversTask -DriverItemData $currentItem `
|
||||||
|
-DriversFolder $localJobArgs['DriversFolder'] `
|
||||||
|
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||||
|
-Headers $localJobArgs['Headers'] `
|
||||||
|
-UserAgent $localJobArgs['UserAgent'] `
|
||||||
|
-ProgressQueue $localProgressQueue `
|
||||||
|
-CompressToWim $localJobArgs['CompressToWim']
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
$unsupportedMakeMessage = "Error: Unsupported Make '$make' for driver download."
|
||||||
|
WriteLog $unsupportedMakeMessage
|
||||||
|
$resultStatus = $unsupportedMakeMessage
|
||||||
|
$resultCode = 1
|
||||||
|
# $resultIdentifier is already set from $currentItem.$($using:IdentifierProperty)
|
||||||
|
$localProgressQueue.Enqueue(@{ Identifier = $resultIdentifier; Status = $resultStatus })
|
||||||
|
# $taskResult remains null, handled below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Consolidate result handling for 'DownloadDriverByMake'
|
||||||
|
if ($null -ne $taskResult) {
|
||||||
|
# $resultIdentifier is already $currentItem.$($using:IdentifierProperty)
|
||||||
|
# We use the task's returned Model/Identifier for logging/status if needed,
|
||||||
|
# but the primary identifier for UI updates should be consistent.
|
||||||
|
$taskSpecificIdentifier = $null
|
||||||
|
if ($taskResult.PSObject.Properties.Name -contains 'Model') { $taskSpecificIdentifier = $taskResult.Model }
|
||||||
|
elseif ($taskResult.PSObject.Properties.Name -contains 'Identifier') { $taskSpecificIdentifier = $taskResult.Identifier }
|
||||||
|
|
||||||
|
$resultStatus = $taskResult.Status
|
||||||
|
# Simplified success check. All driver tasks should now return a 'Success' property.
|
||||||
|
if ($taskResult.PSObject.Properties.Name -contains 'Success') {
|
||||||
|
$resultCode = if ($taskResult.Success) { 0 } else { 1 }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Fallback for any task that *still* doesn't return 'Success'. This is now the exceptional case.
|
||||||
|
WriteLog "Warning: Task for '$taskSpecificIdentifier' did not return a 'Success' property. Inferring from status: '$($taskResult.Status)'"
|
||||||
|
if ($taskResult.Status -like 'Completed*' -or $taskResult.Status -like 'Already downloaded*') {
|
||||||
|
$resultCode = 0 # Treat as success
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$resultCode = 1 # Treat as error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($make -in ('Microsoft', 'Dell', 'HP', 'Lenovo')) {
|
||||||
|
# This means a specific Make case was hit, but $taskResult was unexpectedly null
|
||||||
|
$nullTaskResultMessage = "Error: Task for Make '$make' returned null."
|
||||||
|
WriteLog $nullTaskResultMessage
|
||||||
|
$resultStatus = $nullTaskResultMessage
|
||||||
|
$resultCode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Default {
|
||||||
|
# This handles unknown $localTaskType values
|
||||||
|
$resultStatus = "Error: Task type '$localTaskType' not recognized"
|
||||||
|
$resultCode = 1
|
||||||
|
if ($currentItem -is [pscustomobject] -and $currentItem.PSObject.Properties.Name -match $using:IdentifierProperty) {
|
||||||
|
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$resultIdentifier = "UnknownItem"
|
||||||
|
}
|
||||||
|
WriteLog "Error in parallel job: Unknown TaskType '$localTaskType' provided for item '$resultIdentifier'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$resultStatus = "Error: $($_.Exception.Message)"
|
||||||
|
$resultCode = 1
|
||||||
|
# Try to get an identifier
|
||||||
|
if ($currentItem -is [pscustomobject] -and $currentItem.PSObject.Properties.Name -match $using:IdentifierProperty) {
|
||||||
|
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$resultIdentifier = "UnknownItemOnError"
|
||||||
|
}
|
||||||
|
WriteLog "Exception during parallel task '$localTaskType' for item '$resultIdentifier': $($_.Exception.ToString())"
|
||||||
|
$localProgressQueue.Enqueue(@{ Identifier = $resultIdentifier; Status = $resultStatus })
|
||||||
|
}
|
||||||
|
|
||||||
|
$driverPathValue = $null
|
||||||
|
if ($null -ne $taskResult -and $taskResult.PSObject.Properties.Name -contains 'DriverPath') {
|
||||||
|
$driverPathValue = $taskResult.DriverPath
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return a consistent hashtable structure (final result)
|
||||||
|
return @{
|
||||||
|
Identifier = $resultIdentifier
|
||||||
|
Status = $resultStatus
|
||||||
|
ResultCode = $resultCode
|
||||||
|
DriverPath = $driverPathValue
|
||||||
|
}
|
||||||
|
|
||||||
|
} -ThrottleLimit $ThrottleLimit -AsJob
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error initiating ForEach-Object -Parallel: $($_.Exception.Message)"
|
||||||
|
# Update all items to show a general startup error
|
||||||
|
$errorStatus = "$ErrorStatusPrefix Failed to start processing"
|
||||||
|
foreach ($item in $ItemsToProcess) {
|
||||||
|
$identifier = $item.$IdentifierProperty
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { # Use $WindowObject
|
||||||
|
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $identifier -StatusProperty $StatusProperty -StatusValue $errorStatus # Pass $WindowObject
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if any jobs failed to start immediately (e.g., module loading issues within the job)
|
||||||
|
$failedJobs = $jobs | Where-Object { $_.State -eq 'Failed' -and $_.JobStateInfo.Reason }
|
||||||
|
foreach ($failedJob in $failedJobs) {
|
||||||
|
WriteLog "Job $($failedJob.Id) failed to start or failed early: $($failedJob.JobStateInfo.Reason)"
|
||||||
|
# We don't easily know which item failed here without more complex mapping
|
||||||
|
# Update overall status maybe?
|
||||||
|
$processedCount++
|
||||||
|
}
|
||||||
|
# Filter out jobs that failed immediately
|
||||||
|
$jobs = $jobs | Where-Object { $_.State -ne 'Failed' }
|
||||||
|
|
||||||
|
# Process job results and intermediate status updates without blocking the UI thread
|
||||||
|
while ($jobs.Count -gt 0 -or -not $progressQueue.IsEmpty) {
|
||||||
|
# Continue while jobs are running OR queue has messages
|
||||||
|
|
||||||
|
# 1. Process intermediate status updates from the queue
|
||||||
|
$statusUpdate = $null
|
||||||
|
while ($progressQueue.TryDequeue([ref]$statusUpdate)) {
|
||||||
|
if ($null -ne $statusUpdate) {
|
||||||
|
WriteLog "Dequeued progress update: $($statusUpdate | ConvertTo-Json -Compress)"
|
||||||
|
$intermediateIdentifier = $statusUpdate.Identifier
|
||||||
|
# If this item has already been marked as complete, skip this stale intermediate update
|
||||||
|
if ($completedIdentifiers.Contains($intermediateIdentifier)) {
|
||||||
|
WriteLog "Skipping stale intermediate status for already completed item: $intermediateIdentifier"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$intermediateStatus = $statusUpdate.Status
|
||||||
|
if ($isUiMode) {
|
||||||
|
# Update the UI with the intermediate status
|
||||||
|
try {
|
||||||
|
WriteLog "Dispatching INTERMEDIATE status for '$intermediateIdentifier': '$intermediateStatus'"
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
|
||||||
|
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $intermediateIdentifier -StatusProperty $StatusProperty -StatusValue $intermediateStatus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error setting intermediate status for item '$intermediateIdentifier': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Log intermediate status if not in UI mode
|
||||||
|
WriteLog "Intermediate Status for '$intermediateIdentifier': $intermediateStatus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Check for completed jobs
|
||||||
|
$completedJobs = $jobs | Where-Object { $_.State -in 'Completed', 'Failed', 'Stopped' }
|
||||||
|
|
||||||
|
if ($completedJobs) {
|
||||||
|
foreach ($completedJob in $completedJobs) {
|
||||||
|
$jobHandled = $false
|
||||||
|
if ($completedJob.State -eq 'Failed') {
|
||||||
|
$jobHandled = $true
|
||||||
|
$finalIdentifier = "UnknownJob"
|
||||||
|
WriteLog "Job $($completedJob.Id) failed: $($completedJob.Error)"
|
||||||
|
$finalStatus = "$ErrorStatusPrefix Job Failed"
|
||||||
|
$finalResultCode = 1
|
||||||
|
$processedCount++
|
||||||
|
|
||||||
|
# --- DISPATCH FOR FAILED JOB ---
|
||||||
|
$completedIdentifiers.Add($finalIdentifier) | Out-Null
|
||||||
|
if ($isUiMode) {
|
||||||
|
try {
|
||||||
|
WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'"
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus })
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" })
|
||||||
|
}
|
||||||
|
catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" }
|
||||||
|
}
|
||||||
|
else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" }
|
||||||
|
}
|
||||||
|
elseif ($completedJob.HasMoreData) {
|
||||||
|
$jobHandled = $true
|
||||||
|
$jobResults = $completedJob | Receive-Job
|
||||||
|
foreach ($result in $jobResults) {
|
||||||
|
WriteLog "Received FINAL job result: $($result | ConvertTo-Json -Compress -Depth 3)"
|
||||||
|
if ($null -ne $result -and $result -is [hashtable] -and $result.ContainsKey('Identifier')) {
|
||||||
|
$finalIdentifier = $result.Identifier
|
||||||
|
$status = $result.Status
|
||||||
|
$finalResultCode = $result.ResultCode
|
||||||
|
$finalStatus = if ($finalResultCode -eq 0) { $status } else { "$($ErrorStatusPrefix)$($status)" }
|
||||||
|
$processedCount++
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$finalIdentifier = "UnknownResult"
|
||||||
|
WriteLog "Warning: Received unexpected final job result format: $($result | Out-String)"
|
||||||
|
$finalStatus = "$ErrorStatusPrefix Invalid Result Format"
|
||||||
|
$finalResultCode = 1
|
||||||
|
$processedCount++
|
||||||
|
}
|
||||||
|
if ($null -ne $result) { $resultsCollection.Add($result) }
|
||||||
|
|
||||||
|
# --- DISPATCH PER RESULT ---
|
||||||
|
$completedIdentifiers.Add($finalIdentifier) | Out-Null
|
||||||
|
if ($isUiMode) {
|
||||||
|
try {
|
||||||
|
WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'"
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus })
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" })
|
||||||
|
}
|
||||||
|
catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" }
|
||||||
|
}
|
||||||
|
else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $jobHandled) {
|
||||||
|
# Catches 'Completed' with no data
|
||||||
|
$finalIdentifier = "UnknownJob"
|
||||||
|
WriteLog "Job $($completedJob.Id) completed with state '$($completedJob.State)' but had no data."
|
||||||
|
$finalStatus = "$ErrorStatusPrefix No Result Data"
|
||||||
|
$finalResultCode = 1
|
||||||
|
$processedCount++
|
||||||
|
|
||||||
|
# --- DISPATCH FOR NO-DATA JOB ---
|
||||||
|
$completedIdentifiers.Add($finalIdentifier) | Out-Null
|
||||||
|
if ($isUiMode) {
|
||||||
|
try {
|
||||||
|
WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'"
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus })
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" })
|
||||||
|
}
|
||||||
|
catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" }
|
||||||
|
}
|
||||||
|
else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove the completed/failed job from the list and clean it up
|
||||||
|
$jobs = $jobs | Where-Object { $_.Id -ne $completedJob.Id }
|
||||||
|
Remove-Job -Job $completedJob -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Allow UI events to process and sleep briefly
|
||||||
|
if ($isUiMode) {
|
||||||
|
# Only sleep if jobs are still running AND the queue is empty (to avoid delaying UI updates)
|
||||||
|
if ($jobs.Count -gt 0 -and $progressQueue.IsEmpty) {
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action] { }) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
}
|
||||||
|
elseif (-not $progressQueue.IsEmpty) {
|
||||||
|
# If queue has messages, process them immediately without sleeping
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action] { }) | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Non-UI mode, just sleep if jobs are running
|
||||||
|
if ($jobs.Count -gt 0) {
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
# Final cleanup of any remaining jobs (shouldn't be necessary with this loop logic, but good practice)
|
||||||
|
if ($jobs.Count -gt 0) {
|
||||||
|
WriteLog "Cleaning up $($jobs.Count) remaining jobs after loop exit."
|
||||||
|
Remove-Job -Job $jobs -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isUiMode) {
|
||||||
|
WriteLog "Invoke-ParallelProcessing finished for ListView '$($ListViewControl.Name)'."
|
||||||
|
# Final overall progress update
|
||||||
|
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
|
||||||
|
Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processing complete. Processed $processedCount of $totalItems." -ProgressBarName "progressBar" -StatusLabelName "txtStatus"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Invoke-ParallelProcessing finished (non-UI mode). Processed $processedCount of $totalItems."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return all collected final results from jobs
|
||||||
|
return $resultsCollection
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Invoke-ParallelProcessing
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Provides functions for interacting with WinGet and the Microsoft Store to find, download, and configure applications.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This module contains a set of functions designed to automate application management using the WinGet package manager and the Microsoft Store.
|
||||||
|
It supports checking for and installing WinGet, downloading applications, handling different application types (Win32 and UWP), and generating silent installation commands for Win32 applications.
|
||||||
|
This module is used by both the build script (BuildFFUVM.ps1) and the UI (BuildFFUVM_UI.ps1) to manage application downloads and configuration.
|
||||||
|
#>
|
||||||
|
function Get-Application {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$AppName,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$AppId,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet('winget', 'msstore')]
|
||||||
|
[string]$Source,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$AppsPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$WindowsArch,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$OrchestrationPath
|
||||||
|
)
|
||||||
|
|
||||||
|
# Block Company Portal from winget source
|
||||||
|
# I refuse to code around the poor packaging of this app
|
||||||
|
if ($AppId -eq 'Microsoft.CompanyPortal' -and $Source -eq 'winget') {
|
||||||
|
WriteLog "Skipping download of Company Portal from the 'winget' source. This version has packaging inconsistencies. Please use the 'msstore' source instead."
|
||||||
|
return 4 # Return specific error code for this case
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine base folder path for checking existence
|
||||||
|
$appIsWin32ForCheck = ($Source -eq 'msstore' -and $AppId.StartsWith("XP"))
|
||||||
|
$appBaseFolderPathForCheck = ""
|
||||||
|
if ($Source -eq 'winget' -or $appIsWin32ForCheck) {
|
||||||
|
$appBaseFolderPathForCheck = Join-Path -Path "$AppsPath\Win32" -ChildPath $AppName
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$appBaseFolderPathForCheck = Join-Path -Path "$AppsPath\MSStore" -ChildPath $AppName
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if the app (any architecture) has already been downloaded by checking for its content folder.
|
||||||
|
# This prevents re-downloading if BuildFFUVM.ps1 is run after downloading via the UI.
|
||||||
|
if (Test-Path -Path $appBaseFolderPathForCheck -PathType Container) {
|
||||||
|
# Check if the folder is not empty.
|
||||||
|
if (Get-ChildItem -Path $appBaseFolderPathForCheck -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1) {
|
||||||
|
WriteLog "Application '$AppName' appears to be already downloaded as content exists in '$appBaseFolderPathForCheck'. Skipping download."
|
||||||
|
return 0 # Success, already present
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate app exists in repository
|
||||||
|
$wingetSearchResult = Find-WinGetPackage -id $AppId -MatchOption Equals -Source $Source
|
||||||
|
if (-not $wingetSearchResult) {
|
||||||
|
if ($VerbosePreference -ne 'Continue') {
|
||||||
|
Write-Error "$AppName not found in $Source repository."
|
||||||
|
Write-Error "Check the AppList.json file and make sure the AppID is correct."
|
||||||
|
}
|
||||||
|
WriteLog "$AppName not found in $Source repository."
|
||||||
|
WriteLog "Check the AppList.json file and make sure the AppID is correct."
|
||||||
|
return 1 # Return error code
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine architectures to download
|
||||||
|
$architecturesToDownload = if ($WindowsArch -eq 'x86 x64') { @('x86', 'x64') } else { @($WindowsArch) }
|
||||||
|
$overallResult = 0
|
||||||
|
|
||||||
|
# For msstore, we don't specify architecture, so we only need to loop once.
|
||||||
|
if ($Source -eq 'msstore') {
|
||||||
|
$architecturesToDownload = @('neutral') # Use a placeholder to loop once
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($arch in $architecturesToDownload) {
|
||||||
|
if ($Source -eq 'msstore') {
|
||||||
|
WriteLog "Processing '$AppName' for all architectures."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Processing '$AppName' for architecture '$arch'."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine app type and folder path
|
||||||
|
$appIsWin32 = ($Source -eq 'msstore' -and $AppId.StartsWith("XP"))
|
||||||
|
if ($Source -eq 'winget' -or $appIsWin32) {
|
||||||
|
$appBaseFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $AppName
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$appBaseFolderPath = Join-Path -Path "$AppsPath\MSStore" -ChildPath $AppName
|
||||||
|
}
|
||||||
|
|
||||||
|
# If downloading multiple archs for a Win32 app, create a subfolder
|
||||||
|
$appFolderPath = $appBaseFolderPath
|
||||||
|
$subFolderForCommand = $null
|
||||||
|
if ($architecturesToDownload.Count -gt 1 -and ($Source -eq 'winget' -or $appIsWin32)) {
|
||||||
|
$appFolderPath = Join-Path -Path $appBaseFolderPath -ChildPath $arch
|
||||||
|
$subFolderForCommand = $arch
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create app folder
|
||||||
|
New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null
|
||||||
|
|
||||||
|
# Build download parameters and log information
|
||||||
|
$downloadParams = @{
|
||||||
|
id = $AppId
|
||||||
|
DownloadDirectory = $appFolderPath
|
||||||
|
Source = $Source
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Source -ne 'msstore') {
|
||||||
|
$downloadParams.Architecture = $arch
|
||||||
|
WriteLog "Downloading $AppName for $arch architecture..."
|
||||||
|
WriteLog "WinGet command: Export-WinGetPackage -id $AppId -DownloadDirectory `"$appFolderPath`" -Architecture $arch -Source $Source"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Downloading $AppName for all architectures..."
|
||||||
|
WriteLog 'MSStore app downloads require authentication with an Entra ID account. You may be prompted twice for credentials, once for the app and another for the license file.'
|
||||||
|
WriteLog "WinGet command: Export-WinGetPackage -id $AppId -DownloadDirectory `"$appFolderPath`" -Source $Source"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download the app
|
||||||
|
$wingetDownloadResult = Export-WinGetPackage @downloadParams
|
||||||
|
|
||||||
|
# Handle download status
|
||||||
|
if ($wingetDownloadResult.status -ne 'Ok') {
|
||||||
|
# For winget source, try downloading without architecture if the specified one fails
|
||||||
|
if (($Source -eq 'winget') -and ($wingetDownloadResult.status -eq 'NoApplicableInstallers' -or $wingetDownloadResult.status -eq 'NoApplicableInstallerFound')) {
|
||||||
|
WriteLog "No installer found for $arch architecture. Attempting to download without specifying architecture..."
|
||||||
|
# Remove the architecture parameter and try again
|
||||||
|
$downloadParams.Remove('Architecture')
|
||||||
|
$wingetDownloadResult = Export-WinGetPackage @downloadParams
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-evaluate status after potential second attempt
|
||||||
|
if ($wingetDownloadResult.status -ne 'Ok') {
|
||||||
|
# Handle Store-specific publisher restriction error
|
||||||
|
if ($Source -eq 'msstore' -and $wingetDownloadResult.ExtendedErrorCode -match '0x8A150084') {
|
||||||
|
$errorMessage = "The Microsoft Store app $AppName does not support downloads by the publisher. Please remove it from the AppList.json. If there's a winget source version of the application, try using that instead. Exiting."
|
||||||
|
WriteLog $errorMessage
|
||||||
|
Remove-Item -Path $appFolderPath -Recurse -Force
|
||||||
|
Write-Error $errorMessage
|
||||||
|
return 3 # Return specific error code for publisher restriction
|
||||||
|
}
|
||||||
|
# Handle other download failures
|
||||||
|
else {
|
||||||
|
$errormsg = "Download failed for $AppName with status: $($wingetDownloadResult.status) $($wingetDownloadResult.ExtendedErrorCode)"
|
||||||
|
WriteLog $errormsg
|
||||||
|
Remove-Item -Path $appFolderPath -Recurse -Force
|
||||||
|
Write-Error $errormsg
|
||||||
|
return 1 # Return generic error code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Downloaded $AppName without specifying architecture."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog "$AppName ($arch) downloaded to $appFolderPath"
|
||||||
|
|
||||||
|
# Handle zip files
|
||||||
|
$zipFile = Get-ChildItem -Path $appFolderPath -Filter "*.zip" -File -ErrorAction SilentlyContinue
|
||||||
|
if ($zipFile) {
|
||||||
|
WriteLog "Found zip file: $($zipFile.FullName). Extracting..."
|
||||||
|
Expand-Archive -Path $zipFile.FullName -DestinationPath $appFolderPath -Force
|
||||||
|
WriteLog "Extraction complete. Removing zip file."
|
||||||
|
Remove-Item -Path $zipFile.FullName -Force
|
||||||
|
WriteLog "Zip file removed."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle winget source apps that have appx, appxbundle, msix, or msixbundle extensions but were downloaded to the Win32 folder
|
||||||
|
$installerFiles = Get-ChildItem -Path "$appFolderPath\*" -Exclude "*.yaml", "*.xml" -File -ErrorAction SilentlyContinue
|
||||||
|
$uwpExtensions = @(".appx", ".appxbundle", ".msix", ".msixbundle")
|
||||||
|
$isUwpApp = $false
|
||||||
|
if ($installerFiles) {
|
||||||
|
foreach ($file in $installerFiles) {
|
||||||
|
if ($uwpExtensions -contains $file.Extension) {
|
||||||
|
$isUwpApp = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isUwpApp -and $appFolderPath -match 'Win32') {
|
||||||
|
# Handle UWP apps
|
||||||
|
$NewAppPath = "$AppsPath\MSStore\$AppName"
|
||||||
|
WriteLog "$AppName is a UWP app. Moving to $NewAppPath"
|
||||||
|
WriteLog "Creating $NewAppPath"
|
||||||
|
New-Item -Path "$AppsPath\MSStore\$AppName" -ItemType Directory -Force | Out-Null
|
||||||
|
WriteLog "Moving $AppName to $NewAppPath"
|
||||||
|
Move-Item -Path "$appFolderPath\*" -Destination "$AppsPath\MSStore\$AppName" -Force
|
||||||
|
WriteLog "Removing $appFolderPath"
|
||||||
|
Remove-Item -Path $appFolderPath -Force -Recurse
|
||||||
|
WriteLog "$AppName moved to $NewAppPath"
|
||||||
|
$result = 0 # Success for UWP app
|
||||||
|
}
|
||||||
|
# If app is in Win32 folder, add the silent install command to the WinGetWin32Apps.json file
|
||||||
|
elseif ($appFolderPath -match 'Win32') {
|
||||||
|
WriteLog "$AppName is a Win32 app. Adding silent install command to $OrchestrationPath\WinGetWin32Apps.json"
|
||||||
|
$result = Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $appFolderPath -OrchestrationPath $OrchestrationPath -SubFolder $subFolderForCommand
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# For any other case, set result to 0 (success)
|
||||||
|
$result = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result -ne 0) { $overallResult = $result }
|
||||||
|
|
||||||
|
# Handle MSStore specific post-processing
|
||||||
|
if ($Source -eq 'msstore' -and $appFolderPath -match 'MSStore') {
|
||||||
|
# Handle ARM64-specific dependencies
|
||||||
|
if ($arch -eq 'ARM64') {
|
||||||
|
WriteLog 'Windows architecture is ARM64. Removing dependencies that are not ARM64.'
|
||||||
|
$dependencies = Get-ChildItem -Path "$appFolderPath\Dependencies" -ErrorAction SilentlyContinue
|
||||||
|
if ($dependencies) {
|
||||||
|
foreach ($dependency in $dependencies) {
|
||||||
|
if ($dependency.Name -notmatch 'ARM64') {
|
||||||
|
WriteLog "Removing dependency file $($dependency.FullName)"
|
||||||
|
Remove-Item -Path $dependency.FullName -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up multiple versions (keep only the latest)
|
||||||
|
WriteLog "$AppName has completed downloading. Identifying the latest version of $AppName."
|
||||||
|
$packages = Get-ChildItem -Path "$appFolderPath\*" -Exclude "Dependencies\*", "*.xml", "*.yaml" -File -ErrorAction Stop
|
||||||
|
|
||||||
|
# Find latest version based on signature date
|
||||||
|
$latestPackage = $packages | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1
|
||||||
|
|
||||||
|
# Remove older versions
|
||||||
|
WriteLog "Latest version of $AppName has been identified as $latestPackage. Removing old versions of $AppName that may have downloaded."
|
||||||
|
foreach ($package in $packages) {
|
||||||
|
if ($package.FullName -ne $latestPackage.FullName) {
|
||||||
|
try {
|
||||||
|
WriteLog "Removing $($package.FullName)"
|
||||||
|
Remove-Item -Path $package.FullName -Force
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Failed to delete: $($package.FullName) - $_"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} # End foreach ($arch in $architecturesToDownload)
|
||||||
|
|
||||||
|
return $overallResult
|
||||||
|
}
|
||||||
|
function Get-Apps {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$AppList,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$AppsPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$WindowsArch,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$OrchestrationPath
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load and validate app list
|
||||||
|
$apps = Get-Content -Path $AppList -Raw | ConvertFrom-Json
|
||||||
|
if (-not $apps) {
|
||||||
|
WriteLog "No apps were specified in AppList.json file."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process WinGet apps
|
||||||
|
$wingetApps = $apps.apps | Where-Object { $_.source -eq "winget" }
|
||||||
|
if ($wingetApps) {
|
||||||
|
WriteLog 'Winget apps to be installed:'
|
||||||
|
$wingetApps | ForEach-Object { WriteLog $_.Name }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process Store apps
|
||||||
|
$StoreApps = $apps.apps | Where-Object { $_.source -eq "msstore" }
|
||||||
|
if ($StoreApps) {
|
||||||
|
WriteLog 'Store apps to be installed:'
|
||||||
|
$StoreApps | ForEach-Object { WriteLog $_.Name }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure WinGet is available
|
||||||
|
Confirm-WinGetInstallation -WindowsArch $WindowsArch
|
||||||
|
|
||||||
|
# Create necessary folders
|
||||||
|
$win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32"
|
||||||
|
$storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore"
|
||||||
|
|
||||||
|
# Process WinGet apps
|
||||||
|
if ($wingetApps) {
|
||||||
|
if (-not (Test-Path -Path $win32Folder -PathType Container)) {
|
||||||
|
WriteLog "Creating folder for Winget Win32 apps: $win32Folder"
|
||||||
|
New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null
|
||||||
|
WriteLog "Folder created successfully."
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($wingetApp in $wingetApps) {
|
||||||
|
try {
|
||||||
|
$appArch = if ($wingetApp.PSObject.Properties['architecture']) { $wingetApp.architecture } else { $WindowsArch }
|
||||||
|
Get-Application -AppName $wingetApp.Name -AppId $wingetApp.Id -Source 'winget' -AppsPath $AppsPath -WindowsArch $appArch -OrchestrationPath $OrchestrationPath
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error occurred while processing $($wingetApp.Name): $_"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process Store apps
|
||||||
|
if ($StoreApps) {
|
||||||
|
if (-not (Test-Path -Path $storeAppsFolder -PathType Container)) {
|
||||||
|
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($storeApp in $StoreApps) {
|
||||||
|
try {
|
||||||
|
$appArch = if ($storeApp.PSObject.Properties['architecture']) { $storeApp.architecture } else { $WindowsArch }
|
||||||
|
Get-Application -AppName $storeApp.Name -AppId $storeApp.Id -Source 'msstore' -AppsPath $AppsPath -WindowsArch $appArch -OrchestrationPath $OrchestrationPath
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error occurred while processing $($storeApp.Name): $_"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Install-WinGet {
|
||||||
|
param (
|
||||||
|
[string]$Architecture
|
||||||
|
)
|
||||||
|
$packages = @(
|
||||||
|
@{Name = "VCLibs"; Url = "https://aka.ms/Microsoft.VCLibs.$Architecture.14.00.Desktop.appx"; File = "Microsoft.VCLibs.$Architecture.14.00.Desktop.appx" },
|
||||||
|
@{Name = "UIXaml"; Url = "https://github.com/microsoft/microsoft-ui-xaml/releases/download/v2.8.6/Microsoft.UI.Xaml.2.8.$Architecture.appx"; File = "Microsoft.UI.Xaml.2.8.$Architecture.appx" },
|
||||||
|
@{Name = "WinGet"; Url = "https://aka.ms/getwinget"; File = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle" }
|
||||||
|
)
|
||||||
|
foreach ($package in $packages) {
|
||||||
|
$destination = Join-Path -Path $env:TEMP -ChildPath $package.File
|
||||||
|
WriteLog "Downloading $($package.Name) from $($package.Url) to $destination"
|
||||||
|
Start-BitsTransferWithRetry -Source $package.Url -Destination $destination
|
||||||
|
WriteLog "Installing $($package.Name)..."
|
||||||
|
# Don't show progress bar for Add-AppxPackage - there's a weird issue where the progress stays on the screen after the apps are installed
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
Add-AppxPackage -Path $destination -ErrorAction SilentlyContinue
|
||||||
|
# Set progress preference back to default
|
||||||
|
$ProgressPreference = 'Continue'
|
||||||
|
WriteLog "Removing $($package.Name)..."
|
||||||
|
Remove-Item -Path $destination -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
WriteLog "WinGet installation complete."
|
||||||
|
}
|
||||||
|
function Confirm-WinGetInstallation {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$WindowsArch
|
||||||
|
)
|
||||||
|
|
||||||
|
WriteLog 'Checking if WinGet is installed...'
|
||||||
|
$minVersion = [version]"1.8.1911"
|
||||||
|
|
||||||
|
# Check WinGet PowerShell module
|
||||||
|
$wingetModule = Get-InstalledModule -Name Microsoft.Winget.Client -ErrorAction SilentlyContinue
|
||||||
|
$wingetModuleVersion = [version]$wingetModule.Version
|
||||||
|
if ($wingetModuleVersion -lt $minVersion -or -not $wingetModule) {
|
||||||
|
WriteLog 'Microsoft.Winget.Client module is not installed or is an older version. Installing the latest version...'
|
||||||
|
|
||||||
|
# Handle PSGallery trust settings
|
||||||
|
$PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy
|
||||||
|
if ($PSGalleryTrust -eq 'Untrusted') {
|
||||||
|
WriteLog 'Temporarily setting PSGallery as a trusted repository...'
|
||||||
|
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
|
||||||
|
}
|
||||||
|
|
||||||
|
Install-Module -Name Microsoft.Winget.Client -Force -Repository 'PSGallery'
|
||||||
|
|
||||||
|
if ($PSGalleryTrust -eq 'Untrusted') {
|
||||||
|
WriteLog 'Setting PSGallery back to untrusted repository...'
|
||||||
|
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted
|
||||||
|
WriteLog 'Done'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Installed Microsoft.Winget.Client module version: $($wingetModule.Version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check WinGet CLI
|
||||||
|
$wingetVersion = Get-WinGetVersion
|
||||||
|
if (-not $wingetVersion) {
|
||||||
|
WriteLog "WinGet is not installed. Installing WinGet..."
|
||||||
|
Install-WinGet -Architecture $WindowsArch
|
||||||
|
}
|
||||||
|
elseif ($wingetVersion -match 'v?(\d+\.\d+\.\d+)' -and [version]$matches[1] -lt $minVersion) {
|
||||||
|
WriteLog "The installed version of WinGet $($matches[1]) does not support downloading MSStore apps. Installing the latest version of WinGet..."
|
||||||
|
Install-WinGet -Architecture $WindowsArch
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Installed WinGet version: $wingetVersion"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Add-Win32SilentInstallCommand {
|
||||||
|
param (
|
||||||
|
[string]$AppFolder,
|
||||||
|
[string]$AppFolderPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$OrchestrationPath,
|
||||||
|
[string]$SubFolder
|
||||||
|
)
|
||||||
|
$appName = $AppFolder
|
||||||
|
$installerPath = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction Stop
|
||||||
|
if (-not $installerPath) {
|
||||||
|
WriteLog "No win32 app installers were found. Skipping the inclusion of $AppFolder"
|
||||||
|
Remove-Item -Path $AppFolderPath -Recurse -Force
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
$yamlFile = Get-ChildItem -Path "$appFolderPath\*" -Include "*.yaml" -File -ErrorAction Stop
|
||||||
|
$yamlContent = Get-Content -Path $yamlFile -Raw
|
||||||
|
$silentInstallSwitch = [regex]::Match($yamlContent, 'Silent:\s*(.+)').Groups[1].Value.Replace("'", "").Trim()
|
||||||
|
if (-not $silentInstallSwitch) {
|
||||||
|
WriteLog "Silent install switch for $appName could not be found. Skipping the inclusion of $appName."
|
||||||
|
Remove-Item -Path $appFolderPath -Recurse -Force
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
$installer = Split-Path -Path $installerPath -Leaf
|
||||||
|
|
||||||
|
$basePath = "D:\win32\$AppFolder"
|
||||||
|
if (-not [string]::IsNullOrEmpty($SubFolder)) {
|
||||||
|
$basePath = "$basePath\$SubFolder"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($installerPath.Extension -eq ".exe") {
|
||||||
|
$silentInstallCommand = "$basePath\$installer"
|
||||||
|
}
|
||||||
|
elseif ($installerPath.Extension -eq ".msi") {
|
||||||
|
$silentInstallCommand = "msiexec"
|
||||||
|
$silentInstallSwitch = "/i `"$basePath\$installer`" $silentInstallSwitch"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Path to the JSON file
|
||||||
|
$wingetWin32AppsJson = "$OrchestrationPath\WinGetWin32Apps.json"
|
||||||
|
|
||||||
|
# Initialize or load existing JSON data
|
||||||
|
if (Test-Path -Path $wingetWin32AppsJson) {
|
||||||
|
[array]$appsData = Get-Content -Path $wingetWin32AppsJson -Raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
# Get highest priority value
|
||||||
|
if ($appsData.Count -gt 0) {
|
||||||
|
$highestPriority = $appsData.Count + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$appsData = @()
|
||||||
|
$highestPriority = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create new app entry
|
||||||
|
$newApp = [PSCustomObject]@{
|
||||||
|
Priority = $highestPriority
|
||||||
|
Name = if (-not [string]::IsNullOrEmpty($SubFolder)) { "$appName ($SubFolder)" } else { $appName }
|
||||||
|
CommandLine = $silentInstallCommand
|
||||||
|
Arguments = $silentInstallSwitch
|
||||||
|
}
|
||||||
|
|
||||||
|
$appsData += $newApp
|
||||||
|
$appsData | ConvertTo-Json -Depth 10 | Set-Content -Path $wingetWin32AppsJson
|
||||||
|
|
||||||
|
WriteLog "Added $($newApp.Name) to WinGetWin32Apps.json with priority $highestPriority"
|
||||||
|
|
||||||
|
# Return 0 for success
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# SECTION: Module Export
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Export functions needed by both BuildFFUVM and the UI Core module
|
||||||
|
Export-ModuleMember -Function Get-Application, Get-Apps, Confirm-WinGetInstallation, Add-Win32SilentInstallCommand, Install-Winget
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#
|
||||||
|
# Module manifest for module 'FFU.Common'
|
||||||
|
#
|
||||||
|
# Generated by: Richard Balsley
|
||||||
|
#
|
||||||
|
# Generated on: 6/11/2025
|
||||||
|
#
|
||||||
|
|
||||||
|
@{
|
||||||
|
|
||||||
|
# Script module or binary module file associated with this manifest.
|
||||||
|
RootModule = 'FFU.Common.Core.psm1'
|
||||||
|
|
||||||
|
# Version number of this module.
|
||||||
|
ModuleVersion = '0.0.1'
|
||||||
|
|
||||||
|
# Supported PSEditions
|
||||||
|
# CompatiblePSEditions = @()
|
||||||
|
|
||||||
|
# ID used to uniquely identify this module
|
||||||
|
GUID = '7dac2b8f-e65a-4997-961e-7a5ef5161901'
|
||||||
|
|
||||||
|
# Author of this module
|
||||||
|
Author = 'Richard Balsley'
|
||||||
|
|
||||||
|
# Company or vendor of this module
|
||||||
|
CompanyName = 'Unknown'
|
||||||
|
|
||||||
|
# Copyright statement for this module
|
||||||
|
Copyright = '(c) Richard Balsley. All rights reserved.'
|
||||||
|
|
||||||
|
# Description of the functionality provided by this module
|
||||||
|
Description = 'Common functions shared between FFU Builder UI and the BuildFFUVM.ps1 build script.'
|
||||||
|
|
||||||
|
# Minimum version of the PowerShell engine required by this module
|
||||||
|
# PowerShellVersion = ''
|
||||||
|
|
||||||
|
# Name of the PowerShell host required by this module
|
||||||
|
# PowerShellHostName = ''
|
||||||
|
|
||||||
|
# Minimum version of the PowerShell host required by this module
|
||||||
|
# PowerShellHostVersion = ''
|
||||||
|
|
||||||
|
# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
|
||||||
|
# DotNetFrameworkVersion = ''
|
||||||
|
|
||||||
|
# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
|
||||||
|
# ClrVersion = ''
|
||||||
|
|
||||||
|
# Processor architecture (None, X86, Amd64) required by this module
|
||||||
|
# ProcessorArchitecture = ''
|
||||||
|
|
||||||
|
# Modules that must be imported into the global environment prior to importing this module
|
||||||
|
# RequiredModules = @()
|
||||||
|
|
||||||
|
# Assemblies that must be loaded prior to importing this module
|
||||||
|
# RequiredAssemblies = @()
|
||||||
|
|
||||||
|
# Script files (.ps1) that are run in the caller's environment prior to importing this module.
|
||||||
|
# ScriptsToProcess = @()
|
||||||
|
|
||||||
|
# Type files (.ps1xml) to be loaded when importing this module
|
||||||
|
# TypesToProcess = @()
|
||||||
|
|
||||||
|
# Format files (.ps1xml) to be loaded when importing this module
|
||||||
|
# FormatsToProcess = @()
|
||||||
|
|
||||||
|
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
|
||||||
|
NestedModules = @('FFU.Common.Drivers.psm1',
|
||||||
|
'FFU.Common.Winget.psm1',
|
||||||
|
'FFU.Common.Parallel.psm1')
|
||||||
|
|
||||||
|
# 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,400 @@
|
|||||||
|
<#
|
||||||
|
.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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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 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]@{ Priority = $priority; Name = $name; CommandLine = $commandLine; Arguments = $arguments; Source = $source; CopyStatus = "" }
|
||||||
|
$listView.Items.Add($application)
|
||||||
|
$State.Controls.txtAppName.Text = ""
|
||||||
|
$State.Controls.txtAppCommandLine.Text = ""
|
||||||
|
$State.Controls.txtAppArguments.Text = ""
|
||||||
|
$State.Controls.txtAppSource.Text = ""
|
||||||
|
Update-CopyButtonState -State $State
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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 CopyStatus when saving and ensure Priority is an integer
|
||||||
|
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source
|
||||||
|
$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) {
|
||||||
|
# Ensure all properties exist, add CopyStatus
|
||||||
|
$appObject = [PSCustomObject]@{
|
||||||
|
Priority = $app.Priority # Keep original priority for now
|
||||||
|
Name = $app.Name
|
||||||
|
CommandLine = $app.CommandLine
|
||||||
|
Arguments = if ($app.PSObject.Properties['Arguments']) { $app.Arguments } else { "" } # Handle missing Arguments
|
||||||
|
Source = $app.Source
|
||||||
|
CopyStatus = "" # Initialize CopyStatus
|
||||||
|
}
|
||||||
|
$listView.Items.Add($appObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reorder priorities sequentially after loading
|
||||||
|
Update-ListViewPriorities -ListView $listView
|
||||||
|
# Update the Copy Apps button state
|
||||||
|
Update-CopyButtonState -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
|
||||||
|
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source
|
||||||
|
$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,612 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Contains functions for loading and saving UI configuration.
|
||||||
|
.DESCRIPTION
|
||||||
|
This module provides the core logic for loading and saving the UI configuration. It includes functions to gather settings from the various UI controls, save them to a JSON file, and load settings from a JSON file to populate the UI. This allows users to persist their build configurations and easily switch between different setups.
|
||||||
|
#>
|
||||||
|
function Get-UIConfig {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
# Create hash to store configuration
|
||||||
|
$config = [ordered]@{
|
||||||
|
AllowExternalHardDiskMedia = $State.Controls.chkAllowExternalHardDiskMedia.IsChecked
|
||||||
|
AllowVHDXCaching = $State.Controls.chkAllowVHDXCaching.IsChecked
|
||||||
|
AppListPath = $State.Controls.txtAppListJsonPath.Text
|
||||||
|
AppsPath = $State.Controls.txtApplicationPath.Text
|
||||||
|
AppsScriptVariables = if ($State.Controls.chkDefineAppsScriptVariables.IsChecked) {
|
||||||
|
$vars = @{}
|
||||||
|
foreach ($item in $State.Data.appsScriptVariablesDataList) {
|
||||||
|
$vars[$item.Key] = $item.Value
|
||||||
|
}
|
||||||
|
if ($vars.Count -gt 0) { $vars } else { $null }
|
||||||
|
}
|
||||||
|
else { $null }
|
||||||
|
BuildUSBDrive = $State.Controls.chkBuildUSBDriveEnable.IsChecked
|
||||||
|
CleanupAppsISO = $State.Controls.chkCleanupAppsISO.IsChecked
|
||||||
|
CleanupCaptureISO = $State.Controls.chkCleanupCaptureISO.IsChecked
|
||||||
|
CleanupDeployISO = $State.Controls.chkCleanupDeployISO.IsChecked
|
||||||
|
CleanupDrivers = $State.Controls.chkCleanupDrivers.IsChecked
|
||||||
|
CompactOS = $State.Controls.chkCompactOS.IsChecked
|
||||||
|
CompressDownloadedDriversToWim = $State.Controls.chkCompressDriversToWIM.IsChecked
|
||||||
|
CopyAutopilot = $State.Controls.chkCopyAutopilot.IsChecked
|
||||||
|
CopyDrivers = $State.Controls.chkCopyDrivers.IsChecked
|
||||||
|
CopyOfficeConfigXML = $State.Controls.chkCopyOfficeConfigXML.IsChecked
|
||||||
|
CopyPEDrivers = $State.Controls.chkCopyPEDrivers.IsChecked
|
||||||
|
CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked
|
||||||
|
CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked
|
||||||
|
CreateCaptureMedia = $State.Controls.chkCreateCaptureMedia.IsChecked
|
||||||
|
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
|
||||||
|
CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text
|
||||||
|
Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB
|
||||||
|
DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked
|
||||||
|
DriversFolder = $State.Controls.txtDriversFolder.Text
|
||||||
|
DriversJsonPath = $State.Controls.txtDriversJsonPath.Text
|
||||||
|
FFUCaptureLocation = $State.Controls.txtFFUCaptureLocation.Text
|
||||||
|
FFUDevelopmentPath = $State.Controls.txtFFUDevPath.Text
|
||||||
|
FFUPrefix = $State.Controls.txtVMNamePrefix.Text
|
||||||
|
InstallApps = $State.Controls.chkInstallApps.IsChecked
|
||||||
|
InstallDrivers = $State.Controls.chkInstallDrivers.IsChecked
|
||||||
|
InstallOffice = $State.Controls.chkInstallOffice.IsChecked
|
||||||
|
InstallWingetApps = $State.Controls.chkInstallWingetApps.IsChecked
|
||||||
|
ISOPath = $State.Controls.txtISOPath.Text
|
||||||
|
LogicalSectorSizeBytes = [int]$State.Controls.cmbLogicalSectorSize.SelectedItem.Content
|
||||||
|
# Make = $null
|
||||||
|
MediaType = $State.Controls.cmbMediaType.SelectedItem
|
||||||
|
Memory = [int64]$State.Controls.txtMemory.Text * 1GB
|
||||||
|
# Model = if ($State.Controls.chkDownloadDrivers.IsChecked) {
|
||||||
|
# $selectedModels = $State.Controls.lstDriverModels.Items | Where-Object { $_.IsSelected }
|
||||||
|
# if ($selectedModels.Count -ge 1) {
|
||||||
|
# $selectedModels[0].Model
|
||||||
|
# }
|
||||||
|
# else {
|
||||||
|
# $null
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# else {
|
||||||
|
# $null
|
||||||
|
# }
|
||||||
|
OfficeConfigXMLFile = $State.Controls.txtOfficeConfigXMLFilePath.Text
|
||||||
|
OfficePath = $State.Controls.txtOfficePath.Text
|
||||||
|
Optimize = $State.Controls.chkOptimize.IsChecked
|
||||||
|
OptionalFeatures = $State.Controls.txtOptionalFeatures.Text
|
||||||
|
OrchestrationPath = "$($State.Controls.txtApplicationPath.Text)\Orchestration"
|
||||||
|
PEDriversFolder = $State.Controls.txtPEDriversFolder.Text
|
||||||
|
Processors = [int]$State.Controls.txtProcessors.Text
|
||||||
|
ProductKey = $State.Controls.txtProductKey.Text
|
||||||
|
PromptExternalHardDiskMedia = $State.Controls.chkPromptExternalHardDiskMedia.IsChecked
|
||||||
|
RemoveApps = $State.Controls.chkRemoveApps.IsChecked
|
||||||
|
RemoveFFU = $State.Controls.chkRemoveFFU.IsChecked
|
||||||
|
RemoveUpdates = $State.Controls.chkRemoveUpdates.IsChecked
|
||||||
|
ShareName = $State.Controls.txtShareName.Text
|
||||||
|
UpdateADK = $State.Controls.chkUpdateADK.IsChecked
|
||||||
|
UpdateEdge = $State.Controls.chkUpdateEdge.IsChecked
|
||||||
|
UpdateLatestCU = $State.Controls.chkUpdateLatestCU.IsChecked
|
||||||
|
UpdateLatestDefender = $State.Controls.chkUpdateLatestDefender.IsChecked
|
||||||
|
UpdateLatestMicrocode = $State.Controls.chkUpdateLatestMicrocode.IsChecked
|
||||||
|
UpdateLatestMSRT = $State.Controls.chkUpdateLatestMSRT.IsChecked
|
||||||
|
UpdateLatestNet = $State.Controls.chkUpdateLatestNet.IsChecked
|
||||||
|
UpdateOneDrive = $State.Controls.chkUpdateOneDrive.IsChecked
|
||||||
|
UpdatePreviewCU = $State.Controls.chkUpdatePreviewCU.IsChecked
|
||||||
|
UserAppListPath = "$($State.Controls.txtApplicationPath.Text)\UserAppList.json"
|
||||||
|
USBDriveList = @{}
|
||||||
|
Username = $State.Controls.txtUsername.Text
|
||||||
|
Threads = [int]$State.Controls.txtThreads.Text
|
||||||
|
Verbose = $State.Controls.chkVerbose.IsChecked
|
||||||
|
VMHostIPAddress = $State.Controls.txtVMHostIPAddress.Text
|
||||||
|
VMLocation = $State.Controls.txtVMLocation.Text
|
||||||
|
VMSwitchName = if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
|
||||||
|
$State.Controls.txtCustomVMSwitchName.Text
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$State.Controls.cmbVMSwitchName.SelectedItem
|
||||||
|
}
|
||||||
|
WindowsArch = $State.Controls.cmbWindowsArch.SelectedItem
|
||||||
|
WindowsLang = $State.Controls.cmbWindowsLang.SelectedItem
|
||||||
|
WindowsRelease = [int]$State.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||||
|
WindowsSKU = $State.Controls.cmbWindowsSKU.SelectedItem
|
||||||
|
WindowsVersion = $State.Controls.cmbWindowsVersion.SelectedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
$State.Controls.lstUSBDrives.Items | Where-Object { $_.IsSelected } | ForEach-Object {
|
||||||
|
$config.USBDriveList[$_.Model] = $_.SerialNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-UIValue {
|
||||||
|
param(
|
||||||
|
[string]$ControlName,
|
||||||
|
[string]$PropertyName,
|
||||||
|
[object]$ConfigObject,
|
||||||
|
[string]$ConfigKey,
|
||||||
|
[scriptblock]$TransformValue = $null, # Optional scriptblock to transform the value from config
|
||||||
|
[psobject]$State # Pass the $State object
|
||||||
|
)
|
||||||
|
|
||||||
|
$control = $State.Controls[$ControlName]
|
||||||
|
if ($null -eq $control) {
|
||||||
|
WriteLog "LoadConfig Error: Control '$ControlName' not found in the state object."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Robust check for property existence.
|
||||||
|
$keyExists = $false
|
||||||
|
if ($ConfigObject -is [System.Management.Automation.PSCustomObject] -and $null -ne $ConfigObject.PSObject.Properties) {
|
||||||
|
# Use the Match() method, which returns a collection of matching properties.
|
||||||
|
# If the count is greater than 0, the key exists.
|
||||||
|
try {
|
||||||
|
if (($ConfigObject.PSObject.Properties.Match($ConfigKey)).Count -gt 0) {
|
||||||
|
$keyExists = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "ERROR: Exception while trying to Match key '$ConfigKey' on ConfigObject.PSObject.Properties. Error: $($_.Exception.Message)"
|
||||||
|
# $keyExists remains false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $keyExists) {
|
||||||
|
WriteLog "LoadConfig Info: Key '$ConfigKey' not found in configuration object. Skipping '$ControlName.$PropertyName'."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$valueFromConfig = $ConfigObject.$ConfigKey
|
||||||
|
WriteLog "LoadConfig: Preparing to set '$ControlName.$PropertyName'. Config key: '$ConfigKey', Raw value: '$valueFromConfig'."
|
||||||
|
|
||||||
|
$finalValue = $valueFromConfig
|
||||||
|
if ($null -ne $TransformValue) {
|
||||||
|
try {
|
||||||
|
$finalValue = Invoke-Command -ScriptBlock $TransformValue -ArgumentList $valueFromConfig
|
||||||
|
WriteLog "LoadConfig: Transformed value for '$ControlName.$PropertyName' (from key '$ConfigKey') is: '$finalValue'."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "LoadConfig Error: Failed to transform value for '$ControlName.$PropertyName' from key '$ConfigKey'. Error: $($_.Exception.Message)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Handle ComboBox SelectedItem specifically
|
||||||
|
if ($control -is [System.Windows.Controls.ComboBox] -and $PropertyName -eq 'SelectedItem') {
|
||||||
|
$itemToSelect = $null
|
||||||
|
# Iterate through the Items collection of the ComboBox
|
||||||
|
foreach ($item in $control.Items) {
|
||||||
|
$itemValue = $null
|
||||||
|
if ($item -is [System.Windows.Controls.ComboBoxItem]) {
|
||||||
|
$itemValue = $item.Content
|
||||||
|
}
|
||||||
|
elseif ($item -is [pscustomobject] -and $item.PSObject.Properties['Value']) {
|
||||||
|
$itemValue = $item.Value
|
||||||
|
}
|
||||||
|
elseif ($item -is [pscustomobject] -and $item.PSObject.Properties['Display']) {
|
||||||
|
# Assuming 'Display' might be used if 'Value' isn't
|
||||||
|
$itemValue = $item.Display
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$itemValue = $item # For simple string items or direct object comparison
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compare, ensuring types are compatible or converting $finalValue if necessary
|
||||||
|
if (($null -ne $itemValue -and $itemValue.ToString() -eq $finalValue.ToString()) -or ($item -eq $finalValue)) {
|
||||||
|
$itemToSelect = $item
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $itemToSelect) {
|
||||||
|
$control.SelectedItem = $itemToSelect
|
||||||
|
WriteLog "LoadConfig: Successfully set '$ControlName.SelectedItem' by finding matching item for value '$finalValue'."
|
||||||
|
}
|
||||||
|
elseif ($control.IsEditable -and ($finalValue -is [string] -or $finalValue -is [int] -or $finalValue -is [long])) {
|
||||||
|
$control.Text = $finalValue.ToString()
|
||||||
|
WriteLog "LoadConfig: Set '$ControlName.Text' to '$($finalValue.ToString())' as SelectedItem match failed (editable ComboBox)."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$itemsString = ""
|
||||||
|
try {
|
||||||
|
# Safer way to get item strings
|
||||||
|
$itemStrings = @()
|
||||||
|
foreach ($cbItem in $control.Items) {
|
||||||
|
if ($null -ne $cbItem) { $itemStrings += $cbItem.ToString() } else { $itemStrings += "[NULL_ITEM]" }
|
||||||
|
}
|
||||||
|
$itemsString = $itemStrings -join "; "
|
||||||
|
}
|
||||||
|
catch { $itemsString = "Error retrieving item strings." }
|
||||||
|
WriteLog "LoadConfig Warning: Could not find or set item matching value '$finalValue' for '$ControlName.SelectedItem'. Current items: [$itemsString]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# For other properties or controls
|
||||||
|
$control.$PropertyName = $finalValue
|
||||||
|
WriteLog "LoadConfig: Successfully set '$ControlName.$PropertyName' to '$finalValue'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "LoadConfig Error: Failed to set '$ControlName.$PropertyName' to '$finalValue'. Error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-LoadConfiguration {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
$filePath = Invoke-BrowseAction -Type 'OpenFile' -Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" -Title "Load Configuration File"
|
||||||
|
if (-not $filePath) {
|
||||||
|
WriteLog "Load configuration cancelled by user."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog "Loading configuration from: $filePath"
|
||||||
|
$configContent = Get-Content -Path $filePath -Raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
if ($null -eq $configContent) {
|
||||||
|
WriteLog "LoadConfig Error: configContent is null after parsing $filePath. File might be empty or malformed."
|
||||||
|
[System.Windows.MessageBox]::Show("Failed to parse the configuration file. It might be empty or not valid JSON.", "Load Error", "OK", "Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteLog "LoadConfig: Successfully parsed config file. Top-level keys: $($configContent.PSObject.Properties.Name -join ', ')"
|
||||||
|
|
||||||
|
# Apply the configuration to the UI
|
||||||
|
Update-UIFromConfig -ConfigContent $configContent -State $State
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "LoadConfig FATAL Error: $($_.Exception.ToString())"
|
||||||
|
[System.Windows.MessageBox]::Show("Error loading config file:`n$($_.Exception.Message)", "Error", "OK", "Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Update-UIFromConfig {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$ConfigContent,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
WriteLog "Applying loaded configuration to the UI."
|
||||||
|
|
||||||
|
# Update Build tab values
|
||||||
|
Set-UIValue -ControlName 'txtFFUDevPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUDevelopmentPath' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtCustomFFUNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'CustomFFUNameTemplate' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtFFUCaptureLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUCaptureLocation' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtShareName' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ShareName' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtUsername' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Username' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtThreads' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Threads' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkBuildUSBDriveEnable' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BuildUSBDrive' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCompactOS' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CompactOS' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkUpdateADK' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UpdateADK' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkOptimize' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Optimize' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkAllowVHDXCaching' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowVHDXCaching' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkAllowExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowExternalHardDiskMedia' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkPromptExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'PromptExternalHardDiskMedia' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCreateCaptureMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateCaptureMedia' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCreateDeploymentMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateDeploymentMedia' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkVerbose' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Verbose' -State $State
|
||||||
|
|
||||||
|
# USB Drive Modification group (Build Tab)
|
||||||
|
Set-UIValue -ControlName 'chkCopyAutopilot' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAutopilot' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCopyUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyUnattend' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCopyPPKG' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPPKG' -State $State
|
||||||
|
|
||||||
|
# Post Build Cleanup group (Build Tab)
|
||||||
|
Set-UIValue -ControlName 'chkCleanupAppsISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupAppsISO' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCleanupCaptureISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupCaptureISO' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCleanupDeployISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDeployISO' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCleanupDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDrivers' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkRemoveFFU' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveFFU' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkRemoveApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveApps' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkRemoveUpdates' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveUpdates' -State $State
|
||||||
|
|
||||||
|
# Hyper-V Settings
|
||||||
|
Set-UIValue -ControlName 'cmbVMSwitchName' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'VMSwitchName' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtVMHostIPAddress' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMHostIPAddress' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtDiskSize' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Disksize' -TransformValue { param($val) $val / 1GB } -State $State
|
||||||
|
Set-UIValue -ControlName 'txtMemory' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Memory' -TransformValue { param($val) $val / 1GB } -State $State
|
||||||
|
Set-UIValue -ControlName 'txtProcessors' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Processors' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtVMLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMLocation' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtVMNamePrefix' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUPrefix' -State $State
|
||||||
|
Set-UIValue -ControlName 'cmbLogicalSectorSize' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'LogicalSectorSizeBytes' -TransformValue { param($val) $val.ToString() } -State $State
|
||||||
|
|
||||||
|
# Windows Settings
|
||||||
|
Set-UIValue -ControlName 'txtISOPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ISOPath' -State $State
|
||||||
|
|
||||||
|
# Special handling for Windows Release and SKU due to value collision (e.g., 2019 for Server and LTSC)
|
||||||
|
if (($null -ne $ConfigContent.PSObject.Properties.Item('WindowsRelease')) -and ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsSKU'))) {
|
||||||
|
$configReleaseValue = $ConfigContent.WindowsRelease
|
||||||
|
$configSkuValue = $ConfigContent.WindowsSKU
|
||||||
|
WriteLog "LoadConfig: Handling Windows Release/SKU selection. Release: '$configReleaseValue', SKU: '$configSkuValue'."
|
||||||
|
|
||||||
|
$releaseCombo = $State.Controls.cmbWindowsRelease
|
||||||
|
# The items in the combobox are PSCustomObjects with Display and Value properties
|
||||||
|
$possibleReleases = $releaseCombo.Items | Where-Object { $_.Value -eq $configReleaseValue }
|
||||||
|
|
||||||
|
$releaseToSelect = $null
|
||||||
|
if ($possibleReleases.Count -gt 1) {
|
||||||
|
WriteLog "LoadConfig: Ambiguous release value '$configReleaseValue' found. Using SKU to disambiguate."
|
||||||
|
if ($configSkuValue -like '*LTS*') {
|
||||||
|
$releaseToSelect = $possibleReleases | Where-Object { $_.Display -like '*LTS*' } | Select-Object -First 1
|
||||||
|
WriteLog "LoadConfig: SKU contains 'LTS'. Selecting LTSC-related release: '$($releaseToSelect.Display)'."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$releaseToSelect = $possibleReleases | Where-Object { $_.Display -notlike '*LTS*' } | Select-Object -First 1
|
||||||
|
WriteLog "LoadConfig: SKU does not contain 'LTS'. Selecting non-LTSC (Server) release: '$($releaseToSelect.Display)'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$releaseToSelect = $possibleReleases | Select-Object -First 1
|
||||||
|
if ($null -ne $releaseToSelect) {
|
||||||
|
WriteLog "LoadConfig: Found unique release match: '$($releaseToSelect.Display)'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $releaseToSelect) {
|
||||||
|
$releaseCombo.SelectedItem = $releaseToSelect
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "LoadConfig: Could not determine a specific Windows Release to select for value '$configReleaseValue'. Skipping."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Fallback to individual setting if only one key exists
|
||||||
|
WriteLog "LoadConfig: WindowsRelease or WindowsSKU key not found in config. Falling back to simple assignment for WindowsRelease."
|
||||||
|
Set-UIValue -ControlName 'cmbWindowsRelease' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'WindowsRelease' -State $State
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-UIValue -ControlName 'cmbWindowsVersion' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'WindowsVersion' -State $State
|
||||||
|
Set-UIValue -ControlName 'cmbWindowsArch' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'WindowsArch' -State $State
|
||||||
|
Set-UIValue -ControlName 'cmbWindowsLang' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'WindowsLang' -State $State
|
||||||
|
Set-UIValue -ControlName 'cmbWindowsSKU' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'WindowsSKU' -State $State
|
||||||
|
Set-UIValue -ControlName 'cmbMediaType' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'MediaType' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtProductKey' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ProductKey' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtOptionalFeatures' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'OptionalFeatures' -State $State
|
||||||
|
|
||||||
|
# Update Optional Features checkboxes based on the loaded text
|
||||||
|
$loadedFeaturesString = $State.Controls.txtOptionalFeatures.Text
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($loadedFeaturesString)) {
|
||||||
|
$loadedFeaturesArray = $loadedFeaturesString.Split(';')
|
||||||
|
WriteLog "LoadConfig: Updating Optional Features checkboxes. Loaded features: $($loadedFeaturesArray -join ', ')"
|
||||||
|
foreach ($featureEntry in $State.Controls.featureCheckBoxes.GetEnumerator()) {
|
||||||
|
$featureName = $featureEntry.Key
|
||||||
|
$featureCheckbox = $featureEntry.Value
|
||||||
|
if ($loadedFeaturesArray -contains $featureName) {
|
||||||
|
$featureCheckbox.IsChecked = $true
|
||||||
|
WriteLog "LoadConfig: Checked checkbox for feature '$featureName'."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$featureCheckbox.IsChecked = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# If no optional features are loaded, uncheck all
|
||||||
|
WriteLog "LoadConfig: No optional features string loaded. Unchecking all feature checkboxes."
|
||||||
|
foreach ($featureEntry in $State.Controls.featureCheckBoxes.GetEnumerator()) {
|
||||||
|
$featureEntry.Value.IsChecked = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# M365 Apps/Office tab
|
||||||
|
Set-UIValue -ControlName 'chkInstallOffice' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InstallOffice' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtOfficePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'OfficePath' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCopyOfficeConfigXML' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyOfficeConfigXML' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtOfficeConfigXMLFilePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'OfficeConfigXMLFile' -State $State
|
||||||
|
|
||||||
|
# Drivers tab
|
||||||
|
Set-UIValue -ControlName 'chkInstallDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InstallDrivers' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkDownloadDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'DownloadDrivers' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCopyDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyDrivers' -State $State
|
||||||
|
# Set-UIValue -ControlName 'cmbMake' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'Make' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtDriversFolder' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DriversFolder' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtPEDriversFolder' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'PEDriversFolder' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtDriversJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DriversJsonPath' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCopyPEDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPEDrivers' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkCompressDriversToWIM' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CompressDownloadedDriversToWim' -State $State
|
||||||
|
|
||||||
|
# Updates tab
|
||||||
|
Set-UIValue -ControlName 'chkUpdateLatestCU' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UpdateLatestCU' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkUpdateLatestNet' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UpdateLatestNet' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkUpdateLatestDefender' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UpdateLatestDefender' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkUpdateEdge' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UpdateEdge' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkUpdateOneDrive' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UpdateOneDrive' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkUpdateLatestMSRT' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UpdateLatestMSRT' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkUpdateLatestMicrocode' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UpdateLatestMicrocode' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkUpdatePreviewCU' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UpdatePreviewCU' -State $State
|
||||||
|
|
||||||
|
# Applications tab
|
||||||
|
Set-UIValue -ControlName 'chkInstallApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InstallApps' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkInstallWingetApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InstallWingetApps' -State $State
|
||||||
|
Set-UIValue -ControlName 'chkBringYourOwnApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BringYourOwnApps' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtApplicationPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppsPath' -State $State
|
||||||
|
Set-UIValue -ControlName 'txtAppListJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppListPath' -State $State
|
||||||
|
|
||||||
|
# Handle AppsScriptVariables
|
||||||
|
$appsScriptVarsKeyExists = $false
|
||||||
|
if ($ConfigContent -is [System.Management.Automation.PSCustomObject] -and $null -ne $ConfigContent.PSObject.Properties) {
|
||||||
|
try {
|
||||||
|
if (($ConfigContent.PSObject.Properties.Match('AppsScriptVariables')).Count -gt 0) {
|
||||||
|
$appsScriptVarsKeyExists = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { WriteLog "ERROR: Exception while trying to Match key 'AppsScriptVariables'. Error: $($_.Exception.Message)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$lstAppsScriptVars = $State.Controls.lstAppsScriptVariables
|
||||||
|
$chkDefineAppsScriptVars = $State.Controls.chkDefineAppsScriptVariables
|
||||||
|
$appsScriptVarsPanel = $State.Controls.appsScriptVariablesPanel
|
||||||
|
$State.Data.appsScriptVariablesDataList.Clear()
|
||||||
|
|
||||||
|
if ($appsScriptVarsKeyExists -and $null -ne $ConfigContent.AppsScriptVariables -and $ConfigContent.AppsScriptVariables -is [System.Management.Automation.PSCustomObject]) {
|
||||||
|
WriteLog "LoadConfig: Processing AppsScriptVariables from config."
|
||||||
|
$loadedVars = $ConfigContent.AppsScriptVariables
|
||||||
|
$hasVars = $false
|
||||||
|
foreach ($prop in $loadedVars.PSObject.Properties) {
|
||||||
|
$State.Data.appsScriptVariablesDataList.Add([PSCustomObject]@{ IsSelected = $false; Key = $prop.Name; Value = $prop.Value })
|
||||||
|
$hasVars = $true
|
||||||
|
}
|
||||||
|
if ($hasVars) {
|
||||||
|
$chkDefineAppsScriptVars.IsChecked = $true
|
||||||
|
$appsScriptVarsPanel.Visibility = 'Visible'
|
||||||
|
WriteLog "LoadConfig: Loaded AppsScriptVariables and checked 'Define Apps Script Variables'."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$chkDefineAppsScriptVars.IsChecked = $false
|
||||||
|
$appsScriptVarsPanel.Visibility = 'Collapsed'
|
||||||
|
WriteLog "LoadConfig: AppsScriptVariables key was present but empty. Unchecked 'Define Apps Script Variables'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($appsScriptVarsKeyExists -and $null -ne $ConfigContent.AppsScriptVariables -and $ConfigContent.AppsScriptVariables -is [hashtable]) {
|
||||||
|
# Handle if it's already a hashtable (e.g., from older config or direct creation)
|
||||||
|
WriteLog "LoadConfig: Processing AppsScriptVariables (Hashtable) from config."
|
||||||
|
$loadedVars = $ConfigContent.AppsScriptVariables
|
||||||
|
$hasVars = $false
|
||||||
|
foreach ($keyName in $loadedVars.Keys) {
|
||||||
|
$State.Data.appsScriptVariablesDataList.Add([PSCustomObject]@{ IsSelected = $false; Key = $keyName; Value = $loadedVars[$keyName] })
|
||||||
|
$hasVars = $true
|
||||||
|
}
|
||||||
|
if ($hasVars) {
|
||||||
|
$chkDefineAppsScriptVars.IsChecked = $true
|
||||||
|
$appsScriptVarsPanel.Visibility = 'Visible'
|
||||||
|
WriteLog "LoadConfig: Loaded AppsScriptVariables (Hashtable) and checked 'Define Apps Script Variables'."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$chkDefineAppsScriptVars.IsChecked = $false
|
||||||
|
$appsScriptVarsPanel.Visibility = 'Collapsed'
|
||||||
|
WriteLog "LoadConfig: AppsScriptVariables (Hashtable) key was present but empty. Unchecked 'Define Apps Script Variables'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$chkDefineAppsScriptVars.IsChecked = $false
|
||||||
|
$appsScriptVarsPanel.Visibility = 'Collapsed'
|
||||||
|
WriteLog "LoadConfig Info: Key 'AppsScriptVariables' not found, is null, or not a PSCustomObject/Hashtable. Unchecked 'Define Apps Script Variables'."
|
||||||
|
}
|
||||||
|
# Update the ListView's ItemsSource after populating the data list
|
||||||
|
$lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||||
|
# Update the header checkbox state
|
||||||
|
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
|
||||||
|
Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update USB Drive selection if present in config
|
||||||
|
$usbDriveListKeyExists = $false
|
||||||
|
if ($ConfigContent -is [System.Management.Automation.PSCustomObject] -and $null -ne $ConfigContent.PSObject.Properties) {
|
||||||
|
try {
|
||||||
|
if (($ConfigContent.PSObject.Properties.Match('USBDriveList')).Count -gt 0) {
|
||||||
|
$usbDriveListKeyExists = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "ERROR: Exception while trying to Match key 'USBDriveList' on ConfigContent.PSObject.Properties. Error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($usbDriveListKeyExists -and $null -ne $ConfigContent.USBDriveList) {
|
||||||
|
WriteLog "LoadConfig: Processing USBDriveList from config."
|
||||||
|
# First click the Check USB Drives button to populate the list
|
||||||
|
$State.Controls.btnCheckUSBDrives.RaiseEvent(
|
||||||
|
[System.Windows.RoutedEventArgs]::new(
|
||||||
|
[System.Windows.Controls.Button]::ClickEvent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then select the drives that match the saved configuration
|
||||||
|
foreach ($item in $State.Controls.lstUSBDrives.Items) {
|
||||||
|
$propertyName = $item.Model
|
||||||
|
$propertyExists = $false
|
||||||
|
$propertyValue = $null
|
||||||
|
|
||||||
|
# Ensure USBDriveList is a PSCustomObject before trying to access its properties dynamically
|
||||||
|
if ($null -ne $ConfigContent.USBDriveList -and $ConfigContent.USBDriveList -is [System.Management.Automation.PSCustomObject]) {
|
||||||
|
# Check if the property exists on the USBDriveList object
|
||||||
|
if ($ConfigContent.USBDriveList.PSObject.Properties.Match($propertyName).Count -gt 0) {
|
||||||
|
$propertyExists = $true
|
||||||
|
# Access the value dynamically
|
||||||
|
$propertyValue = $ConfigContent.USBDriveList.$($propertyName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($propertyExists -and ($propertyValue -eq $item.SerialNumber)) {
|
||||||
|
WriteLog "LoadConfig: Selecting USB Drive Model '$($item.Model)' with Serial '$($item.SerialNumber)'."
|
||||||
|
$item.IsSelected = $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (-not $propertyExists -and ($null -ne $ConfigContent.USBDriveList)) {
|
||||||
|
WriteLog "LoadConfig: Property '$($propertyName)' not found on USBDriveList for item Model '$($item.Model)'."
|
||||||
|
}
|
||||||
|
$item.IsSelected = $false # Ensure others are deselected if not in config or value mismatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$State.Controls.lstUSBDrives.Items.Refresh()
|
||||||
|
|
||||||
|
# Update the Select All header checkbox state
|
||||||
|
$headerChk = $State.Controls.chkSelectAllUSBDrivesHeader
|
||||||
|
if ($null -ne $headerChk) {
|
||||||
|
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstUSBDrives -HeaderCheckBox $headerChk
|
||||||
|
}
|
||||||
|
WriteLog "LoadConfig: USBDriveList processing complete."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "LoadConfig Info: Key 'USBDriveList' not found or is null in configuration file. Skipping USB drive selection."
|
||||||
|
}
|
||||||
|
|
||||||
|
# If BuildUSBDrive is enabled and USBDriveList was present and not empty in the config,
|
||||||
|
# ensure "Select Specific USB Drives" is checked to show the list.
|
||||||
|
$shouldAutoCheckSpecificDrives = $false
|
||||||
|
if ($State.Controls.chkBuildUSBDriveEnable.IsChecked -and $usbDriveListKeyExists -and ($null -ne $ConfigContent.USBDriveList)) {
|
||||||
|
if ($ConfigContent.USBDriveList -is [System.Management.Automation.PSCustomObject]) {
|
||||||
|
if ($ConfigContent.USBDriveList.PSObject.Properties.Count -gt 0) {
|
||||||
|
$shouldAutoCheckSpecificDrives = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($ConfigContent.USBDriveList -is [hashtable]) {
|
||||||
|
# Fallback for older configs
|
||||||
|
if ($ConfigContent.USBDriveList.Keys.Count -gt 0) {
|
||||||
|
$shouldAutoCheckSpecificDrives = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($shouldAutoCheckSpecificDrives) {
|
||||||
|
WriteLog "LoadConfig: Auto-checking 'Select Specific USB Drives' due to pre-selected USB drives in config."
|
||||||
|
$State.Controls.chkSelectSpecificUSBDrives.IsChecked = $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "LoadConfig: Condition to auto-check 'Select Specific USB Drives' was NOT met."
|
||||||
|
}
|
||||||
|
WriteLog "LoadConfig: Configuration loading process finished."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-SaveConfiguration {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
$config = Get-UIConfig -State $State
|
||||||
|
$defaultConfigPath = Join-Path $config.FFUDevelopmentPath "config"
|
||||||
|
if (-not (Test-Path $defaultConfigPath)) {
|
||||||
|
New-Item -Path $defaultConfigPath -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$savePath = Invoke-BrowseAction -Type 'SaveFile' `
|
||||||
|
-Title "Save Configuration File" `
|
||||||
|
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
|
||||||
|
-InitialDirectory $defaultConfigPath `
|
||||||
|
-FileName "FFUConfig.json" `
|
||||||
|
-DefaultExt ".json"
|
||||||
|
|
||||||
|
if ($savePath) {
|
||||||
|
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $savePath -Encoding UTF8
|
||||||
|
[System.Windows.MessageBox]::Show("Configuration file saved to:`n$savePath", "Success", "OK", "Information")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
[System.Windows.MessageBox]::Show("Error saving config file:`n$($_.Exception.Message)", "Error", "OK", "Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function *
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Provides functions for discovering, downloading, and processing Dell device drivers.
|
||||||
|
.DESCRIPTION
|
||||||
|
This module contains the logic specific to handling Dell drivers for the FFU Builder UI. It includes functions to parse Dell's large XML driver catalog to retrieve a list of supported models (Get-DellDriversModelList). It also provides a parallel-capable task function (Save-DellDriversTask) that finds, downloads, extracts, and optionally compresses all the latest driver packages for a specified Dell model and operating system.
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Function to get the list of Dell models from the catalog using XML streaming
|
||||||
|
function Get-DellDriversModelList {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[int]$WindowsRelease,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$DriversFolder, # Base drivers folder (e.g., C:\FFUDevelopment\Drivers)
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Make # Should be 'Dell'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define Dell specific drivers folder and catalog file names
|
||||||
|
$dellDriversFolder = Join-Path -Path $DriversFolder -ChildPath "Dell"
|
||||||
|
$catalogBaseName = if ($WindowsRelease -le 11) { "CatalogPC" } else { "Catalog" }
|
||||||
|
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
|
||||||
|
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
|
||||||
|
$catalogUrl = if ($WindowsRelease -le 11) { "http://downloads.dell.com/catalog/CatalogPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
|
||||||
|
|
||||||
|
$uniqueModelNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||||
|
$reader = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Check if the Dell catalog XML exists and is recent
|
||||||
|
$downloadCatalog = $true
|
||||||
|
if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
|
||||||
|
WriteLog "Dell Catalog XML found: $dellCatalogXML"
|
||||||
|
$dellCatalogCreationTime = (Get-Item $dellCatalogXML).CreationTime
|
||||||
|
WriteLog "Dell Catalog XML Creation time: $dellCatalogCreationTime"
|
||||||
|
# Check if the XML file is less than 7 days old
|
||||||
|
if (((Get-Date) - $dellCatalogCreationTime).TotalDays -lt 7) {
|
||||||
|
WriteLog "Using existing Dell Catalog XML (less than 7 days old): $dellCatalogXML"
|
||||||
|
$downloadCatalog = $false
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Existing Dell Catalog XML is older than 7 days: $dellCatalogXML"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Dell Catalog XML not found: $dellCatalogXML"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($downloadCatalog) {
|
||||||
|
WriteLog "Attempting to download and extract Dell Catalog for Get-DellDriversModelList..."
|
||||||
|
# Ensure Dell drivers folder exists
|
||||||
|
if (-not (Test-Path -Path $dellDriversFolder -PathType Container)) {
|
||||||
|
WriteLog "Creating Dell drivers folder: $dellDriversFolder"
|
||||||
|
New-Item -Path $dellDriversFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check URL accessibility
|
||||||
|
try {
|
||||||
|
$request = [System.Net.WebRequest]::Create($catalogUrl)
|
||||||
|
$request.Method = 'HEAD'; $response = $request.GetResponse(); $response.Close()
|
||||||
|
}
|
||||||
|
catch { throw "Dell Catalog URL '$catalogUrl' not accessible: $($_.Exception.Message)" }
|
||||||
|
|
||||||
|
# Remove existing files before download if they exist
|
||||||
|
if (Test-Path -Path $dellCabFile) { Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue }
|
||||||
|
if (Test-Path -Path $dellCatalogXML) { Remove-Item -Path $dellCatalogXML -Force -ErrorAction SilentlyContinue }
|
||||||
|
|
||||||
|
WriteLog "Downloading Dell Catalog cab file: $catalogUrl to $dellCabFile"
|
||||||
|
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $dellCabFile
|
||||||
|
WriteLog "Dell Catalog cab file downloaded to $dellCabFile"
|
||||||
|
|
||||||
|
WriteLog "Extracting Dell Catalog cab file '$dellCabFile' to '$dellCatalogXML'"
|
||||||
|
Invoke-Process -FilePath "Expand.exe" -ArgumentList """$dellCabFile"" ""$dellCatalogXML""" | Out-Null
|
||||||
|
WriteLog "Dell Catalog cab file extracted to $dellCatalogXML"
|
||||||
|
|
||||||
|
# Delete the CAB file after extraction
|
||||||
|
WriteLog "Deleting Dell Catalog CAB file: $dellCabFile"
|
||||||
|
Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure the XML file exists before trying to read it
|
||||||
|
if (-not (Test-Path -Path $dellCatalogXML -PathType Leaf)) {
|
||||||
|
throw "Dell Catalog XML file '$dellCatalogXML' not found after download/check attempt."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use XmlReader for streaming from the XML file
|
||||||
|
$settings = New-Object System.Xml.XmlReaderSettings
|
||||||
|
$settings.IgnoreWhitespace = $true
|
||||||
|
$settings.IgnoreComments = $true
|
||||||
|
# $settings.DtdProcessing = [System.Xml.DtdProcessing]::Ignore # Optional
|
||||||
|
|
||||||
|
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
|
||||||
|
WriteLog "Starting XML stream parsing for Dell models from '$dellCatalogXML'..."
|
||||||
|
|
||||||
|
$isDriverComponent = $false
|
||||||
|
$isModelElement = $false
|
||||||
|
$modelDepth = -1 # Track depth to handle nested elements if needed
|
||||||
|
|
||||||
|
# Read through the XML stream node by node
|
||||||
|
while ($reader.Read()) {
|
||||||
|
switch ($reader.NodeType) {
|
||||||
|
([System.Xml.XmlNodeType]::Element) {
|
||||||
|
switch ($reader.Name) {
|
||||||
|
'SoftwareComponent' { $isDriverComponent = $false } # Reset flag
|
||||||
|
'ComponentType' { if ($reader.GetAttribute('value') -eq 'DRVR') { $isDriverComponent = $true } }
|
||||||
|
'Model' { if ($isDriverComponent) { $isModelElement = $true; $modelDepth = $reader.Depth } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
([System.Xml.XmlNodeType]::CDATA) {
|
||||||
|
if ($isModelElement -and $isDriverComponent) {
|
||||||
|
$modelName = $reader.Value.Trim()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($modelName)) { $uniqueModelNames.Add($modelName) | Out-Null }
|
||||||
|
$isModelElement = $false # Reset after reading CDATA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
([System.Xml.XmlNodeType]::EndElement) {
|
||||||
|
switch ($reader.Name) {
|
||||||
|
'SoftwareComponent' { $isDriverComponent = $false; $isModelElement = $false; $modelDepth = -1 }
|
||||||
|
'Model' { if ($reader.Depth -eq $modelDepth) { $isModelElement = $false; $modelDepth = -1 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} # End while ($reader.Read())
|
||||||
|
|
||||||
|
WriteLog "Finished XML stream parsing. Found $($uniqueModelNames.Count) unique Dell models."
|
||||||
|
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error getting Dell models: $($_.Exception.ToString())" # Log full exception
|
||||||
|
throw "Failed to retrieve Dell models. Check log for details." # Re-throw for UI handling
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# Ensure the reader is closed and disposed
|
||||||
|
if ($null -ne $reader) {
|
||||||
|
$reader.Dispose()
|
||||||
|
}
|
||||||
|
# Ensure CAB file is deleted even if extraction failed but download succeeded
|
||||||
|
if (Test-Path -Path $dellCabFile) {
|
||||||
|
WriteLog "Cleaning up downloaded Dell CAB file: $dellCabFile"
|
||||||
|
Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert HashSet to sorted list of PSCustomObjects
|
||||||
|
$models = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
foreach ($modelName in ($uniqueModelNames | Sort-Object)) {
|
||||||
|
$models.Add([PSCustomObject]@{
|
||||||
|
Make = $Make
|
||||||
|
Model = $modelName
|
||||||
|
# Link is not applicable here like for Microsoft
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return $models
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to download and extract drivers for a specific Dell model (Modified for ForEach-Object -Parallel)
|
||||||
|
function Save-DellDriversTask {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[PSCustomObject]$DriverItemData, # Contains Model property
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$DriversFolder, # Base drivers folder (e.g., C:\FFUDevelopment\Drivers)
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$WindowsArch,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[int]$WindowsRelease,
|
||||||
|
[Parameter()] # Made optional
|
||||||
|
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
|
||||||
|
[Parameter()]
|
||||||
|
[bool]$CompressToWim = $false # New parameter for compression
|
||||||
|
)
|
||||||
|
|
||||||
|
$modelName = $DriverItemData.Model
|
||||||
|
$make = "Dell" # Hardcoded for this task
|
||||||
|
$status = "Starting..." # Initial local status
|
||||||
|
$success = $false
|
||||||
|
|
||||||
|
# Initial status update
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." }
|
||||||
|
|
||||||
|
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
|
||||||
|
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
|
||||||
|
$driverRelativePath = Join-Path -Path $make -ChildPath $modelName # Relative path for the driver folder
|
||||||
|
|
||||||
|
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') {
|
||||||
|
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($modelName).wim"
|
||||||
|
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
|
||||||
|
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Compressing existing..." }
|
||||||
|
try {
|
||||||
|
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $modelName -WimDescription "Drivers for $modelName" -ErrorAction Stop
|
||||||
|
$existingDriver.Status = "Already downloaded & Compressed"
|
||||||
|
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($modelName).wim"
|
||||||
|
$existingDriver.Success = $true
|
||||||
|
WriteLog "Successfully compressed existing drivers for $modelName to $wimFilePath."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error compressing existing drivers for $($modelName): $($_.Exception.Message)"
|
||||||
|
$existingDriver.Status = "Already downloaded (Compression failed)"
|
||||||
|
$existingDriver.Success = $false
|
||||||
|
}
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $existingDriver.Status }
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existingDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
# Define paths for Dell catalog. The catalog is assumed to be prepared by the calling function.
|
||||||
|
$dellDriversFolder = Join-Path -Path $DriversFolder -ChildPath "Dell"
|
||||||
|
$catalogBaseName = if ($WindowsRelease -le 11) { "CatalogPC" } else { "Catalog" }
|
||||||
|
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
|
||||||
|
|
||||||
|
# 3. Parse the *EXISTING* XML and Find Drivers for *this specific model*
|
||||||
|
$status = "Finding drivers..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
|
||||||
|
# Check if the provided XML path exists
|
||||||
|
if (-not (Test-Path -Path $dellCatalogXML -PathType Leaf)) {
|
||||||
|
throw "Dell Catalog XML file not found at specified path: $dellCatalogXML"
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog "Parsing existing Dell Catalog XML for model '$modelName' from: $dellCatalogXML"
|
||||||
|
|
||||||
|
# Initialize variables
|
||||||
|
$baseLocation = $null
|
||||||
|
$latestDrivers = @{} # Hashtable to store latest drivers for this model
|
||||||
|
$modelSpecificDriversFound = $false
|
||||||
|
|
||||||
|
# Create XML reader settings
|
||||||
|
$settings = New-Object System.Xml.XmlReaderSettings
|
||||||
|
$settings.IgnoreWhitespace = $true
|
||||||
|
$settings.IgnoreComments = $true
|
||||||
|
|
||||||
|
# Create XML reader
|
||||||
|
$reader = $null
|
||||||
|
try {
|
||||||
|
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
|
||||||
|
|
||||||
|
# First pass - get baseLocation from manifest
|
||||||
|
while ($reader.Read()) {
|
||||||
|
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq "Manifest") {
|
||||||
|
$baseLocationAttr = $reader.GetAttribute("baseLocation")
|
||||||
|
if ($null -ne $baseLocationAttr) {
|
||||||
|
$baseLocation = "https://" + $baseLocationAttr + "/"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $baseLocation) {
|
||||||
|
throw "Invalid Dell Catalog XML format: Missing 'baseLocation' attribute in Manifest element."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reset reader for second pass
|
||||||
|
$reader.Dispose()
|
||||||
|
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
|
||||||
|
|
||||||
|
# Process SoftwareComponents
|
||||||
|
while ($reader.Read()) {
|
||||||
|
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq "SoftwareComponent") {
|
||||||
|
# Read the entire SoftwareComponent subtree
|
||||||
|
$componentXml = $reader.ReadSubtree()
|
||||||
|
$component = New-Object System.Xml.XmlDocument
|
||||||
|
$component.Load($componentXml)
|
||||||
|
$componentXml.Dispose()
|
||||||
|
|
||||||
|
# Check if it's a driver component
|
||||||
|
$componentTypeNode = $component.SelectSingleNode("//ComponentType[@value='DRVR']")
|
||||||
|
if ($null -eq $componentTypeNode) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if component supports the model
|
||||||
|
$modelNodes = $component.SelectNodes("//SupportedSystems/Brand/Model")
|
||||||
|
$modelMatch = $false
|
||||||
|
|
||||||
|
foreach ($modelNode in $modelNodes) {
|
||||||
|
$displayNode = $modelNode.SelectSingleNode("Display")
|
||||||
|
if ($null -ne $displayNode -and $displayNode.InnerText.Trim() -eq $modelName) {
|
||||||
|
$modelMatch = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($modelMatch) {
|
||||||
|
# Check OS compatibility
|
||||||
|
$validOS = $null
|
||||||
|
$osNodes = $component.SelectNodes("//SupportedOperatingSystems/OperatingSystem")
|
||||||
|
|
||||||
|
if ($null -ne $osNodes) {
|
||||||
|
foreach ($osNode in $osNodes) {
|
||||||
|
$osArch = $osNode.GetAttribute("osArch")
|
||||||
|
|
||||||
|
if ($WindowsRelease -le 11) {
|
||||||
|
# Client OS check
|
||||||
|
if ($osArch -eq $WindowsArch) {
|
||||||
|
$validOS = $osNode
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Server OS check
|
||||||
|
$osCode = $osNode.GetAttribute("osCode")
|
||||||
|
$osCodePattern = switch ($WindowsRelease) {
|
||||||
|
2016 { "W14" }
|
||||||
|
2019 { "W19" }
|
||||||
|
2022 { "W22" }
|
||||||
|
2025 { "W25" }
|
||||||
|
default { "W22" }
|
||||||
|
}
|
||||||
|
if ($osArch -eq $WindowsArch -and $osCode -match $osCodePattern) {
|
||||||
|
$validOS = $osNode
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($validOS) {
|
||||||
|
$modelSpecificDriversFound = $true
|
||||||
|
|
||||||
|
# Extract driver information
|
||||||
|
$driverPath = $component.SoftwareComponent.GetAttribute("path")
|
||||||
|
$downloadUrl = $baseLocation + $driverPath
|
||||||
|
$driverFileName = [System.IO.Path]::GetFileName($driverPath)
|
||||||
|
|
||||||
|
# Get name
|
||||||
|
$nameNode = $component.SelectSingleNode("//Name/Display")
|
||||||
|
$name = if ($null -ne $nameNode) { $nameNode.InnerText } else { "UnknownDriver" }
|
||||||
|
$name = $name -replace '[\\\/\:\*\?\"\<\>\| ]', '_' -replace '[\,]', '-'
|
||||||
|
|
||||||
|
# Get category
|
||||||
|
$categoryNode = $component.SelectSingleNode("//Category/Display")
|
||||||
|
$category = if ($null -ne $categoryNode) { $categoryNode.InnerText } else { "Uncategorized" }
|
||||||
|
$category = $category -replace '[\\\/\:\*\?\"\<\>\| ]', '_'
|
||||||
|
|
||||||
|
# Get version
|
||||||
|
$version = [version]"0.0"
|
||||||
|
$vendorVersion = $component.SoftwareComponent.GetAttribute("vendorVersion")
|
||||||
|
if ($null -ne $vendorVersion) {
|
||||||
|
try { $version = [version]$vendorVersion } catch { WriteLog "Warning: Could not parse version '$vendorVersion' for driver '$name'. Using 0.0." }
|
||||||
|
}
|
||||||
|
|
||||||
|
$namePrefix = ($name -split '-')[0]
|
||||||
|
|
||||||
|
# Store the latest version for each category/prefix combination
|
||||||
|
if (-not $latestDrivers.ContainsKey($category)) { $latestDrivers[$category] = @{} }
|
||||||
|
if (-not $latestDrivers[$category].ContainsKey($namePrefix) -or $latestDrivers[$category][$namePrefix].Version -lt $version) {
|
||||||
|
$latestDrivers[$category][$namePrefix] = [PSCustomObject]@{
|
||||||
|
Name = $name
|
||||||
|
DownloadUrl = $downloadUrl
|
||||||
|
DriverFileName = $driverFileName
|
||||||
|
Version = $version
|
||||||
|
Category = $category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($null -ne $reader) {
|
||||||
|
$reader.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog "Searching $($softwareComponents.Count) DRVR components in '$dellCatalogXML' for model '$modelName'..."
|
||||||
|
|
||||||
|
foreach ($component in $softwareComponents) {
|
||||||
|
# Check if SupportedSystems and Brand exist
|
||||||
|
if ($null -eq $component.SupportedSystems -or $null -eq $component.SupportedSystems.Brand) { continue }
|
||||||
|
# Ensure Model is iterable
|
||||||
|
$componentModels = @($component.SupportedSystems.Brand.Model)
|
||||||
|
if ($null -eq $componentModels) { continue }
|
||||||
|
|
||||||
|
$modelMatch = $false
|
||||||
|
foreach ($item in $componentModels) {
|
||||||
|
# Check if Display and its CDATA section exist before accessing
|
||||||
|
if ($null -ne $item.Display -and $null -ne $item.Display.'#cdata-section' -and $item.Display.'#cdata-section'.Trim() -eq $modelName) {
|
||||||
|
$modelMatch = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($modelMatch) {
|
||||||
|
# Model matches, now check OS compatibility
|
||||||
|
$validOS = $null
|
||||||
|
if ($null -ne $component.SupportedOperatingSystems) {
|
||||||
|
# Ensure OperatingSystem is always an array/collection
|
||||||
|
$osList = @($component.SupportedOperatingSystems.OperatingSystem)
|
||||||
|
|
||||||
|
if ($null -ne $osList) {
|
||||||
|
if ($WindowsRelease -le 11) {
|
||||||
|
# Client OS check
|
||||||
|
$validOS = $osList | Where-Object { $_.osArch -eq $WindowsArch } | Select-Object -First 1
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Server OS check
|
||||||
|
$osCodePattern = switch ($WindowsRelease) {
|
||||||
|
2016 { "W14" } # Note: Dell uses W14 for Server 2016
|
||||||
|
2019 { "W19" }
|
||||||
|
2022 { "W22" }
|
||||||
|
2025 { "W25" }
|
||||||
|
default { "W22" } # Fallback, adjust as needed
|
||||||
|
}
|
||||||
|
$validOS = $osList | Where-Object { ($_.osArch -eq $WindowsArch) -and ($_.osCode -match $osCodePattern) } | Select-Object -First 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($validOS) {
|
||||||
|
$modelSpecificDriversFound = $true # Mark that we found at least one relevant driver component
|
||||||
|
$driverPath = $component.path
|
||||||
|
$downloadUrl = $baseLocation + $driverPath
|
||||||
|
$driverFileName = [System.IO.Path]::GetFileName($driverPath)
|
||||||
|
# Check if Name, Display, and CDATA exist
|
||||||
|
$name = "UnknownDriver" # Default name
|
||||||
|
if ($null -ne $component.Name -and $null -ne $component.Name.Display -and $null -ne $component.Name.Display.'#cdata-section') {
|
||||||
|
$name = $component.Name.Display.'#cdata-section'
|
||||||
|
$name = $name -replace '[\\\/\:\*\?\"\<\>\| ]', '_' -replace '[\,]', '-'
|
||||||
|
}
|
||||||
|
# Check if Category, Display, and CDATA exist
|
||||||
|
$category = "Uncategorized" # Default category
|
||||||
|
if ($null -ne $component.Category -and $null -ne $component.Category.Display -and $null -ne $component.Category.Display.'#cdata-section') {
|
||||||
|
$category = $component.Category.Display.'#cdata-section'
|
||||||
|
$category = $category -replace '[\\\/\:\*\?\"\<\>\| ]', '_'
|
||||||
|
}
|
||||||
|
$version = [version]"0.0" # Default version
|
||||||
|
if ($null -ne $component.vendorVersion) {
|
||||||
|
try { $version = [version]$component.vendorVersion } catch { WriteLog "Warning: Could not parse version '$($component.vendorVersion)' for driver '$name'. Using 0.0." }
|
||||||
|
}
|
||||||
|
$namePrefix = ($name -split '-')[0] # Group by prefix within category
|
||||||
|
|
||||||
|
# Store the latest version for each category/prefix combination
|
||||||
|
if (-not $latestDrivers.ContainsKey($category)) { $latestDrivers[$category] = @{} }
|
||||||
|
if (-not $latestDrivers[$category].ContainsKey($namePrefix) -or $latestDrivers[$category][$namePrefix].Version -lt $version) {
|
||||||
|
$latestDrivers[$category][$namePrefix] = [PSCustomObject]@{
|
||||||
|
Name = $name
|
||||||
|
DownloadUrl = $downloadUrl
|
||||||
|
DriverFileName = $driverFileName
|
||||||
|
Version = $version
|
||||||
|
Category = $category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} # End if ($modelMatch)
|
||||||
|
} # End foreach ($component in $softwareComponents)
|
||||||
|
|
||||||
|
if (-not $modelSpecificDriversFound) {
|
||||||
|
$status = "No drivers found for OS"
|
||||||
|
WriteLog "No drivers found for model '$modelName' matching Windows Release '$WindowsRelease' and Arch '$WindowsArch' in '$dellCatalogXML'."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
# Consider this success as the process completed, just no drivers to download
|
||||||
|
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $true }
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Download and Extract Found Drivers (Logic remains largely the same)
|
||||||
|
$totalDriversToProcess = ($latestDrivers.Values | ForEach-Object { $_.Values.Count } | Measure-Object -Sum).Sum
|
||||||
|
$driversProcessed = 0
|
||||||
|
WriteLog "Found $totalDriversToProcess latest driver packages to download for $modelName."
|
||||||
|
|
||||||
|
# Ensure base directories exist before loop
|
||||||
|
if (-not (Test-Path -Path $makeDriversPath)) { New-Item -Path $makeDriversPath -ItemType Directory -Force | Out-Null }
|
||||||
|
if (-not (Test-Path -Path $modelPath)) { New-Item -Path $modelPath -ItemType Directory -Force | Out-Null }
|
||||||
|
|
||||||
|
foreach ($category in $latestDrivers.Keys) {
|
||||||
|
foreach ($driver in $latestDrivers[$category].Values) {
|
||||||
|
$driversProcessed++
|
||||||
|
$status = "Downloading $($driversProcessed)/$($totalDriversToProcess): $($driver.Name)"
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
|
||||||
|
$downloadFolder = Join-Path -Path $modelPath -ChildPath $driver.Category
|
||||||
|
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driver.DriverFileName
|
||||||
|
$extractFolder = Join-Path -Path $downloadFolder -ChildPath $driver.DriverFileName.TrimEnd($driver.DriverFileName[-4..-1])
|
||||||
|
|
||||||
|
# Check if already extracted (more robust check)
|
||||||
|
if (Test-Path -Path $extractFolder -PathType Container) {
|
||||||
|
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||||
|
if ($extractSize -gt 1KB) {
|
||||||
|
WriteLog "Driver already extracted: $($driver.Name) in $extractFolder. Skipping."
|
||||||
|
continue # Skip to next driver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Check if download file exists but extraction folder doesn't or is empty
|
||||||
|
if (Test-Path -Path $driverFilePath -PathType Leaf) {
|
||||||
|
WriteLog "Download file $($driver.DriverFileName) exists, but extraction folder '$extractFolder' is missing or empty. Will attempt extraction."
|
||||||
|
# Proceed to extraction logic below
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Download the driver
|
||||||
|
WriteLog "Downloading driver: $($driver.Name) ($($driver.DriverFileName))"
|
||||||
|
if (-not (Test-Path -Path $downloadFolder)) {
|
||||||
|
WriteLog "Creating download folder: $downloadFolder"
|
||||||
|
New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
WriteLog "Downloading from: $($driver.DownloadUrl) to $driverFilePath"
|
||||||
|
try {
|
||||||
|
Start-BitsTransferWithRetry -Source $driver.DownloadUrl -Destination $driverFilePath
|
||||||
|
WriteLog "Driver downloaded: $($driver.DriverFileName)"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Failed to download driver: $($driver.DownloadUrl). Error: $($_.Exception.Message). Skipping."
|
||||||
|
# Update status for this specific driver failure? Maybe too granular.
|
||||||
|
continue # Skip to next driver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Extract the driver
|
||||||
|
$status = "Extracting $($driversProcessed)/$($totalDriversToProcess): $($driver.Name)"
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
|
||||||
|
# Ensure extraction folder exists before attempting extraction
|
||||||
|
if (-not (Test-Path -Path $extractFolder)) {
|
||||||
|
WriteLog "Creating extraction folder: $extractFolder"
|
||||||
|
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dell uses /e to extact the entire DUP while /drivers to extract only the drivers
|
||||||
|
# In many cases /drivers will extract drivers for mutliple OS versions
|
||||||
|
# Which can cause many duplicate files and bloat your driver folder
|
||||||
|
# /e seems to be better and only extracts what is necessary and has less issues
|
||||||
|
# We will default to using /e, but will fall back to /drivers if content cannot be found
|
||||||
|
|
||||||
|
$arguments = "/s /e=`"$extractFolder`" /l=`"$extractFolder\log.log`""
|
||||||
|
$altArguments = "/s /drivers=`"$extractFolder`" /l=`"$extractFolder\log.log`""
|
||||||
|
$extractionSuccess = $false
|
||||||
|
try {
|
||||||
|
# Handle special cases (Chipset/Network) - Check if OS is Server
|
||||||
|
$osInfo = Get-CimInstance -ClassName Win32_OperatingSystem # Get OS info within the task scope
|
||||||
|
$isServer = $osInfo.Caption -match 'server'
|
||||||
|
|
||||||
|
# Chipset drivers may require killing child processes in some cases
|
||||||
|
if ($driver.Category -eq "Chipset") {
|
||||||
|
WriteLog "Extracting Chipset driver: $driverFilePath $arguments"
|
||||||
|
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait $false
|
||||||
|
Start-Sleep -Seconds 5 # Allow time for extraction
|
||||||
|
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
|
||||||
|
# Attempt to gracefully close child process if needed (logic from original script)
|
||||||
|
$childProcesses = Get-CimInstance Win32_Process -Filter "ParentProcessId = $($process.Id)"
|
||||||
|
if ($childProcesses) {
|
||||||
|
$latestProcess = $childProcesses | Sort-Object CreationDate -Descending | Select-Object -First 1
|
||||||
|
WriteLog "Stopping child process for Chipset driver: $($latestProcess.Name) (PID: $($latestProcess.ProcessId))"
|
||||||
|
Stop-Process -Id $latestProcess.ProcessId -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Network drivers on client OS may require killing child processes
|
||||||
|
elseif ($driver.Category -eq "Network" -and -not $isServer) {
|
||||||
|
WriteLog "Extracting Network driver: $driverFilePath $arguments"
|
||||||
|
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait $false
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
|
||||||
|
if (-not $process.HasExited) {
|
||||||
|
$childProcesses = Get-CimInstance Win32_Process -Filter "ParentProcessId = $($process.Id)"
|
||||||
|
if ($childProcesses) {
|
||||||
|
$latestProcess = $childProcesses | Sort-Object CreationDate -Descending | Select-Object -First 1
|
||||||
|
WriteLog "Stopping child process for Network driver: $($latestProcess.Name) (PID: $($latestProcess.ProcessId))"
|
||||||
|
Stop-Process -Id $latestProcess.ProcessId -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Extracting driver: $driverFilePath $arguments"
|
||||||
|
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments
|
||||||
|
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify extraction (check if folder has content)
|
||||||
|
if (Test-Path -Path $extractFolder -PathType Container) {
|
||||||
|
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||||
|
if ($extractSize -gt 1KB) {
|
||||||
|
$extractionSuccess = $true
|
||||||
|
WriteLog "Extraction successful (Method 1) for $driverFilePath $arguments"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# If primary extraction failed or folder is empty, try alternative
|
||||||
|
if (-not $extractionSuccess) {
|
||||||
|
# $arguments = "/s /e=`"$extractFolder`""
|
||||||
|
# $altArguments = "/s /drivers=`"$extractFolder`""
|
||||||
|
WriteLog "Extraction with $arguments failed or resulted in empty folder for $driverFilePath. Retrying with $altArguments"
|
||||||
|
# Clean up potentially empty folder before retrying
|
||||||
|
Remove-Item -Path $extractFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null # Recreate empty folder
|
||||||
|
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $altArguments
|
||||||
|
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
|
||||||
|
|
||||||
|
# Verify extraction again
|
||||||
|
if (Test-Path -Path $extractFolder -PathType Container) {
|
||||||
|
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||||
|
if ($extractSize -gt 1KB) {
|
||||||
|
$extractionSuccess = $true
|
||||||
|
WriteLog "Extraction successful (Method 2) for $driverFilePath $altArguments"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error during extraction process for $($driver.DriverFileName): $($_.Exception.Message). Trying alternative method."
|
||||||
|
# Try alternative method on any error during the first attempt block
|
||||||
|
try {
|
||||||
|
if (Test-Path -Path $extractFolder) {
|
||||||
|
# Clean up before retry if needed
|
||||||
|
Remove-Item -Path $extractFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
|
||||||
|
# $arguments = "/s /e=`"$extractFolder`""
|
||||||
|
# $altArguments = "/s /drivers=`"$extractFolder`""
|
||||||
|
WriteLog "Extracting driver (Method 2): $driverFilePath $altArguments"
|
||||||
|
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $altArguments
|
||||||
|
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
|
||||||
|
|
||||||
|
# Verify extraction again
|
||||||
|
if (Test-Path -Path $extractFolder -PathType Container) {
|
||||||
|
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||||
|
if ($extractSize -gt 1KB) {
|
||||||
|
$extractionSuccess = $true
|
||||||
|
WriteLog "Extraction successful (Method 2) for $driverFilePath."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Alternative extraction method also failed for $($driver.DriverFileName): $($_.Exception.Message)."
|
||||||
|
# Extraction failed completely
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup downloaded file only if extraction was successful
|
||||||
|
if ($extractionSuccess) {
|
||||||
|
WriteLog "Deleting driver file: $driverFilePath"
|
||||||
|
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
|
||||||
|
WriteLog "Driver file deleted: $driverFilePath"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Extraction failed for $($driver.DriverFileName). Downloaded file kept at $driverFilePath for inspection."
|
||||||
|
# Update status to indicate partial failure?
|
||||||
|
}
|
||||||
|
|
||||||
|
} # End foreach ($driver in $latestDrivers)
|
||||||
|
} # End foreach ($category in $latestDrivers)
|
||||||
|
|
||||||
|
# --- Compress to WIM if requested (after all drivers processed) ---
|
||||||
|
if ($CompressToWim) {
|
||||||
|
$status = "Compressing..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
$wimFileName = "$($modelName).wim"
|
||||||
|
$destinationWimPath = Join-Path -Path $makeDriversPath -ChildPath $wimFileName
|
||||||
|
$driverRelativePath = Join-Path -Path $make -ChildPath $wimFileName # Update relative path to the WIM file
|
||||||
|
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
|
||||||
|
try {
|
||||||
|
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -ErrorAction Stop
|
||||||
|
if ($compressResult) {
|
||||||
|
WriteLog "Compression successful for '$modelName'."
|
||||||
|
$status = "Completed & Compressed"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Compression failed for '$modelName'. Check verbose/error output from Compress-DriverFolderToWim."
|
||||||
|
$status = "Completed (Compression Failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error during compression for '$modelName': $($_.Exception.Message)"
|
||||||
|
$status = "Completed (Compression Error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$status = "Completed" # Final status if not compressing
|
||||||
|
}
|
||||||
|
# --- End Compression ---
|
||||||
|
|
||||||
|
$success = $true # Mark success as download/extract was okay
|
||||||
|
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
|
||||||
|
WriteLog "Error saving Dell drivers for $($modelName): $($_.Exception.ToString())" # Log full exception string
|
||||||
|
$success = $false
|
||||||
|
# Enqueue the error status before returning
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
# Ensure return object is created even on error
|
||||||
|
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; DriverPath = $null }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enqueue the final status (success or error) before returning
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
|
||||||
|
# Return the final status
|
||||||
|
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; DriverPath = $driverRelativePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function *
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Provides functions to retrieve HP model lists and download corresponding driver packs.
|
||||||
|
.DESCRIPTION
|
||||||
|
This module contains the logic specific to handling HP drivers for the FFU Builder UI. It includes functions to:
|
||||||
|
- Download and parse the HP PlatformList.xml to generate a list of supported HP computer models.
|
||||||
|
- For a selected model, find the most appropriate driver pack based on the specified Windows release and version, with intelligent fallback logic.
|
||||||
|
- Download the driver pack, extract all individual driver installers, and then extract the driver files from each installer.
|
||||||
|
- Optionally, compress the final extracted drivers into a single WIM file for easier deployment.
|
||||||
|
These functions are designed to be called by the main UI logic, often in parallel, to efficiently manage driver acquisition.
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Function to get the list of HP models from the PlatformList.xml
|
||||||
|
function Get-HPDriversModelList {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$DriversFolder,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Make # Expected to be 'HP'
|
||||||
|
)
|
||||||
|
|
||||||
|
WriteLog "Getting HP driver model list..."
|
||||||
|
$hpDriversFolder = Join-Path -Path $DriversFolder -ChildPath $Make
|
||||||
|
$platformListUrl = 'https://hpia.hpcloud.hp.com/ref/platformList.cab'
|
||||||
|
$platformListCab = Join-Path -Path $hpDriversFolder -ChildPath "platformList.cab"
|
||||||
|
$platformListXml = Join-Path -Path $hpDriversFolder -ChildPath "PlatformList.xml"
|
||||||
|
$modelList = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Ensure HP drivers folder exists
|
||||||
|
if (-not (Test-Path -Path $hpDriversFolder)) {
|
||||||
|
WriteLog "Creating HP Drivers folder: $hpDriversFolder"
|
||||||
|
New-Item -Path $hpDriversFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download PlatformList.cab if it doesn't exist or is outdated (e.g., older than 7 days)
|
||||||
|
if (-not (Test-Path -Path $platformListCab) -or ((Get-Date) - (Get-Item $platformListCab).LastWriteTime).TotalDays -gt 7) {
|
||||||
|
WriteLog "Downloading $platformListUrl to $platformListCab"
|
||||||
|
# Use the private helper function for download with retry
|
||||||
|
Start-BitsTransferWithRetry -Source $platformListUrl -Destination $platformListCab -ErrorAction Stop
|
||||||
|
WriteLog "PlatformList.cab download complete."
|
||||||
|
# Force extraction if downloaded
|
||||||
|
if (Test-Path -Path $platformListXml) {
|
||||||
|
Remove-Item -Path $platformListXml -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Using existing PlatformList.cab found at $platformListCab"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract PlatformList.xml if it doesn't exist
|
||||||
|
if (-not (Test-Path -Path $platformListXml)) {
|
||||||
|
WriteLog "Expanding $platformListCab to $platformListXml"
|
||||||
|
# Use the private helper function for process invocation
|
||||||
|
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$platformListCab`"", "`"$platformListXml`"") -ErrorAction Stop | Out-Null
|
||||||
|
WriteLog "PlatformList.xml extraction complete."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Using existing PlatformList.xml found at $platformListXml"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse the PlatformList.xml using XmlReader for efficiency
|
||||||
|
WriteLog "Parsing PlatformList.xml to extract HP models..."
|
||||||
|
$settings = New-Object System.Xml.XmlReaderSettings
|
||||||
|
$settings.Async = $false # Ensure synchronous reading
|
||||||
|
|
||||||
|
$reader = [System.Xml.XmlReader]::Create($platformListXml, $settings)
|
||||||
|
$uniqueModels = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||||
|
|
||||||
|
while ($reader.Read()) {
|
||||||
|
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'Platform') {
|
||||||
|
# Read the inner content of the Platform node
|
||||||
|
$platformReader = $reader.ReadSubtree()
|
||||||
|
while ($platformReader.Read()) {
|
||||||
|
if ($platformReader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $platformReader.Name -eq 'ProductName') {
|
||||||
|
$modelName = $platformReader.ReadElementContentAsString()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($modelName) -and $uniqueModels.Add($modelName)) {
|
||||||
|
# Add to list only if it's a new unique model
|
||||||
|
$modelList.Add([PSCustomObject]@{
|
||||||
|
Make = $Make
|
||||||
|
Model = $modelName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$platformReader.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$reader.Close()
|
||||||
|
|
||||||
|
WriteLog "Successfully parsed $($modelList.Count) unique HP models from PlatformList.xml."
|
||||||
|
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error getting HP driver model list: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sort the list alphabetically by Model name before returning
|
||||||
|
return $modelList | Sort-Object -Property Model
|
||||||
|
}
|
||||||
|
# Function to download and extract drivers for a specific HP model (Designed for ForEach-Object -Parallel)
|
||||||
|
function Save-HPDriversTask {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[PSCustomObject]$DriverItemData, # Contains Make, Model
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$DriversFolder,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet("x64", "x86", "ARM64")]
|
||||||
|
[string]$WindowsArch,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet(10, 11)]
|
||||||
|
[int]$WindowsRelease,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$WindowsVersion, # e.g., 22H2, 23H2, etc.
|
||||||
|
[Parameter()] # Made optional
|
||||||
|
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
|
||||||
|
[Parameter()]
|
||||||
|
[bool]$CompressToWim = $false # New parameter for compression
|
||||||
|
)
|
||||||
|
|
||||||
|
$modelName = $DriverItemData.Model
|
||||||
|
$make = $DriverItemData.Make # Should be 'HP'
|
||||||
|
$identifier = $modelName # Unique identifier for progress updates
|
||||||
|
$sanitizedModelName = $modelName -replace '[\\/:"*?<>|]', '_'
|
||||||
|
$hpDriversBaseFolder = Join-Path -Path $DriversFolder -ChildPath $make # Changed variable name for clarity
|
||||||
|
$platformListXml = Join-Path -Path $hpDriversBaseFolder -ChildPath "PlatformList.xml"
|
||||||
|
$modelSpecificFolder = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName # Sanitize model name for folder path
|
||||||
|
$driverRelativePath = Join-Path -Path $make -ChildPath $sanitizedModelName # Relative path for the driver folder
|
||||||
|
$finalStatus = "" # Initialize final status
|
||||||
|
$successState = $true # Assume success unless an operation fails
|
||||||
|
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Checking HP drivers for $modelName..." }
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Check for existing drivers
|
||||||
|
$existingDriver = Test-ExistingDriver -Make $make -Model $sanitizedModelName -DriversFolder $DriversFolder -Identifier $identifier -ProgressQueue $ProgressQueue
|
||||||
|
if ($null -ne $existingDriver) {
|
||||||
|
# The return object from Test-ExistingDriver uses 'Model' as the identifier key.
|
||||||
|
# We need to return 'Identifier' for HP's logic.
|
||||||
|
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Identifier' -Value $identifier -Force
|
||||||
|
$existingDriver.PSObject.Properties.Remove('Model')
|
||||||
|
|
||||||
|
# Special handling for existing folders that need compression
|
||||||
|
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
|
||||||
|
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($sanitizedModelName).wim"
|
||||||
|
$sourceFolderPath = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName
|
||||||
|
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." }
|
||||||
|
try {
|
||||||
|
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -ErrorAction Stop
|
||||||
|
$existingDriver.Status = "Already downloaded & Compressed"
|
||||||
|
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($sanitizedModelName).wim"
|
||||||
|
$existingDriver.Success = $true
|
||||||
|
WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error compressing existing drivers for $($identifier): $($_.Exception.Message)"
|
||||||
|
$existingDriver.Status = "Already downloaded (Compression failed)"
|
||||||
|
$existingDriver.Success = $false
|
||||||
|
}
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $existingDriver.Status }
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existingDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
# If folder does not exist, proceed with download and extraction
|
||||||
|
WriteLog "HP drivers for '$identifier' not found locally. Starting download process..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Downloading..." }
|
||||||
|
|
||||||
|
# Ensure PlatformList.xml exists (it should have been downloaded by Get-HPDriversModelList)
|
||||||
|
if (-not (Test-Path -Path $platformListXml)) {
|
||||||
|
# Attempt to download/extract it again if missing
|
||||||
|
WriteLog "PlatformList.xml not found for HP task, attempting download/extract..."
|
||||||
|
$platformListUrl = 'https://hpia.hpcloud.hp.com/ref/platformList.cab'
|
||||||
|
$platformListCab = Join-Path -Path $hpDriversBaseFolder -ChildPath "platformList.cab"
|
||||||
|
# Base folder already checked/created
|
||||||
|
Start-BitsTransferWithRetry -Source $platformListUrl -Destination $platformListCab -ErrorAction Stop
|
||||||
|
if (Test-Path -Path $platformListXml) { Remove-Item -Path $platformListXml -Force }
|
||||||
|
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$platformListCab`"", "`"$platformListXml`"") -ErrorAction Stop | Out-Null
|
||||||
|
WriteLog "PlatformList.xml download/extract complete for HP task."
|
||||||
|
if (-not (Test-Path -Path $platformListXml)) {
|
||||||
|
throw "Failed to obtain PlatformList.xml for HP driver task."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse PlatformList.xml to find SystemID and OSReleaseID for the specific model
|
||||||
|
WriteLog "Parsing $platformListXml for model '$modelName' details..."
|
||||||
|
[xml]$platformListContent = Get-Content -Path $platformListXml -Raw -Encoding UTF8 -ErrorAction Stop
|
||||||
|
$platformNode = $platformListContent.ImagePal.Platform | Where-Object { $_.ProductName.'#text' -match "^$([regex]::Escape($modelName))$" } | Select-Object -First 1
|
||||||
|
|
||||||
|
if ($null -eq $platformNode) {
|
||||||
|
throw "Model '$modelName' not found in PlatformList.xml."
|
||||||
|
}
|
||||||
|
|
||||||
|
$systemID = $platformNode.SystemID
|
||||||
|
# --- OS Node Selection with Fallback Logic ---
|
||||||
|
$selectedOSNode = $null
|
||||||
|
$selectedOSVersion = $null
|
||||||
|
$selectedOSRelease = $WindowsRelease # Start with the requested release
|
||||||
|
|
||||||
|
# Complete list of Windows 11 feature-update versions (newest to oldest)
|
||||||
|
$win11Versions = @(
|
||||||
|
"24H2", "23H2", "22H2", "21H2"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Complete list of Windows 10 feature-update versions (newest to oldest)
|
||||||
|
$win10Versions = @(
|
||||||
|
"22H2", "21H2", "21H1", "20H2", "2004", "1909", "1903", "1809", "1803", "1709", "1703", "1607", "1511", "1507"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Helper function to find a matching OS node for a given release and version list
|
||||||
|
function Find-MatchingOSNode {
|
||||||
|
param(
|
||||||
|
[int]$ReleaseToSearch,
|
||||||
|
[array]$VersionsToSearch
|
||||||
|
)
|
||||||
|
$osNodesForRelease = $platformNode.OS | Where-Object {
|
||||||
|
($ReleaseToSearch -eq 11 -and $_.IsWindows11 -contains 'true') -or
|
||||||
|
($ReleaseToSearch -eq 10 -and ($null -eq $_.IsWindows11 -or $_.IsWindows11 -notcontains 'true'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $osNodesForRelease) { return $null }
|
||||||
|
|
||||||
|
foreach ($version in $VersionsToSearch) {
|
||||||
|
foreach ($osNode in $osNodesForRelease) {
|
||||||
|
$releaseIDs = $osNode.OSReleaseIdFileName -replace 'H', 'h' -split ' '
|
||||||
|
if ($releaseIDs -contains $version.ToLower()) {
|
||||||
|
return @{ Node = $osNode; Version = $version }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Attempt Exact Match (Requested Release and Version)
|
||||||
|
WriteLog "Attempting to find exact match for Win$($WindowsRelease) ($($WindowsVersion))..."
|
||||||
|
$exactMatchResult = Find-MatchingOSNode -ReleaseToSearch $WindowsRelease -VersionsToSearch @($WindowsVersion)
|
||||||
|
if ($null -ne $exactMatchResult) {
|
||||||
|
$selectedOSNode = $exactMatchResult.Node
|
||||||
|
$selectedOSVersion = $exactMatchResult.Version
|
||||||
|
WriteLog "Exact match found: Win$($selectedOSRelease) ($($selectedOSVersion))."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Exact match not found for Win$($WindowsRelease) ($($WindowsVersion))."
|
||||||
|
# 2. Fallback: Same Release, Other Versions (Newest First)
|
||||||
|
WriteLog "Attempting fallback within Win$($WindowsRelease)..."
|
||||||
|
$versionsForCurrentRelease = if ($WindowsRelease -eq 11) { $win11Versions } else { $win10Versions }
|
||||||
|
$fallbackVersions = $versionsForCurrentRelease | Where-Object { $_ -ne $WindowsVersion }
|
||||||
|
$fallbackResult = Find-MatchingOSNode -ReleaseToSearch $WindowsRelease -VersionsToSearch $fallbackVersions
|
||||||
|
if ($null -ne $fallbackResult) {
|
||||||
|
$selectedOSNode = $fallbackResult.Node
|
||||||
|
$selectedOSVersion = $fallbackResult.Version
|
||||||
|
WriteLog "Fallback successful within Win$($selectedOSRelease). Using version: $($selectedOSVersion)."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Fallback within Win$($WindowsRelease) unsuccessful."
|
||||||
|
# 3. Fallback: Other Release, Versions (Newest First)
|
||||||
|
$otherRelease = if ($WindowsRelease -eq 11) { 10 } else { 11 }
|
||||||
|
WriteLog "Attempting fallback to Win$($otherRelease)..."
|
||||||
|
$versionsForOtherRelease = if ($otherRelease -eq 11) { $win11Versions } else { $win10Versions }
|
||||||
|
$otherFallbackResult = Find-MatchingOSNode -ReleaseToSearch $otherRelease -VersionsToSearch $versionsForOtherRelease
|
||||||
|
if ($null -ne $otherFallbackResult) {
|
||||||
|
$selectedOSNode = $otherFallbackResult.Node
|
||||||
|
$selectedOSVersion = $otherFallbackResult.Version
|
||||||
|
$selectedOSRelease = $otherRelease
|
||||||
|
WriteLog "Fallback successful to Win$($selectedOSRelease). Using version: $($selectedOSVersion)."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Fallback to Win$($otherRelease) also failed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $selectedOSNode) {
|
||||||
|
$allAvailableVersions = @()
|
||||||
|
if ($platformNode.OS) {
|
||||||
|
foreach ($osNode in $platformNode.OS) {
|
||||||
|
$osRel = if ($osNode.IsWindows11 -contains 'true') { 11 } else { 10 }
|
||||||
|
$relIDs = $osNode.OSReleaseIdFileName -replace 'H', 'h' -split ' '
|
||||||
|
foreach ($id in $relIDs) { $allAvailableVersions += "Win$($osRel) $($id)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$availableVersionsString = ($allAvailableVersions | Select-Object -Unique) -join ', '
|
||||||
|
if ([string]::IsNullOrWhiteSpace($availableVersionsString)) { $availableVersionsString = "None" }
|
||||||
|
throw "Could not find any suitable OS driver pack for model '$modelName' matching requested or fallback versions (Win$($WindowsRelease) $WindowsVersion). Available: $availableVersionsString"
|
||||||
|
}
|
||||||
|
|
||||||
|
$osReleaseIdFileName = $selectedOSNode.OSReleaseIdFileName -replace 'H', 'h'
|
||||||
|
WriteLog "Using SystemID: $systemID and OS Info: Win$($selectedOSRelease) ($($selectedOSVersion)) for '$modelName'"
|
||||||
|
$archSuffix = $WindowsArch -replace "^x", ""
|
||||||
|
$modelRelease = "$($systemID)_$($archSuffix)_$($selectedOSRelease).0.$($selectedOSVersion.ToLower())"
|
||||||
|
$driverCabUrl = "https://hpia.hpcloud.hp.com/ref/$systemID/$modelRelease.cab"
|
||||||
|
$driverCabFile = Join-Path -Path $hpDriversBaseFolder -ChildPath "$modelRelease.cab" # Store in base HP folder
|
||||||
|
$driverXmlFile = Join-Path -Path $hpDriversBaseFolder -ChildPath "$modelRelease.xml" # Store in base HP folder
|
||||||
|
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Downloading driver index..." }
|
||||||
|
WriteLog "Downloading HP Driver cab from $driverCabUrl to $driverCabFile"
|
||||||
|
Start-BitsTransferWithRetry -Source $driverCabUrl -Destination $driverCabFile -ErrorAction Stop
|
||||||
|
WriteLog "Expanding HP Driver cab $driverCabFile to $driverXmlFile"
|
||||||
|
if (Test-Path -Path $driverXmlFile) { Remove-Item -Path $driverXmlFile -Force }
|
||||||
|
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$driverCabFile`"", "`"$driverXmlFile`"") -ErrorAction Stop | Out-Null
|
||||||
|
|
||||||
|
WriteLog "Parsing driver XML $driverXmlFile"
|
||||||
|
[xml]$driverXmlContent = Get-Content -Path $driverXmlFile -Raw -Encoding UTF8 -ErrorAction Stop
|
||||||
|
$updates = $driverXmlContent.ImagePal.Solutions.UpdateInfo | Where-Object { $_.Category -match '^Driver' }
|
||||||
|
$totalDrivers = ($updates | Measure-Object).Count
|
||||||
|
$downloadedCount = 0
|
||||||
|
WriteLog "Found $totalDrivers driver updates for $modelName."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Found $totalDrivers drivers. Downloading..." }
|
||||||
|
|
||||||
|
if (-not (Test-Path -Path $modelSpecificFolder)) {
|
||||||
|
New-Item -Path $modelSpecificFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($update in $updates) {
|
||||||
|
$driverName = $update.Name -replace '[\\/:"*?<>|]', '_'
|
||||||
|
$category = $update.Category -replace '[\\/:"*?<>|]', '_'
|
||||||
|
$version = $update.Version -replace '[\\/:"*?<>|]', '_'
|
||||||
|
$driverUrl = "https://$($update.URL)"
|
||||||
|
$driverFileName = Split-Path -Path $driverUrl -Leaf
|
||||||
|
$downloadFolder = Join-Path -Path $modelSpecificFolder -ChildPath $category
|
||||||
|
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driverFileName
|
||||||
|
$extractFolder = Join-Path -Path $downloadFolder -ChildPath ($driverName + "_" + $version + "_" + ($driverFileName -replace '\.exe$', ''))
|
||||||
|
|
||||||
|
$downloadedCount++
|
||||||
|
$progressMsg = "($downloadedCount/$totalDrivers) Downloading $driverName..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $progressMsg }
|
||||||
|
WriteLog "$progressMsg URL: $driverUrl"
|
||||||
|
|
||||||
|
if (Test-Path -Path $extractFolder) {
|
||||||
|
WriteLog "Driver already extracted to $extractFolder, skipping download."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -Path $downloadFolder)) {
|
||||||
|
New-Item -Path $downloadFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||||
|
}
|
||||||
|
WriteLog "Downloading driver to: $driverFilePath"
|
||||||
|
Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath -ErrorAction Stop
|
||||||
|
WriteLog "Driver downloaded: $driverFilePath"
|
||||||
|
WriteLog "Creating extraction folder: $extractFolder"
|
||||||
|
New-Item -Path $extractFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||||
|
$arguments = "/s /e /f `"$extractFolder`""
|
||||||
|
WriteLog "Extracting driver $driverFilePath with args: $arguments"
|
||||||
|
WriteLog "Running HP Driver Extraction Command: $driverFilePath $arguments"
|
||||||
|
Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -ErrorAction Stop | Out-Null
|
||||||
|
# Start-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait -NoNewWindow -ErrorAction Stop | Out-Null
|
||||||
|
WriteLog "Driver extracted to: $extractFolder"
|
||||||
|
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
|
||||||
|
WriteLog "Deleted driver installer: $driverFilePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item -Path $driverCabFile, $driverXmlFile -Force -ErrorAction SilentlyContinue
|
||||||
|
WriteLog "Cleaned up driver cab and xml files for $modelName"
|
||||||
|
|
||||||
|
$finalStatus = "Completed"
|
||||||
|
if ($CompressToWim) {
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing..." }
|
||||||
|
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($identifier).wim"
|
||||||
|
WriteLog "Compressing '$modelSpecificFolder' to '$wimFilePath'..."
|
||||||
|
try {
|
||||||
|
Compress-DriverFolderToWim -SourceFolderPath $modelSpecificFolder -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -ErrorAction Stop
|
||||||
|
WriteLog "Compression successful for '$identifier'."
|
||||||
|
$finalStatus = "Completed & Compressed"
|
||||||
|
$driverRelativePath = Join-Path -Path $make -ChildPath "$($identifier).wim" # Update relative path to the WIM
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error during compression for '$identifier': $($_.Exception.Message)"
|
||||||
|
$finalStatus = "Completed (Compression Failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$successState = $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$errorMessage = "Error saving HP drivers for $($modelName): $($_.Exception.Message)"
|
||||||
|
WriteLog $errorMessage
|
||||||
|
$finalStatus = "Error: $($_.Exception.Message.Split([Environment]::NewLine)[0])"
|
||||||
|
$successState = $false
|
||||||
|
$driverRelativePath = $null # Ensure path is null on error
|
||||||
|
if (Test-Path -Path $modelSpecificFolder -PathType Container) {
|
||||||
|
WriteLog "Attempting to remove partially created folder $modelSpecificFolder due to error."
|
||||||
|
Remove-Item -Path $modelSpecificFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $finalStatus }
|
||||||
|
return [PSCustomObject]@{ Identifier = $identifier; Status = $finalStatus; Success = $successState; DriverPath = $driverRelativePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function *
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
<#
|
||||||
|
.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()] # Made optional
|
||||||
|
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null,
|
||||||
|
[Parameter()]
|
||||||
|
[bool]$CompressToWim = $false
|
||||||
|
)
|
||||||
|
|
||||||
|
# The Model property from the UI already contains the combined "ProductName (MachineType)" string
|
||||||
|
$identifier = $DriverItemData.Model
|
||||||
|
$machineType = $DriverItemData.MachineType
|
||||||
|
$make = "Lenovo"
|
||||||
|
$sanitizedIdentifier = $identifier -replace '[\\/:"*?<>|]', '_'
|
||||||
|
$status = "Starting..."
|
||||||
|
$success = $false
|
||||||
|
|
||||||
|
# Define paths
|
||||||
|
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
|
||||||
|
# Use the identifier (which contains the model name and machine type) and sanitize it for the path
|
||||||
|
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedIdentifier
|
||||||
|
$driverRelativePath = Join-Path -Path $make -ChildPath $sanitizedIdentifier # Relative path for the driver folder
|
||||||
|
$tempDownloadPath = Join-Path -Path $makeDriversPath -ChildPath "_TEMP_$($machineType)_$($PID)" # Temp folder for catalog/package XMLs
|
||||||
|
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Checking..." }
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Check for existing drivers
|
||||||
|
$existingDriver = Test-ExistingDriver -Make $make -Model $sanitizedIdentifier -DriversFolder $DriversFolder -Identifier $identifier -ProgressQueue $ProgressQueue
|
||||||
|
if ($null -ne $existingDriver) {
|
||||||
|
# The return object from Test-ExistingDriver uses 'Model' as the identifier key.
|
||||||
|
# We need to return 'Identifier' for Lenovo's logic.
|
||||||
|
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Identifier' -Value $identifier -Force
|
||||||
|
$existingDriver.PSObject.Properties.Remove('Model')
|
||||||
|
|
||||||
|
# Special handling for existing folders that need compression
|
||||||
|
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
|
||||||
|
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($sanitizedIdentifier).wim"
|
||||||
|
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedIdentifier
|
||||||
|
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." }
|
||||||
|
try {
|
||||||
|
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -ErrorAction Stop
|
||||||
|
$existingDriver.Status = "Already downloaded & Compressed"
|
||||||
|
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($sanitizedIdentifier).wim"
|
||||||
|
$existingDriver.Success = $true
|
||||||
|
WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error compressing existing drivers for $($identifier): $($_.Exception.Message)"
|
||||||
|
$existingDriver.Status = "Already downloaded (Compression failed)"
|
||||||
|
$existingDriver.Success = $false
|
||||||
|
}
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $existingDriver.Status }
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existingDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure base directories exist
|
||||||
|
if (-not (Test-Path -Path $makeDriversPath)) { New-Item -Path $makeDriversPath -ItemType Directory -Force | Out-Null }
|
||||||
|
if (-not (Test-Path -Path $modelPath)) { New-Item -Path $modelPath -ItemType Directory -Force | Out-Null }
|
||||||
|
if (-not (Test-Path -Path $tempDownloadPath)) { New-Item -Path $tempDownloadPath -ItemType Directory -Force | Out-Null }
|
||||||
|
|
||||||
|
# 2. Construct and Download Catalog URL
|
||||||
|
$modelRelease = $machineType + "_Win" + $WindowsRelease
|
||||||
|
$catalogUrl = "https://download.lenovo.com/catalog/$modelRelease.xml"
|
||||||
|
$lenovoCatalogXML = Join-Path -Path $tempDownloadPath -ChildPath "$modelRelease.xml"
|
||||||
|
|
||||||
|
$status = "Downloading Catalog..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||||
|
WriteLog "Downloading Lenovo Driver catalog for '$identifier' from $catalogUrl"
|
||||||
|
|
||||||
|
# Check URL accessibility first
|
||||||
|
try {
|
||||||
|
$request = [System.Net.WebRequest]::Create($catalogUrl); $request.Method = 'HEAD'; $response = $request.GetResponse(); $response.Close()
|
||||||
|
}
|
||||||
|
catch { throw "Lenovo Driver catalog URL is not accessible: $catalogUrl. Error: $($_.Exception.Message)" }
|
||||||
|
|
||||||
|
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $lenovoCatalogXML
|
||||||
|
WriteLog "Catalog download Complete: $lenovoCatalogXML"
|
||||||
|
|
||||||
|
# 3. Parse Catalog and Process Packages
|
||||||
|
$status = "Parsing Catalog..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||||
|
[xml]$xmlContent = Get-Content -Path $lenovoCatalogXML -Encoding UTF8
|
||||||
|
|
||||||
|
$packages = @($xmlContent.packages.package) # Ensure it's an array
|
||||||
|
$totalPackages = $packages.Count
|
||||||
|
$processedPackages = 0
|
||||||
|
WriteLog "Found $totalPackages packages in catalog for '$identifier'."
|
||||||
|
|
||||||
|
foreach ($package in $packages) {
|
||||||
|
$processedPackages++
|
||||||
|
$category = $package.category
|
||||||
|
$packageUrl = $package.location # URL to the package's *XML* file
|
||||||
|
|
||||||
|
# Skip BIOS/Firmware based on category
|
||||||
|
if ($category -like 'BIOS*' -or $category -like 'Firmware*') {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Skipping BIOS/Firmware package: $category"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sanitize category for path
|
||||||
|
$categoryClean = $category -replace '[\\/:"*?<>|]', '_'
|
||||||
|
if ($categoryClean -eq 'Motherboard Devices Backplanes core chipset onboard video PCIe switches') {
|
||||||
|
$categoryClean = 'Motherboard Devices' # Shorten long category name
|
||||||
|
}
|
||||||
|
|
||||||
|
$packageName = [System.IO.Path]::GetFileName($packageUrl)
|
||||||
|
$packageXMLPath = Join-Path -Path $tempDownloadPath -ChildPath $packageName
|
||||||
|
$baseURL = $packageUrl -replace [regex]::Escape($packageName), "" # Base URL for the driver file
|
||||||
|
|
||||||
|
$status = "($processedPackages/$totalPackages) Getting package info..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||||
|
|
||||||
|
# Download the package XML
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Downloading package XML: $packageUrl"
|
||||||
|
try {
|
||||||
|
Start-BitsTransferWithRetry -Source $packageUrl -Destination $packageXMLPath
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Failed to download package XML '$packageUrl'. Skipping. Error: $($_.Exception.Message)"
|
||||||
|
continue # Skip this package
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load and parse the package XML
|
||||||
|
[xml]$packageXmlContent = Get-Content -Path $packageXMLPath -Encoding UTF8
|
||||||
|
$packageType = $packageXmlContent.Package.PackageType.type
|
||||||
|
$packageTitleRaw = $packageXmlContent.Package.title.InnerText
|
||||||
|
|
||||||
|
# Filter out non-driver packages (Type 2 = Driver)
|
||||||
|
if ($packageType -ne 2) {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Skipping package '$packageTitleRaw' (Type: $packageType) - Not a driver."
|
||||||
|
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sanitize title for folder name
|
||||||
|
$packageTitle = $packageTitleRaw -replace '[\\/:"*?<>|]', '_' -replace ' - .*', ''
|
||||||
|
|
||||||
|
# Extract driver file name and extract command
|
||||||
|
$driverFileName = $null
|
||||||
|
$extractCommand = $null
|
||||||
|
try {
|
||||||
|
$driverFileName = $packageXmlContent.Package.Files.Installer.File.Name
|
||||||
|
$extractCommand = $packageXmlContent.Package.ExtractCommand
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Error parsing package XML '$packageXMLPath' for file name/command. Skipping. Error: $($_.Exception.Message)"
|
||||||
|
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Skip if essential info is missing
|
||||||
|
if ([string]::IsNullOrWhiteSpace($driverFileName) -or [string]::IsNullOrWhiteSpace($extractCommand)) {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Skipping package '$packageTitleRaw' - Missing driver file name or extract command in XML."
|
||||||
|
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Construct paths
|
||||||
|
$driverUrl = $baseURL + $driverFileName
|
||||||
|
$categoryPath = Join-Path -Path $modelPath -ChildPath $categoryClean
|
||||||
|
$downloadFolder = Join-Path -Path $categoryPath -ChildPath $packageTitle # Final destination subfolder
|
||||||
|
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driverFileName
|
||||||
|
$extractFolder = Join-Path -Path $downloadFolder -ChildPath ($driverFileName -replace '\.exe$', '') # Extract to subfolder named after exe
|
||||||
|
# Check if already extracted
|
||||||
|
if (Test-Path -Path $extractFolder -PathType Container) {
|
||||||
|
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||||
|
if ($extractSize -gt 1KB) {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Driver '$packageTitleRaw' already extracted to '$extractFolder'. Skipping."
|
||||||
|
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure download folder exists
|
||||||
|
if (-not (Test-Path -Path $downloadFolder)) {
|
||||||
|
New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download the driver .exe
|
||||||
|
$status = "($processedPackages/$totalPackages) Downloading $packageTitle..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Downloading driver: $driverUrl to $driverFilePath"
|
||||||
|
try {
|
||||||
|
Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Driver downloaded: $driverFileName"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Failed to download driver '$driverUrl'. Skipping. Error: $($_.Exception.Message)"
|
||||||
|
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
|
||||||
|
continue # Skip this driver
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Extraction Logic ---
|
||||||
|
$status = "($processedPackages/$totalPackages) Extracting $packageTitle..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||||
|
|
||||||
|
# Always use a temporary extraction path to avoid long path issues
|
||||||
|
$originalExtractFolder = $extractFolder # Store the originally intended final path
|
||||||
|
$extractionSucceeded = $false
|
||||||
|
$tempExtractBase = $null # Initialize
|
||||||
|
|
||||||
|
# Create randomized number for use with temp folder name
|
||||||
|
$randomNumber = Get-Random -Minimum 1000 -Maximum 9999
|
||||||
|
$tempExtractBase = Join-Path $env:TEMP "LenovoDriverExtract_$randomNumber"
|
||||||
|
$extractFolder = Join-Path $tempExtractBase ($driverFileName -replace '\.exe$', '') # Actual temp extraction folder
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Using temporary extraction path: $extractFolder"
|
||||||
|
|
||||||
|
# Ensure the base temp directory exists
|
||||||
|
if (-not (Test-Path -Path $tempExtractBase)) {
|
||||||
|
New-Item -Path $tempExtractBase -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
# Ensure the target temporary extraction folder exists
|
||||||
|
if (-not (Test-Path -Path $extractFolder)) {
|
||||||
|
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Modify the extract command to point to the temporary folder
|
||||||
|
$modifiedExtractCommand = $extractCommand -replace '%PACKAGEPATH%', "`"$extractFolder`""
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Extracting driver: $driverFilePath using command: $modifiedExtractCommand"
|
||||||
|
|
||||||
|
try {
|
||||||
|
Invoke-Process -FilePath $driverFilePath -ArgumentList $modifiedExtractCommand -Wait $true | Out-Null
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Driver extracted to temporary path: $extractFolder"
|
||||||
|
$extractionSucceeded = $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Failed to extract driver '$driverFilePath' to temporary path. Skipping. Error: $($_.Exception.Message)"
|
||||||
|
# Don't delete the downloaded exe yet if extraction fails
|
||||||
|
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
|
||||||
|
# Clean up temp folder if extraction failed
|
||||||
|
if ($tempExtractBase -and (Test-Path -Path $tempExtractBase)) {
|
||||||
|
Remove-Item -Path $tempExtractBase -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
continue # Skip further processing for this driver
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Post-Extraction Handling (Move from Temp to Final Destination) ---
|
||||||
|
if ($extractionSucceeded) {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Performing post-extraction move from temp to final destination..."
|
||||||
|
try {
|
||||||
|
# Ensure the *original* final destination folder exists and is empty
|
||||||
|
if (Test-Path -Path $originalExtractFolder) {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Clearing existing final destination folder: $originalExtractFolder"
|
||||||
|
Get-ChildItem -Path $originalExtractFolder -Recurse | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Creating final destination folder: $originalExtractFolder"
|
||||||
|
New-Item -Path $originalExtractFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get all items (files and folders) directly inside the temp extraction folder
|
||||||
|
$extractedItems = Get-ChildItem -Path $extractFolder -ErrorAction Stop
|
||||||
|
|
||||||
|
foreach ($item in $extractedItems) {
|
||||||
|
$itemName = $item.Name
|
||||||
|
$finalDestinationPath = $null
|
||||||
|
|
||||||
|
# Check if it's a directory containing 'Liteon'
|
||||||
|
if ($item.PSIsContainer -and $itemName -like '*Liteon*') {
|
||||||
|
# Rename Liteon folders with a random number suffix
|
||||||
|
$randomNumber = Get-Random -Minimum 1000 -Maximum 9999
|
||||||
|
$finalFolderName = "Liteon_$randomNumber"
|
||||||
|
$finalDestinationPath = Join-Path -Path $originalExtractFolder -ChildPath $finalFolderName
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Moving Liteon folder '$itemName' to '$finalDestinationPath'"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# For other files/folders, move them directly
|
||||||
|
$finalDestinationPath = Join-Path -Path $originalExtractFolder -ChildPath $itemName
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Moving item '$itemName' to '$finalDestinationPath'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Perform the move
|
||||||
|
try {
|
||||||
|
Move-Item -Path $item.FullName -Destination $finalDestinationPath -Force -ErrorAction Stop
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Failed to move item '$($item.FullName)' to '$finalDestinationPath'. Error: $($_.Exception.Message)"
|
||||||
|
# Decide if this should stop the whole process or just skip this item
|
||||||
|
# For now, we'll log and continue, but mark overall success as false
|
||||||
|
$extractionSucceeded = $false
|
||||||
|
}
|
||||||
|
} # End foreach ($item in $extractedItems)
|
||||||
|
|
||||||
|
if ($extractionSucceeded) {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) All driver contents moved successfully from temp to final destination."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Some driver contents failed to move. Check logs."
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Error during post-extraction move: $($_.Exception.Message). Files might remain in temp."
|
||||||
|
$extractionSucceeded = $false # Mark as failed for cleanup logic below
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# Clean up the base temporary directory regardless of move success/failure
|
||||||
|
if ($tempExtractBase -and (Test-Path -Path $tempExtractBase)) {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Cleaning up temporary extraction base: $tempExtractBase"
|
||||||
|
Remove-Item -Path $tempExtractBase -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Final Cleanup ---
|
||||||
|
# Delete the downloaded .exe only if extraction AND move were successful
|
||||||
|
if ($extractionSucceeded) {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Deleting driver installation file: $driverFilePath"
|
||||||
|
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Keeping driver installation file due to extraction/move failure: $driverFilePath"
|
||||||
|
}
|
||||||
|
# Always delete the package XML
|
||||||
|
WriteLog "($processedPackages/$totalPackages) Deleting package XML file: $packageXMLPath"
|
||||||
|
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
} # End foreach package
|
||||||
|
|
||||||
|
# --- Compress to WIM if requested (after all drivers processed) ---
|
||||||
|
if ($CompressToWim) {
|
||||||
|
$status = "Compressing..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||||
|
$wimFileName = "$($sanitizedIdentifier).wim" # Use sanitized identifier for filename
|
||||||
|
$destinationWimPath = Join-Path -Path $makeDriversPath -ChildPath $wimFileName
|
||||||
|
$driverRelativePath = Join-Path -Path $make -ChildPath $wimFileName # Update relative path to the WIM file
|
||||||
|
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
|
||||||
|
try {
|
||||||
|
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $identifier -WimDescription $identifier -ErrorAction Stop
|
||||||
|
if ($compressResult) {
|
||||||
|
WriteLog "Compression successful for '$identifier'."
|
||||||
|
$status = "Completed & Compressed"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Compression failed for '$identifier'. Check verbose/error output from Compress-DriverFolderToWim."
|
||||||
|
$status = "Completed (Compression Failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error during compression for '$identifier': $($_.Exception.Message)"
|
||||||
|
$status = "Completed (Compression Error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$status = "Completed"
|
||||||
|
}
|
||||||
|
# --- End Compression ---
|
||||||
|
|
||||||
|
$success = $true # Mark success as download/extract was okay
|
||||||
|
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
|
||||||
|
WriteLog "Error saving Lenovo drivers for '$identifier': $($_.Exception.ToString())" # Log full exception string
|
||||||
|
$success = $false
|
||||||
|
# Enqueue the error status before returning
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||||
|
# Ensure return object is created even on error
|
||||||
|
return [PSCustomObject]@{ Identifier = $identifier; Status = $status; Success = $success; DriverPath = $null }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# Clean up the main catalog XML and temp folder
|
||||||
|
WriteLog "Cleaning up temporary download folder: $tempDownloadPath"
|
||||||
|
Remove-Item -Path $tempDownloadPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enqueue the final status (success or error) before returning
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||||
|
|
||||||
|
# Return the final status
|
||||||
|
return [PSCustomObject]@{ Identifier = $identifier; Status = $status; Success = $success; DriverPath = $driverRelativePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function *
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Provides functions for discovering, downloading, and processing Microsoft Surface device drivers.
|
||||||
|
.DESCRIPTION
|
||||||
|
This module contains the logic specific to handling Microsoft Surface drivers for the FFU UI. It includes a function to scrape the official Microsoft support website to build a list of available Surface models and their driver download pages. It also provides a robust, parallel-capable function to download the correct driver package (MSI or ZIP) based on the selected Windows release, extract its contents, and optionally compress them into a WIM archive. The download process includes logic to handle MSI installer mutexes to prevent conflicts during parallel execution.
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Function to get the list of Microsoft Surface models
|
||||||
|
function Get-MicrosoftDriversModelList {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[hashtable]$Headers, # Pass necessary headers
|
||||||
|
[string]$UserAgent # Pass UserAgent
|
||||||
|
)
|
||||||
|
|
||||||
|
$url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120"
|
||||||
|
$models = @()
|
||||||
|
|
||||||
|
try {
|
||||||
|
WriteLog "Getting Surface driver information from $url"
|
||||||
|
$OriginalVerbosePreference = $VerbosePreference
|
||||||
|
$VerbosePreference = 'SilentlyContinue'
|
||||||
|
# Use passed-in UserAgent and Headers
|
||||||
|
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent
|
||||||
|
$VerbosePreference = $OriginalVerbosePreference
|
||||||
|
WriteLog "Complete"
|
||||||
|
|
||||||
|
WriteLog "Parsing web content for models and download links"
|
||||||
|
$html = $webContent.Content
|
||||||
|
$divPattern = '<div[^>]*class="selectable-content-options__option-content(?: ocHidden)?"[^>]*>(.*?)</div>'
|
||||||
|
$divMatches = [regex]::Matches($html, $divPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
|
||||||
|
|
||||||
|
foreach ($divMatch in $divMatches) {
|
||||||
|
$divContent = $divMatch.Groups[1].Value
|
||||||
|
$tablePattern = '<table[^>]*>(.*?)</table>'
|
||||||
|
$tableMatches = [regex]::Matches($divContent, $tablePattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
|
||||||
|
|
||||||
|
foreach ($tableMatch in $tableMatches) {
|
||||||
|
$tableContent = $tableMatch.Groups[1].Value
|
||||||
|
$rowPattern = '<tr[^>]*>(.*?)</tr>'
|
||||||
|
$rowMatches = [regex]::Matches($tableContent, $rowPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
|
||||||
|
|
||||||
|
foreach ($rowMatch in $rowMatches) {
|
||||||
|
$rowContent = $rowMatch.Groups[1].Value
|
||||||
|
$cellPattern = '<td[^>]*>\s*(?:<p[^>]*>)?(.*?)(?:</p>)?\s*</td>'
|
||||||
|
$cellMatches = [regex]::Matches($rowContent, $cellPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
|
||||||
|
|
||||||
|
if ($cellMatches.Count -ge 2) {
|
||||||
|
$modelName = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[0].Groups[1].Value).Trim()))
|
||||||
|
$secondTdContent = $cellMatches[1].Groups[1].Value.Trim()
|
||||||
|
# $linkPattern = '<a[^>]+href="([^"]+)"[^>]*>'
|
||||||
|
# Change linkPattern to match https://www.microsoft.com/download/details.aspx?id=
|
||||||
|
$linkPattern = '<a[^>]+href="(https://www\.microsoft\.com/download/details\.aspx\?id=\d+)"[^>]*>'
|
||||||
|
$linkMatch = [regex]::Match($secondTdContent, $linkPattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||||
|
|
||||||
|
if ($linkMatch.Success) {
|
||||||
|
$modelLink = $linkMatch.Groups[1].Value
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$models += [PSCustomObject]@{
|
||||||
|
Make = 'Microsoft'
|
||||||
|
Model = $modelName
|
||||||
|
Link = $modelLink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WriteLog "Parsing complete. Found $($models.Count) models."
|
||||||
|
return $models
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error getting Microsoft models: $($_.Exception.Message)"
|
||||||
|
throw "Failed to retrieve Microsoft Surface models."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Function to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel)
|
||||||
|
function Save-MicrosoftDriversTask {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[PSCustomObject]$DriverItemData, # Pass data, not the UI object
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$DriversFolder,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[int]$WindowsRelease,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[hashtable]$Headers, # Pass necessary headers
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$UserAgent, # Pass UserAgent
|
||||||
|
[Parameter()] # Made optional
|
||||||
|
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
|
||||||
|
[Parameter()]
|
||||||
|
[bool]$CompressToWim = $false # New parameter for compression
|
||||||
|
# REMOVED: UI-related parameters
|
||||||
|
)
|
||||||
|
|
||||||
|
$modelName = $DriverItemData.Model
|
||||||
|
$modelLink = $DriverItemData.Link
|
||||||
|
$make = $DriverItemData.Make
|
||||||
|
$driverRelativePath = Join-Path -Path $make -ChildPath $modelName # Relative path for the driver folder
|
||||||
|
$status = "Getting download link..." # Initial local status
|
||||||
|
$success = $false
|
||||||
|
|
||||||
|
# Initial status update
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." }
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Check for existing drivers
|
||||||
|
$existingDriver = Test-ExistingDriver -Make $make -Model $modelName -DriversFolder $DriversFolder -Identifier $modelName -ProgressQueue $ProgressQueue
|
||||||
|
if ($null -ne $existingDriver) {
|
||||||
|
# Add the 'Model' property to the return object for consistency if it's not there
|
||||||
|
if (-not $existingDriver.PSObject.Properties['Model']) {
|
||||||
|
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Model' -Value $modelName
|
||||||
|
}
|
||||||
|
|
||||||
|
# Special handling for existing folders that need compression
|
||||||
|
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
|
||||||
|
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $make
|
||||||
|
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($modelName).wim"
|
||||||
|
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
|
||||||
|
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Compressing existing..." }
|
||||||
|
try {
|
||||||
|
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $modelName -WimDescription "Drivers for $modelName" -ErrorAction Stop
|
||||||
|
$existingDriver.Status = "Already downloaded & Compressed"
|
||||||
|
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($modelName).wim"
|
||||||
|
$existingDriver.Success = $true
|
||||||
|
WriteLog "Successfully compressed existing drivers for $modelName to $wimFilePath."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error compressing existing drivers for $($modelName): $($_.Exception.Message)"
|
||||||
|
$existingDriver.Status = "Already downloaded (Compression failed)"
|
||||||
|
$existingDriver.Success = $false
|
||||||
|
}
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $existingDriver.Status }
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existingDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
### GET THE DOWNLOAD LINK
|
||||||
|
$status = "Getting download link..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
WriteLog "Getting download page content for $modelName from $modelLink"
|
||||||
|
$OriginalVerbosePreference = $VerbosePreference
|
||||||
|
$VerbosePreference = 'SilentlyContinue'
|
||||||
|
# Use passed-in UserAgent and Headers
|
||||||
|
$downloadPageContent = Invoke-WebRequest -Uri $modelLink -UseBasicParsing -Headers $Headers -UserAgent $UserAgent
|
||||||
|
$VerbosePreference = $OriginalVerbosePreference
|
||||||
|
WriteLog "Complete"
|
||||||
|
|
||||||
|
$status = "Parsing download page..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
WriteLog "Parsing download page for file"
|
||||||
|
$scriptPattern = '<script>window.__DLCDetails__={(.*?)}<\/script>'
|
||||||
|
$scriptMatch = [regex]::Match($downloadPageContent.Content, $scriptPattern)
|
||||||
|
|
||||||
|
if ($scriptMatch.Success) {
|
||||||
|
$scriptContent = $scriptMatch.Groups[1].Value
|
||||||
|
# $downloadFilePattern = '"name":"(.*?)",.*?"url":"(.*?)"'
|
||||||
|
$downloadFilePattern = '"name":"([^"]+\.(?:msi|zip))",[^}]*?"url":"(.*?)"'
|
||||||
|
$downloadFileMatches = [regex]::Matches($scriptContent, $downloadFilePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||||
|
|
||||||
|
|
||||||
|
$win10Link = $null
|
||||||
|
$win10FileName = $null
|
||||||
|
$win11Link = $null
|
||||||
|
$win11FileName = $null
|
||||||
|
|
||||||
|
# Iterate through all matches to find potential Win10 and Win11 links
|
||||||
|
foreach ($downloadFile in $downloadFileMatches) {
|
||||||
|
$currentFileName = $downloadFile.Groups[1].Value
|
||||||
|
$fileUrl = $downloadFile.Groups[2].Value
|
||||||
|
|
||||||
|
if ($currentFileName -match "Win10") {
|
||||||
|
$win10Link = $fileUrl
|
||||||
|
$win10FileName = $currentFileName
|
||||||
|
WriteLog "Found Win10 link: $win10FileName"
|
||||||
|
}
|
||||||
|
elseif ($currentFileName -match "Win11") {
|
||||||
|
$win11Link = $fileUrl
|
||||||
|
$win11FileName = $currentFileName
|
||||||
|
WriteLog "Found Win11 link: $win11FileName"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decision logic to select the appropriate download link
|
||||||
|
$downloadLink = $null
|
||||||
|
$fileName = $null
|
||||||
|
$downloadedVersion = $null # Track which version we are actually downloading
|
||||||
|
|
||||||
|
if ($WindowsRelease -eq 10 -and $win10Link) {
|
||||||
|
$downloadLink = $win10Link
|
||||||
|
$fileName = $win10FileName
|
||||||
|
$downloadedVersion = 10
|
||||||
|
WriteLog "Exact match found for Win10."
|
||||||
|
}
|
||||||
|
elseif ($WindowsRelease -eq 11 -and $win11Link) {
|
||||||
|
$downloadLink = $win11Link
|
||||||
|
$fileName = $win11FileName
|
||||||
|
$downloadedVersion = 11
|
||||||
|
WriteLog "Exact match found for Win11."
|
||||||
|
}
|
||||||
|
elseif (-not $win10Link -and $win11Link) {
|
||||||
|
# Only Win11 available, regardless of $WindowsRelease
|
||||||
|
$downloadLink = $win11Link
|
||||||
|
$fileName = $win11FileName
|
||||||
|
$downloadedVersion = 11
|
||||||
|
WriteLog "Exact match for Win$($WindowsRelease) not found. Falling back to available Win11 driver."
|
||||||
|
}
|
||||||
|
elseif ($win10Link -and -not $win11Link) {
|
||||||
|
# Only Win10 available, regardless of $WindowsRelease
|
||||||
|
$downloadLink = $win10Link
|
||||||
|
$fileName = $win10FileName
|
||||||
|
$downloadedVersion = 10
|
||||||
|
WriteLog "Exact match for Win$($WindowsRelease) not found. Falling back to available Win10 driver."
|
||||||
|
}
|
||||||
|
# If both Win10 and Win11 links exist, but neither matches $WindowsRelease, $downloadLink remains $null.
|
||||||
|
|
||||||
|
### DOWNLOAD AND EXTRACT
|
||||||
|
if ($downloadLink) {
|
||||||
|
WriteLog "Selected Download Link for $modelName (Actual: Windows $downloadedVersion): $downloadLink"
|
||||||
|
$status = "Downloading (Win$downloadedVersion)..." # Update status message
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
if (-not (Test-Path -Path $DriversFolder)) {
|
||||||
|
WriteLog "Creating Drivers folder: $DriversFolder"
|
||||||
|
New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
|
||||||
|
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
|
||||||
|
if (-Not (Test-Path -Path $modelPath)) {
|
||||||
|
WriteLog "Creating model folder: $modelPath"
|
||||||
|
New-Item -Path $modelPath -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Model folder already exists: $modelPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
### DOWNLOAD
|
||||||
|
$filePath = Join-Path -Path $makeDriversPath -ChildPath ($fileName)
|
||||||
|
WriteLog "Downloading $modelName driver file to $filePath"
|
||||||
|
# Use Start-BitsTransferWithRetry
|
||||||
|
Start-BitsTransferWithRetry -Source $downloadLink -Destination $filePath
|
||||||
|
WriteLog "Download complete"
|
||||||
|
|
||||||
|
$fileExtension = [System.IO.Path]::GetExtension($filePath).ToLower()
|
||||||
|
|
||||||
|
### EXTRACT
|
||||||
|
if ($fileExtension -eq ".msi") {
|
||||||
|
$status = "Waiting for MSI lock..." # Set initial status
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
|
||||||
|
# Use a named mutex to ensure only one MSI extraction happens at a time across all parallel tasks
|
||||||
|
$msiMutexName = "Global\FFUDevelopmentMSIExtractionMutex"
|
||||||
|
$msiMutex = New-Object System.Threading.Mutex($false, $msiMutexName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
WriteLog "Waiting to acquire global MSI extraction lock for '$modelName'..."
|
||||||
|
$msiMutex.WaitOne() | Out-Null
|
||||||
|
WriteLog "Acquired global MSI extraction lock for '$modelName'."
|
||||||
|
|
||||||
|
# Loop indefinitely to wait for system mutex and handle MSIExec exit codes
|
||||||
|
while ($true) {
|
||||||
|
$mutexClear = $false
|
||||||
|
|
||||||
|
# 1. Check System-level MSI Mutex
|
||||||
|
try {
|
||||||
|
$sysMutex = [System.Threading.Mutex]::OpenExisting("Global\_MSIExecute")
|
||||||
|
$sysMutex.Dispose()
|
||||||
|
$status = "Waiting for MSIExec..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
WriteLog "Another MSIExec installer is running (System Mutex Held). Waiting 5 seconds before rechecking for $modelName..."
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
continue # Go back to start of while loop to re-check mutex
|
||||||
|
}
|
||||||
|
catch [System.Threading.WaitHandleCannotBeOpenedException] {
|
||||||
|
# Mutex is clear, proceed to extraction attempt
|
||||||
|
WriteLog "System MSI mutex clear. Proceeding with MSI extraction attempt for $modelName."
|
||||||
|
$status = "Extracting MSI..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
$mutexClear = $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Handle other potential errors when checking the mutex
|
||||||
|
WriteLog "Warning: Error checking system MSI mutex for $($modelName): $_. Proceeding with caution."
|
||||||
|
$status = "Extracting MSI (Mutex Error)..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
$mutexClear = $true # Proceed despite mutex error
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Attempt Extraction (only if mutex was clear)
|
||||||
|
if ($mutexClear) {
|
||||||
|
WriteLog "Extracting MSI file to $modelPath"
|
||||||
|
$arguments = "/a `"$($filePath)`" /qn TARGETDIR=`"$($modelPath)`""
|
||||||
|
try {
|
||||||
|
# Use Invoke-Process. It will throw an error for any non-zero exit code.
|
||||||
|
Invoke-Process -FilePath "msiexec.exe" -ArgumentList $arguments -Wait $true -ErrorAction Stop | Out-Null
|
||||||
|
|
||||||
|
# If Invoke-Process succeeded (didn't throw), extraction is complete.
|
||||||
|
WriteLog "Extraction complete for $modelName (Exit Code 0)."
|
||||||
|
|
||||||
|
# Verification Step: Ensure the target folder is not empty.
|
||||||
|
$itemsInDest = Get-ChildItem -Path $modelPath -Recurse
|
||||||
|
if ($itemsInDest.Count -eq 0) {
|
||||||
|
WriteLog "VERIFICATION FAILED: MSI extraction for '$modelName' produced an empty folder. Retrying..."
|
||||||
|
$status = "Retrying (Empty Folder)"
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
continue # Retry the whole process
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog "VERIFICATION PASSED: Target folder for '$modelName' is not empty."
|
||||||
|
break # Success, exit the while loop
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Catch errors thrown by Invoke-Process
|
||||||
|
$errorMessage = $_.Exception.Message
|
||||||
|
if ($errorMessage -match 'Process exited with code 1618') {
|
||||||
|
# Specific handling for MSIExec busy error (1618)
|
||||||
|
WriteLog "MSIExec collision detected (Exit Code 1618) for $modelName. Retrying after wait..."
|
||||||
|
$status = "Waiting (MSI Collision)..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
Start-Sleep -Seconds 5 # Wait before retrying
|
||||||
|
continue # Go back to start of while loop to re-check mutex/retry
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Handle other errors from Invoke-Process (e.g., file not found, permissions, other exit codes)
|
||||||
|
WriteLog "Error during MSI extraction process for $($modelName): $errorMessage"
|
||||||
|
throw # Re-throw the original exception to be caught by the outer try/catch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} # End if ($mutexClear)
|
||||||
|
} # End while ($true)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($null -ne $msiMutex) {
|
||||||
|
$msiMutex.ReleaseMutex()
|
||||||
|
$msiMutex.Dispose()
|
||||||
|
WriteLog "Released global MSI extraction lock for '$modelName'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($fileExtension -eq ".zip") {
|
||||||
|
$status = "Extracting ZIP..." # Set status before extraction
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
WriteLog "Extracting ZIP file to $modelPath"
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
Expand-Archive -Path $filePath -DestinationPath $modelPath -Force
|
||||||
|
$ProgressPreference = 'Continue'
|
||||||
|
WriteLog "Extraction complete"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Unsupported file type: $fileExtension"
|
||||||
|
throw "Unsupported file type: $fileExtension"
|
||||||
|
}
|
||||||
|
# Remove downloaded file
|
||||||
|
$status = "Cleaning up..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
WriteLog "Removing $filePath"
|
||||||
|
Remove-Item -Path $filePath -Force
|
||||||
|
WriteLog "Cleanup complete." # Changed log message slightly
|
||||||
|
|
||||||
|
# --- Compress to WIM if requested ---
|
||||||
|
if ($CompressToWim) {
|
||||||
|
$status = "Compressing..."
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
$wimFileName = "$($modelName).wim"
|
||||||
|
# Corrected WIM path: WIM file should be next to the model folder, not inside it.
|
||||||
|
$destinationWimPath = Join-Path -Path $makeDriversPath -ChildPath $wimFileName
|
||||||
|
$driverRelativePath = Join-Path -Path $make -ChildPath $wimFileName # Update relative path to the WIM file
|
||||||
|
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
|
||||||
|
try {
|
||||||
|
# Use the function from the imported common module
|
||||||
|
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -ErrorAction Stop
|
||||||
|
if ($compressResult) {
|
||||||
|
WriteLog "Compression successful for '$modelName'."
|
||||||
|
$status = "Completed & Compressed"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Compression failed for '$modelName'. Check verbose/error output from Compress-DriverFolderToWim."
|
||||||
|
$status = "Completed (Compression Failed)"
|
||||||
|
# Don't mark overall success as false, download/extract succeeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error during compression for '$modelName': $($_.Exception.Message)"
|
||||||
|
$status = "Completed (Compression Error)"
|
||||||
|
# Don't mark overall success as false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$status = "Completed" # Final status if not compressing
|
||||||
|
}
|
||||||
|
# --- End Compression ---
|
||||||
|
|
||||||
|
$success = $true # Mark success as download/extract was okay
|
||||||
|
} # End if/elseif for .msi/.zip
|
||||||
|
else {
|
||||||
|
WriteLog "No suitable download link found for Windows $WindowsRelease (or fallback) for model $modelName."
|
||||||
|
$status = "Error: No Win$($WindowsRelease)/Fallback link"
|
||||||
|
$success = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Failed to parse the download page for the driver file for model $modelName."
|
||||||
|
$status = "Error: Parse failed"
|
||||||
|
$success = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
|
||||||
|
WriteLog "Error saving Microsoft drivers for $($modelName): $($_.Exception.Message)"
|
||||||
|
$success = $false
|
||||||
|
# Enqueue the error status before returning
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
# Ensure return object is created even on error
|
||||||
|
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; DriverPath = $null }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enqueue the final status (success or error) before returning
|
||||||
|
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||||
|
|
||||||
|
# Return the final status (this is still used by Receive-Job for final confirmation)
|
||||||
|
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; DriverPath = $driverRelativePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function *
|
||||||
@@ -0,0 +1,783 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Provides functions for managing and downloading hardware drivers in the FFU Builder UI.
|
||||||
|
.DESCRIPTION
|
||||||
|
This module contains all the business logic for the 'Drivers' tab in the FFU Builder UI. It handles fetching driver model lists from various manufacturers (Microsoft, Dell, HP, Lenovo), displaying and filtering them in the UI, and managing the selection state. It also includes functions to import and export driver selections to a JSON file (Drivers.json) and to orchestrate the parallel download of selected driver packages using the common parallel processing module.
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Helper function to get models for a selected Make and standardize them
|
||||||
|
function Get-ModelsForMake {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$SelectedMake,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
$standardizedModels = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
$rawModels = @()
|
||||||
|
|
||||||
|
# Get necessary values from UI or script scope
|
||||||
|
$localDriversFolder = $State.Controls.txtDriversFolder.Text
|
||||||
|
$localWindowsRelease = $null
|
||||||
|
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
|
||||||
|
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get headers and user agent from Get-CoreStaticVariables
|
||||||
|
$staticVars = Get-CoreStaticVariables
|
||||||
|
$Headers = $staticVars.Headers
|
||||||
|
$UserAgent = $staticVars.UserAgent
|
||||||
|
|
||||||
|
if (-not $localWindowsRelease -and ($SelectedMake -eq 'Dell' -or $SelectedMake -eq 'Lenovo')) {
|
||||||
|
[System.Windows.MessageBox]::Show("Please select a Windows Release first for $SelectedMake.", "Missing Information", "OK", "Warning")
|
||||||
|
throw "Windows Release not selected for $SelectedMake."
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($SelectedMake) {
|
||||||
|
'Microsoft' {
|
||||||
|
$rawModels = Get-MicrosoftDriversModelList -Headers $Headers -UserAgent $UserAgent
|
||||||
|
}
|
||||||
|
'Dell' {
|
||||||
|
$rawModels = Get-DellDriversModelList -WindowsRelease $localWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake
|
||||||
|
}
|
||||||
|
'HP' {
|
||||||
|
$rawModels = Get-HPDriversModelList -DriversFolder $localDriversFolder -Make $SelectedMake
|
||||||
|
}
|
||||||
|
'Lenovo' {
|
||||||
|
$modelSearchTerm = [Microsoft.VisualBasic.Interaction]::InputBox("Enter Lenovo Model Name or Machine Type (e.g., T480 or 20L5):", "Lenovo Model Search", "")
|
||||||
|
if ([string]::IsNullOrWhiteSpace($modelSearchTerm)) {
|
||||||
|
# User cancelled or entered nothing
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$State.Controls.txtStatus.Text = "Searching Lenovo models for '$modelSearchTerm'..."
|
||||||
|
$rawModels = Get-LenovoDriversModelList -ModelSearchTerm $modelSearchTerm -Headers $Headers -UserAgent $UserAgent
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
[System.Windows.MessageBox]::Show("Selected Make '$SelectedMake' is not supported for automatic model retrieval.", "Unsupported Make", "OK", "Warning")
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $rawModels) {
|
||||||
|
foreach ($rawModel in $rawModels) {
|
||||||
|
# Filter out Chromebooks for Lenovo before standardization
|
||||||
|
if ($SelectedMake -eq 'Lenovo' -and $rawModel.Model -match 'Chromebook') {
|
||||||
|
WriteLog "Get-ModelsForMake: Skipping Chromebook model: $($rawModel.Model)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$standardizedModels.Add((ConvertTo-StandardizedDriverModel -RawDriverObject $rawModel -Make $SelectedMake -State $State))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $standardizedModels.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper function to convert raw driver objects to a standardized format
|
||||||
|
function ConvertTo-StandardizedDriverModel {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[PSCustomObject]$RawDriverObject,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Make,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
$modelDisplay = $RawDriverObject.Model # Default
|
||||||
|
$id = $RawDriverObject.Model # Default
|
||||||
|
$link = $null
|
||||||
|
$productName = $null
|
||||||
|
$machineType = $null
|
||||||
|
|
||||||
|
if ($RawDriverObject.PSObject.Properties['Link']) {
|
||||||
|
$link = $RawDriverObject.Link
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lenovo specific handling
|
||||||
|
if ($Make -eq 'Lenovo') {
|
||||||
|
$modelDisplay = $RawDriverObject.Model
|
||||||
|
$productName = $RawDriverObject.ProductName
|
||||||
|
$machineType = $RawDriverObject.MachineType
|
||||||
|
$id = $RawDriverObject.MachineType
|
||||||
|
}
|
||||||
|
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
IsSelected = $false
|
||||||
|
Make = $Make
|
||||||
|
Model = $modelDisplay
|
||||||
|
Link = $link
|
||||||
|
Id = $id
|
||||||
|
ProductName = $productName
|
||||||
|
MachineType = $machineType
|
||||||
|
Version = "" # Placeholder
|
||||||
|
Type = "" # Placeholder
|
||||||
|
Size = "" # Placeholder
|
||||||
|
Arch = "" # Placeholder
|
||||||
|
DownloadStatus = "" # Initial download status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to filter the driver model list based on text input
|
||||||
|
function Search-DriverModels {
|
||||||
|
param(
|
||||||
|
[string]$filterText,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
# Check if UI elements and the full list are available
|
||||||
|
if ($null -eq $State.Controls.lstDriverModels -or $null -eq $State.Data.allDriverModels) {
|
||||||
|
WriteLog "Search-DriverModels: ListView or full model list not available."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure the ItemsSource is always the master list. This prevents inconsistency.
|
||||||
|
if ($State.Controls.lstDriverModels.ItemsSource -ne $State.Data.allDriverModels) {
|
||||||
|
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the default view of the items source, which supports filtering.
|
||||||
|
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($State.Controls.lstDriverModels.ItemsSource)
|
||||||
|
if ($null -eq $collectionView) {
|
||||||
|
WriteLog "Search-DriverModels: Could not get CollectionView. Filtering may not work."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog "Applying filter with text: '$filterText'"
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($filterText)) {
|
||||||
|
# If filter is empty, remove any existing filter
|
||||||
|
$collectionView.Filter = $null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Apply a filter predicate. This is the correct WPF way to filter.
|
||||||
|
$collectionView.Filter = {
|
||||||
|
param($item)
|
||||||
|
# $item is the PSCustomObject from the list
|
||||||
|
return $item.Model -like "*$filterText*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The view will automatically refresh. No need to call .Refresh() explicitly for filtering.
|
||||||
|
$filteredCount = 0
|
||||||
|
if ($null -ne $collectionView) {
|
||||||
|
foreach ($item in $collectionView) { $filteredCount++ }
|
||||||
|
}
|
||||||
|
WriteLog "Filter applied. View now contains $filteredCount models."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to save selected driver models to a JSON file
|
||||||
|
function Save-DriversJson {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
WriteLog "Save-DriversJson function called."
|
||||||
|
$selectedDrivers = @($State.Controls.lstDriverModels.Items | Where-Object { $_.IsSelected })
|
||||||
|
|
||||||
|
if (-not $selectedDrivers) {
|
||||||
|
[System.Windows.MessageBox]::Show("No drivers selected to save.", "Save Drivers", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||||
|
WriteLog "No drivers selected to save."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$outputJson = @{} # Use a Hashtable for the desired structure
|
||||||
|
|
||||||
|
$selectedDrivers | Group-Object -Property Make | ForEach-Object {
|
||||||
|
$makeName = $_.Name
|
||||||
|
$modelsForThisMake = @() # Initialize an array to hold model objects
|
||||||
|
|
||||||
|
foreach ($driverItem in $_.Group) {
|
||||||
|
$modelObject = $null
|
||||||
|
switch ($makeName) {
|
||||||
|
'Microsoft' {
|
||||||
|
$modelObject = @{
|
||||||
|
Name = $driverItem.Model # Model is the display name
|
||||||
|
Link = $driverItem.Link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'Dell' {
|
||||||
|
$modelObject = @{
|
||||||
|
Name = $driverItem.Model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'HP' {
|
||||||
|
$modelObject = @{
|
||||||
|
Name = $driverItem.Model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'Lenovo' {
|
||||||
|
$modelObject = @{
|
||||||
|
Name = $driverItem.Model # This is "ProductName (MachineType)"
|
||||||
|
ProductName = $driverItem.ProductName # This is "ProductName"
|
||||||
|
MachineType = $driverItem.MachineType # This is "MachineType"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
WriteLog "Save-DriversJson: Unknown Make '$makeName' encountered for model '$($driverItem.Model)'. Skipping."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($null -ne $modelObject) {
|
||||||
|
$modelsForThisMake += $modelObject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($modelsForThisMake.Count -gt 0) {
|
||||||
|
# Store the array of model objects under a "Models" key
|
||||||
|
$outputJson[$makeName] = @{
|
||||||
|
"Models" = $modelsForThisMake
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sfd = New-Object System.Windows.Forms.SaveFileDialog
|
||||||
|
$sfd.Filter = "JSON files (*.json)|*.json|All files (*.*)|*.*"
|
||||||
|
$sfd.Title = "Save Selected Drivers"
|
||||||
|
$sfd.FileName = "Drivers.json"
|
||||||
|
$sfd.InitialDirectory = $FFUDevelopmentPath
|
||||||
|
|
||||||
|
if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||||
|
try {
|
||||||
|
$outputJson | ConvertTo-Json -Depth 5 | Set-Content -Path $sfd.FileName -Encoding UTF8
|
||||||
|
[System.Windows.MessageBox]::Show("Selected drivers saved to $($sfd.FileName)", "Save Successful", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||||
|
WriteLog "Selected drivers saved to $($sfd.FileName)"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
[System.Windows.MessageBox]::Show("Error saving drivers file: $($_.Exception.Message)", "Save Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
|
||||||
|
WriteLog "Error saving drivers file to $($sfd.FileName): $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Save drivers operation cancelled by user."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to import driver models from a JSON file
|
||||||
|
function Import-DriversJson {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
WriteLog "Import-DriversJson function called."
|
||||||
|
$ofd = New-Object System.Windows.Forms.OpenFileDialog
|
||||||
|
$ofd.Filter = "JSON files (*.json)|*.json|All files (*.*)|*.*"
|
||||||
|
$ofd.Title = "Import Drivers"
|
||||||
|
$ofd.InitialDirectory = Join-Path -Path $State.FFUDevelopmentPath -ChildPath "Drivers"
|
||||||
|
|
||||||
|
if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||||
|
try {
|
||||||
|
$importedData = Get-Content -Path $ofd.FileName -Raw | ConvertFrom-Json
|
||||||
|
if ($null -eq $importedData -or $importedData -isnot [System.Management.Automation.PSCustomObject]) {
|
||||||
|
[System.Windows.MessageBox]::Show("Invalid JSON file format. Expected a JSON object with Makes as keys.", "Import Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
|
||||||
|
WriteLog "Import-DriversJson: Invalid JSON format in $($ofd.FileName). Expected an object."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$newModelsAdded = 0
|
||||||
|
$existingModelsUpdated = 0
|
||||||
|
|
||||||
|
if ($null -eq $State.Data.allDriverModels) {
|
||||||
|
$State.Data.allDriverModels = @()
|
||||||
|
}
|
||||||
|
|
||||||
|
$importedData.PSObject.Properties | ForEach-Object {
|
||||||
|
$makeName = $_.Name
|
||||||
|
$makeData = $_.Value # This is the object containing "Models" array
|
||||||
|
|
||||||
|
# Check if $makeData is null, not a PSCustomObject, or does not have a 'Models' property
|
||||||
|
if ($null -eq $makeData -or $makeData -isnot [System.Management.Automation.PSCustomObject] -or -not ($makeData.PSObject.Properties | Where-Object { $_.Name -eq 'Models' })) {
|
||||||
|
WriteLog "Import-DriversJson: Skipping Make '$makeName' due to invalid structure or missing 'Models' key."
|
||||||
|
return # Corresponds to 'continue' in ForEach-Object script block
|
||||||
|
}
|
||||||
|
|
||||||
|
$modelObjectArray = $makeData.Models # This is now an array of objects
|
||||||
|
if ($null -eq $modelObjectArray -or $modelObjectArray -isnot [array]) {
|
||||||
|
WriteLog "Import-DriversJson: Skipping Make '$makeName' because 'Models' value is not an array."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($importedModelObject in $modelObjectArray) {
|
||||||
|
if ($null -eq $importedModelObject -or -not $importedModelObject.PSObject.Properties['Name']) {
|
||||||
|
WriteLog "Import-DriversJson: Skipping model for Make '$makeName' due to missing 'Name' property or null object."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$importedModelNameFromObject = $importedModelObject.Name
|
||||||
|
if ([string]::IsNullOrWhiteSpace($importedModelNameFromObject)) {
|
||||||
|
WriteLog "Import-DriversJson: Skipping empty model name for Make '$makeName'."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingModel = $State.Data.allDriverModels | Where-Object { $_.Make -eq $makeName -and $_.Model -eq $importedModelNameFromObject } | Select-Object -First 1
|
||||||
|
|
||||||
|
if ($null -ne $existingModel) {
|
||||||
|
$existingModel.IsSelected = $true
|
||||||
|
$existingModel.DownloadStatus = "Imported"
|
||||||
|
|
||||||
|
if ($makeName -eq 'Microsoft' -and $importedModelObject.PSObject.Properties['Link']) {
|
||||||
|
if ($existingModel.Link -ne $importedModelObject.Link) {
|
||||||
|
$existingModel.Link = $importedModelObject.Link
|
||||||
|
WriteLog "Import-DriversJson: Updated Link for existing Microsoft model '$($existingModel.Model)'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($makeName -eq 'Lenovo') {
|
||||||
|
$updateExistingLenovo = $false
|
||||||
|
if ($importedModelObject.PSObject.Properties['ProductName'] -and $existingModel.PSObject.Properties['ProductName'] -and $existingModel.ProductName -ne $importedModelObject.ProductName) {
|
||||||
|
$existingModel.ProductName = $importedModelObject.ProductName
|
||||||
|
$updateExistingLenovo = $true
|
||||||
|
}
|
||||||
|
if ($importedModelObject.PSObject.Properties['MachineType'] -and $existingModel.PSObject.Properties['MachineType'] -and $existingModel.MachineType -ne $importedModelObject.MachineType) {
|
||||||
|
$existingModel.MachineType = $importedModelObject.MachineType
|
||||||
|
$existingModel.Id = $importedModelObject.MachineType # Update Id as well
|
||||||
|
$updateExistingLenovo = $true
|
||||||
|
}
|
||||||
|
if ($updateExistingLenovo) {
|
||||||
|
WriteLog "Import-DriversJson: Updated ProductName/MachineType/Id for existing Lenovo model '$($existingModel.Model)'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$existingModelsUpdated++
|
||||||
|
WriteLog "Import-DriversJson: Marked existing model '$($existingModel.Make) - $($existingModel.Model)' as imported."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Model does not exist, create a new one
|
||||||
|
$importedLink = if ($makeName -eq 'Microsoft' -and $importedModelObject.PSObject.Properties['Link']) { $importedModelObject.Link } else { $null }
|
||||||
|
$importedId = $importedModelNameFromObject # Default Id
|
||||||
|
$importedProductName = $null
|
||||||
|
$importedMachineType = $null
|
||||||
|
|
||||||
|
if ($makeName -eq 'Lenovo') {
|
||||||
|
$importedProductName = if ($importedModelObject.PSObject.Properties['ProductName']) { $importedModelObject.ProductName } else { $null }
|
||||||
|
$importedMachineType = if ($importedModelObject.PSObject.Properties['MachineType']) { $importedModelObject.MachineType } else { $null }
|
||||||
|
|
||||||
|
if ($null -ne $importedMachineType) {
|
||||||
|
$importedId = $importedMachineType # Override Id for Lenovo
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback parsing if ProductName/MachineType are missing from JSON but Name has the pattern
|
||||||
|
if (($null -eq $importedProductName -or $null -eq $importedMachineType) -and $importedModelNameFromObject -match '(.+?)\s*\((.+?)\)$') {
|
||||||
|
WriteLog "Import-DriversJson: Lenovo model '$importedModelNameFromObject' missing ProductName or MachineType in JSON. Attempting to parse from Name."
|
||||||
|
if ($null -eq $importedProductName) { $importedProductName = $matches[1].Trim() }
|
||||||
|
if ($null -eq $importedMachineType) {
|
||||||
|
$importedMachineType = $matches[2].Trim()
|
||||||
|
$importedId = $importedMachineType # Update Id if MachineType was parsed here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $importedProductName -or $null -eq $importedMachineType) {
|
||||||
|
WriteLog "Import-DriversJson: Warning - Lenovo model '$importedModelNameFromObject' is missing ProductName or MachineType after parsing. ID might be based on full name."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$newDriverModel = [PSCustomObject]@{
|
||||||
|
IsSelected = $true
|
||||||
|
Make = $makeName
|
||||||
|
Model = $importedModelNameFromObject # Full display name
|
||||||
|
Link = $importedLink
|
||||||
|
Id = $importedId
|
||||||
|
ProductName = $importedProductName
|
||||||
|
MachineType = $importedMachineType
|
||||||
|
Version = ""
|
||||||
|
Type = ""
|
||||||
|
Size = ""
|
||||||
|
Arch = ""
|
||||||
|
DownloadStatus = "Imported"
|
||||||
|
}
|
||||||
|
$State.Data.allDriverModels.Add($newDriverModel)
|
||||||
|
$newModelsAdded++
|
||||||
|
WriteLog "Import-DriversJson: Added new model '$($newDriverModel.Make) - $($newDriverModel.Model)' from import. ID: $($newDriverModel.Id), Link: $($newDriverModel.Link)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sort the full list of models
|
||||||
|
$sortedModels = $State.Data.allDriverModels | Sort-Object @{Expression = { $_.IsSelected }; Descending = $true }, Make, Model
|
||||||
|
|
||||||
|
# Create a new list from the sorted results and assign it to the state.
|
||||||
|
# This prevents the "ItemsControl inconsistent" error by replacing the source instead of modifying it.
|
||||||
|
$newList = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
if ($null -ne $sortedModels) {
|
||||||
|
foreach ($model in @($sortedModels)) {
|
||||||
|
$newList.Add($model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$State.Data.allDriverModels = $newList
|
||||||
|
|
||||||
|
# Update the UI and apply any existing filter
|
||||||
|
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
|
||||||
|
Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State
|
||||||
|
|
||||||
|
$message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated"
|
||||||
|
[System.Windows.MessageBox]::Show($message, "Import Successful", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||||
|
WriteLog $message
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
[System.Windows.MessageBox]::Show("Error importing drivers file: $($_.Exception.Message)", "Import Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
|
||||||
|
WriteLog "Error importing drivers file from $($ofd.FileName): $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Import drivers operation cancelled by user."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to handle the 'Get Models' button click logic
|
||||||
|
function Invoke-GetModels {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[object]$Button
|
||||||
|
)
|
||||||
|
|
||||||
|
$selectedMake = $State.Controls.cmbMake.SelectedItem
|
||||||
|
$State.Controls.txtStatus.Text = "Getting models for $selectedMake..."
|
||||||
|
$State.Window.Cursor = [System.Windows.Input.Cursors]::Wait
|
||||||
|
$Button.IsEnabled = $false
|
||||||
|
try {
|
||||||
|
# Get ALL previously selected models to preserve them, regardless of make.
|
||||||
|
$allPreviouslySelectedModels = @($State.Data.allDriverModels | Where-Object { $_.IsSelected })
|
||||||
|
|
||||||
|
# Get newly fetched models for the current make
|
||||||
|
$newlyFetchedStandardizedModels = Get-ModelsForMake -SelectedMake $selectedMake -State $State
|
||||||
|
|
||||||
|
$combinedModelsList = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
$modelIdentifiersInCombinedList = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||||
|
|
||||||
|
# Add all previously selected models first to preserve their 'IsSelected' state.
|
||||||
|
foreach ($item in $allPreviouslySelectedModels) {
|
||||||
|
$combinedModelsList.Add($item)
|
||||||
|
$modelIdentifiersInCombinedList.Add("$($item.Make)::$($item.Model)") | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add newly fetched models, but only if they are not already in the list.
|
||||||
|
# This prevents overwriting a selected model with an unselected one.
|
||||||
|
$addedNewCount = 0
|
||||||
|
foreach ($item in $newlyFetchedStandardizedModels) {
|
||||||
|
if ($modelIdentifiersInCombinedList.Add("$($item.Make)::$($item.Model)")) {
|
||||||
|
$combinedModelsList.Add($item)
|
||||||
|
$addedNewCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sort the combined list
|
||||||
|
$sortedModels = $combinedModelsList | Sort-Object @{Expression = { $_.IsSelected }; Descending = $true }, Make, Model
|
||||||
|
|
||||||
|
# Create a new list object from the sorted results. This is safer than modifying the existing list
|
||||||
|
# that the UI is bound to, which can cause inconsistency errors.
|
||||||
|
$newList = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
if ($null -ne $sortedModels) {
|
||||||
|
# Sort-Object can return a single object or an array. Ensure it's always treated as a collection.
|
||||||
|
foreach ($model in @($sortedModels)) {
|
||||||
|
$newList.Add($model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$State.Data.allDriverModels = $newList
|
||||||
|
|
||||||
|
# Update the UI ItemsSource to point to the new list and clear the filter
|
||||||
|
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
|
||||||
|
$State.Controls.txtModelFilter.Text = ""
|
||||||
|
|
||||||
|
if ($State.Data.allDriverModels.Count -gt 0) {
|
||||||
|
$State.Controls.spModelFilterSection.Visibility = 'Visible'
|
||||||
|
$State.Controls.lstDriverModels.Visibility = 'Visible'
|
||||||
|
$State.Controls.spDriverActionButtons.Visibility = 'Visible'
|
||||||
|
$statusText = "Displaying $($State.Data.allDriverModels.Count) models."
|
||||||
|
if ($newlyFetchedStandardizedModels.Count -gt 0 -and $addedNewCount -eq 0 -and $allPreviouslySelectedModels.Count -gt 0) {
|
||||||
|
$statusText = "Fetched $($newlyFetchedStandardizedModels.Count) models for $selectedMake; all were already in the selected list. Displaying $($State.Data.allDriverModels.Count) total selected models."
|
||||||
|
}
|
||||||
|
elseif ($addedNewCount -gt 0) {
|
||||||
|
$statusText = "Added $addedNewCount new models for $selectedMake. Displaying $($State.Data.allDriverModels.Count) total models."
|
||||||
|
}
|
||||||
|
elseif ($newlyFetchedStandardizedModels.Count -eq 0 -and $selectedMake -eq 'Lenovo' ) {
|
||||||
|
$statusText = if ($allPreviouslySelectedModels.Count -gt 0) { "No new models found for $selectedMake. Displaying $($allPreviouslySelectedModels.Count) previously selected models." } else { "No models found for $selectedMake." }
|
||||||
|
}
|
||||||
|
elseif ($newlyFetchedStandardizedModels.Count -eq 0) {
|
||||||
|
$statusText = "No new models found for $selectedMake. Displaying $($State.Data.allDriverModels.Count) previously selected models."
|
||||||
|
}
|
||||||
|
$State.Controls.txtStatus.Text = $statusText
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$State.Controls.spModelFilterSection.Visibility = 'Collapsed'
|
||||||
|
$State.Controls.lstDriverModels.Visibility = 'Collapsed'
|
||||||
|
$State.Controls.spDriverActionButtons.Visibility = 'Collapsed'
|
||||||
|
$State.Controls.txtStatus.Text = "No models to display for $selectedMake."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$State.Controls.txtStatus.Text = "Error getting models: $($_.Exception.Message)"
|
||||||
|
[System.Windows.MessageBox]::Show("Error getting models: $($_.Exception.Message)", "Error", "OK", "Error")
|
||||||
|
if ($null -eq $State.Data.allDriverModels -or $State.Data.allDriverModels.Count -eq 0) {
|
||||||
|
$State.Controls.spModelFilterSection.Visibility = 'Collapsed'
|
||||||
|
$State.Controls.lstDriverModels.Visibility = 'Collapsed'
|
||||||
|
$State.Controls.spDriverActionButtons.Visibility = 'Collapsed'
|
||||||
|
$State.Controls.lstDriverModels.ItemsSource = $null
|
||||||
|
$State.Controls.txtModelFilter.Text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$State.Window.Cursor = $null
|
||||||
|
$Button.IsEnabled = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to handle the 'Download Selected Drivers' button click logic
|
||||||
|
function Invoke-DownloadSelectedDrivers {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[object]$Button
|
||||||
|
)
|
||||||
|
|
||||||
|
$selectedDrivers = @($State.Data.allDriverModels | Where-Object { $_.IsSelected })
|
||||||
|
if (-not $selectedDrivers) {
|
||||||
|
[System.Windows.MessageBox]::Show("No drivers selected to download.", "Download Drivers", "OK", "Information")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$Button.IsEnabled = $false
|
||||||
|
$State.Controls.pbOverallProgress.Visibility = 'Visible'
|
||||||
|
$State.Controls.pbOverallProgress.Value = 0
|
||||||
|
$State.Controls.txtStatus.Text = "Preparing driver downloads..."
|
||||||
|
|
||||||
|
# Define common necessary task-specific variables locally
|
||||||
|
# Ensure required selections are made
|
||||||
|
if ($null -eq $State.Controls.cmbWindowsRelease.SelectedItem) {
|
||||||
|
[System.Windows.MessageBox]::Show("Please select a Windows Release.", "Missing Information", "OK", "Warning")
|
||||||
|
$Button.IsEnabled = $true
|
||||||
|
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||||
|
$State.Controls.txtStatus.Text = "Driver download cancelled."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ($null -eq $State.Controls.cmbWindowsArch.SelectedItem) {
|
||||||
|
[System.Windows.MessageBox]::Show("Please select a Windows Architecture.", "Missing Information", "OK", "Warning")
|
||||||
|
$Button.IsEnabled = $true
|
||||||
|
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||||
|
$State.Controls.txtStatus.Text = "Driver download cancelled."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (($selectedDrivers | Where-Object { $_.Make -eq 'HP' }) -and $null -ne $State.Controls.cmbWindowsVersion -and $null -eq $State.Controls.cmbWindowsVersion.SelectedItem) {
|
||||||
|
[System.Windows.MessageBox]::Show("HP drivers are selected. Please select a Windows Version.", "Missing Information", "OK", "Warning")
|
||||||
|
$Button.IsEnabled = $true
|
||||||
|
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||||
|
$State.Controls.txtStatus.Text = "Driver download cancelled."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$localDriversFolder = $State.Controls.txtDriversFolder.Text
|
||||||
|
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||||
|
$localWindowsArch = $State.Controls.cmbWindowsArch.SelectedItem
|
||||||
|
$localWindowsVersion = if ($null -ne $State.Controls.cmbWindowsVersion -and $null -ne $State.Controls.cmbWindowsVersion.SelectedItem) { $State.Controls.cmbWindowsVersion.SelectedItem } else { $null }
|
||||||
|
$coreStaticVars = Get-CoreStaticVariables
|
||||||
|
$localHeaders = $coreStaticVars.Headers
|
||||||
|
$localUserAgent = $coreStaticVars.UserAgent
|
||||||
|
$compressDrivers = $State.Controls.chkCompressDriversToWIM.IsChecked
|
||||||
|
|
||||||
|
$State.Controls.txtStatus.Text = "Processing all selected drivers..."
|
||||||
|
WriteLog "Processing all selected drivers: $($selectedDrivers.Model -join ', ')"
|
||||||
|
|
||||||
|
# Pre-process Dell Catalog if needed, so it's not done in parallel
|
||||||
|
if ($selectedDrivers | Where-Object { $_.Make -eq 'Dell' }) {
|
||||||
|
WriteLog "Dell drivers selected. Ensuring Dell Catalog is up-to-date..."
|
||||||
|
try {
|
||||||
|
$dellDriversFolder = Join-Path -Path $localDriversFolder -ChildPath "Dell"
|
||||||
|
$catalogBaseName = if ($localWindowsRelease -le 11) { "CatalogPC" } else { "Catalog" }
|
||||||
|
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
|
||||||
|
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
|
||||||
|
$catalogUrl = if ($localWindowsRelease -le 11) { "http://downloads.dell.com/catalog/CatalogPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
|
||||||
|
|
||||||
|
$downloadCatalog = $true
|
||||||
|
if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
|
||||||
|
if (((Get-Date) - (Get-Item $dellCatalogXML).CreationTime).TotalDays -lt 7) {
|
||||||
|
WriteLog "Using existing Dell Catalog XML (less than 7 days old): $dellCatalogXML"
|
||||||
|
$downloadCatalog = $false
|
||||||
|
}
|
||||||
|
else { WriteLog "Existing Dell Catalog XML is older than 7 days: $dellCatalogXML" }
|
||||||
|
}
|
||||||
|
else { WriteLog "Dell Catalog XML not found: $dellCatalogXML" }
|
||||||
|
|
||||||
|
if ($downloadCatalog) {
|
||||||
|
WriteLog "Downloading and extracting Dell Catalog for driver download process..."
|
||||||
|
if (-not (Test-Path -Path $dellDriversFolder -PathType Container)) {
|
||||||
|
New-Item -Path $dellDriversFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path -Path $dellCabFile) { Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue }
|
||||||
|
if (Test-Path -Path $dellCatalogXML) { Remove-Item -Path $dellCatalogXML -Force -ErrorAction SilentlyContinue }
|
||||||
|
|
||||||
|
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $dellCabFile
|
||||||
|
Invoke-Process -FilePath "Expand.exe" -ArgumentList """$dellCabFile"" ""$dellCatalogXML""" | Out-Null
|
||||||
|
Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue
|
||||||
|
WriteLog "Dell Catalog prepared successfully."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$errorMessage = "Failed to prepare Dell Catalog: $($_.Exception.Message)"
|
||||||
|
WriteLog $errorMessage
|
||||||
|
[System.Windows.MessageBox]::Show($errorMessage, "Dell Catalog Error", "OK", "Error")
|
||||||
|
$Button.IsEnabled = $true
|
||||||
|
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||||
|
$State.Controls.txtStatus.Text = "Driver download cancelled due to Dell Catalog error."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskArguments = @{
|
||||||
|
DriversFolder = $localDriversFolder
|
||||||
|
WindowsRelease = $localWindowsRelease
|
||||||
|
WindowsArch = $localWindowsArch
|
||||||
|
WindowsVersion = $localWindowsVersion
|
||||||
|
Headers = $localHeaders
|
||||||
|
UserAgent = $localUserAgent
|
||||||
|
CompressToWim = $compressDrivers
|
||||||
|
}
|
||||||
|
|
||||||
|
$parallelResults = Invoke-ParallelProcessing -ItemsToProcess $selectedDrivers `
|
||||||
|
-ListViewControl $State.Controls.lstDriverModels `
|
||||||
|
-IdentifierProperty 'Model' `
|
||||||
|
-StatusProperty 'DownloadStatus' `
|
||||||
|
-TaskType 'DownloadDriverByMake' `
|
||||||
|
-TaskArguments $taskArguments `
|
||||||
|
-CompletedStatusText 'Completed' `
|
||||||
|
-ErrorStatusPrefix 'Error: ' `
|
||||||
|
-WindowObject $State.Window `
|
||||||
|
-MainThreadLogPath $State.LogFilePath `
|
||||||
|
-ThrottleLimit $State.Controls.txtThreads.Text
|
||||||
|
|
||||||
|
$overallSuccess = $true
|
||||||
|
$successfullyDownloaded = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
|
||||||
|
# Check the results from the parallel processing tasks
|
||||||
|
if ($null -ne $parallelResults) {
|
||||||
|
# Create a lookup from the original selected drivers to get the 'Make' property,
|
||||||
|
# as the result object might only have 'Identifier' or 'Model'.
|
||||||
|
$makeLookup = @{}
|
||||||
|
$selectedDrivers | ForEach-Object { $makeLookup[$_.Model] = $_.Make }
|
||||||
|
|
||||||
|
# Filter for objects that could be results, avoiding stray log strings
|
||||||
|
foreach ($result in ($parallelResults | Where-Object { $_ -is [hashtable] })) {
|
||||||
|
if ($null -eq $result) { continue }
|
||||||
|
|
||||||
|
# The result from Invoke-ParallelProcessing is a hashtable.
|
||||||
|
# Access properties using their keys.
|
||||||
|
$modelName = $result['Identifier']
|
||||||
|
$resultCode = $result['ResultCode']
|
||||||
|
$driverPath = $result['DriverPath']
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($modelName)) {
|
||||||
|
WriteLog "Could not determine model name from result object: $($result | ConvertTo-Json -Compress -Depth 3)"
|
||||||
|
$overallSuccess = $false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resultCode -ne 0) {
|
||||||
|
$overallSuccess = $false
|
||||||
|
WriteLog "Error detected for model $modelName."
|
||||||
|
}
|
||||||
|
elseif (-not [string]::IsNullOrWhiteSpace($driverPath)) {
|
||||||
|
# The task was successful and returned a driver path.
|
||||||
|
$make = $makeLookup[$modelName]
|
||||||
|
if ($make) {
|
||||||
|
$successfullyDownloaded.Add([PSCustomObject]@{
|
||||||
|
Make = $make
|
||||||
|
Model = $modelName
|
||||||
|
DriverPath = $driverPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Warning: Could not find 'Make' for successful download of model '$modelName'. Skipping from DriverMapping.json."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update the driver mapping JSON if there are any successful downloads
|
||||||
|
if ($successfullyDownloaded.Count -gt 0) {
|
||||||
|
try {
|
||||||
|
WriteLog "Updating DriverMapping.json with $($successfullyDownloaded.Count) successfully downloaded drivers."
|
||||||
|
Update-DriverMappingJson -DownloadedDrivers $successfullyDownloaded -DriversFolder $localDriversFolder
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Failed to update DriverMapping.json: $($_.Exception.Message)"
|
||||||
|
# This is not a fatal error for the download process itself, so just show a warning.
|
||||||
|
[System.Windows.MessageBox]::Show("The driver download process completed, but failed to update the DriverMapping.json file. Please check the log for details.", "Driver Mapping Error", "OK", "Warning")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Automatically save the selected drivers to the specified Drivers.json path
|
||||||
|
$driversJsonPath = $State.Controls.txtDriversJsonPath.Text
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($driversJsonPath) -and $selectedDrivers.Count -gt 0) {
|
||||||
|
WriteLog "Attempting to automatically save selected drivers list to $driversJsonPath"
|
||||||
|
try {
|
||||||
|
$outputJson = @{} # Use a Hashtable for the desired structure
|
||||||
|
|
||||||
|
$selectedDrivers | Group-Object -Property Make | ForEach-Object {
|
||||||
|
$makeName = $_.Name
|
||||||
|
$modelsForThisMake = @() # Initialize an array to hold model objects
|
||||||
|
|
||||||
|
foreach ($driverItem in $_.Group) {
|
||||||
|
$modelObject = $null
|
||||||
|
switch ($makeName) {
|
||||||
|
'Microsoft' {
|
||||||
|
$modelObject = @{
|
||||||
|
Name = $driverItem.Model # Model is the display name
|
||||||
|
Link = $driverItem.Link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'Dell' {
|
||||||
|
$modelObject = @{
|
||||||
|
Name = $driverItem.Model # Model is the display name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'HP' {
|
||||||
|
$modelObject = @{
|
||||||
|
Name = $driverItem.Model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'Lenovo' {
|
||||||
|
$modelObject = @{
|
||||||
|
Name = $driverItem.Model
|
||||||
|
ProductName = $driverItem.ProductName
|
||||||
|
MachineType = $driverItem.MachineType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
WriteLog "Auto-Save Drivers.json: Unrecognized Make '$makeName' for driver '$($driverItem.Model)'. Skipping."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($null -ne $modelObject) {
|
||||||
|
$modelsForThisMake += $modelObject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Add the models array to the make-specific object
|
||||||
|
$outputJson[$makeName] = @{ Models = $modelsForThisMake }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
$parentDir = Split-Path -Path $driversJsonPath -Parent
|
||||||
|
if (-not (Test-Path -Path $parentDir -PathType Container)) {
|
||||||
|
WriteLog "Creating directory for Drivers.json: $parentDir"
|
||||||
|
New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$outputJson | ConvertTo-Json -Depth 5 | Set-Content -Path $driversJsonPath -Encoding UTF8
|
||||||
|
WriteLog "Successfully auto-saved selected drivers to $driversJsonPath"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Failed to automatically save selected drivers to $driversJsonPath. Error: $($_.Exception.Message)"
|
||||||
|
# This is a best-effort operation, so we only log the error and don't bother the user with a popup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||||
|
$Button.IsEnabled = $true
|
||||||
|
if ($overallSuccess) {
|
||||||
|
$State.Controls.txtStatus.Text = "All selected driver downloads processed."
|
||||||
|
[System.Windows.MessageBox]::Show("All selected driver downloads processed. Check status column for details.", "Download Process Finished", "OK", "Information")
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$State.Controls.txtStatus.Text = "Driver downloads processed with some errors. Check status column and log."
|
||||||
|
[System.Windows.MessageBox]::Show("Driver downloads processed, but some errors occurred. Please check the status column for each driver and the log file for details.", "Download Process Finished with Errors", "OK", "Warning")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function *
|
||||||
@@ -0,0 +1,887 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Contains the function for registering all WPF UI event handlers for the FFU Builder application.
|
||||||
|
.DESCRIPTION
|
||||||
|
This module is dedicated to managing user interactions within the FFU Builder UI. It contains the Register-EventHandlers function, which connects UI controls defined in the XAML to their corresponding actions in the PowerShell backend. This includes handling button clicks, text input validation, checkbox state changes, and list view interactions across all tabs, effectively wiring up the application's front-end to its core logic.
|
||||||
|
#>
|
||||||
|
|
||||||
|
function Register-EventHandlers {
|
||||||
|
param([PSCustomObject]$State)
|
||||||
|
WriteLog "Registering UI event handlers..."
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# SECTION: Shared Input Validation Handlers
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Define a shared event handler for TextBoxes that should only accept integer input
|
||||||
|
$integerPreviewTextInputHandler = {
|
||||||
|
param($eventSource, $textCompositionEventArgs)
|
||||||
|
# Use a regex to check if the input text is NOT a digit. \D matches any non-digit character.
|
||||||
|
if ($textCompositionEventArgs.Text -match '\D') {
|
||||||
|
# If the input is not a digit, mark the event as handled to prevent the character from being entered.
|
||||||
|
$textCompositionEventArgs.Handled = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Define a handler to validate pasted text, ensuring it's only integers
|
||||||
|
$integerPastingHandler = {
|
||||||
|
param($sender, $pastingEventArgs)
|
||||||
|
if ($pastingEventArgs.DataObject.GetDataPresent([string])) {
|
||||||
|
$pastedText = $pastingEventArgs.DataObject.GetData([string])
|
||||||
|
# Check if the pasted text consists ONLY of one or more digits.
|
||||||
|
if ($pastedText -notmatch '^\d+$') {
|
||||||
|
# If not, cancel the paste operation.
|
||||||
|
$pastingEventArgs.CancelCommand()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# If the pasted data is not in a string format, cancel it.
|
||||||
|
$pastingEventArgs.CancelCommand()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# List of TextBox controls that require integer-only input
|
||||||
|
$integerOnlyTextBoxes = @(
|
||||||
|
$State.Controls.txtDiskSize,
|
||||||
|
$State.Controls.txtMemory,
|
||||||
|
$State.Controls.txtProcessors,
|
||||||
|
$State.Controls.txtThreads
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach the handlers to each relevant textbox
|
||||||
|
foreach ($textBox in $integerOnlyTextBoxes) {
|
||||||
|
if ($null -ne $textBox) {
|
||||||
|
$textBox.Add_PreviewTextInput($integerPreviewTextInputHandler)
|
||||||
|
[System.Windows.DataObject]::AddPastingHandler($textBox, $integerPastingHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add specific validation for the Threads textbox to ensure it's not empty and is at least 1
|
||||||
|
if ($null -ne $State.Controls.txtThreads) {
|
||||||
|
$State.Controls.txtThreads.Add_LostFocus({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$textBox = $eventSource
|
||||||
|
$currentValue = 0
|
||||||
|
# Try to parse the current text as an integer
|
||||||
|
$isValidInteger = [int]::TryParse($textBox.Text, [ref]$currentValue)
|
||||||
|
|
||||||
|
# If the text is not a valid integer OR the value is less than 1, reset it to the default value '1'
|
||||||
|
if (-not $isValidInteger -or $currentValue -lt 1) {
|
||||||
|
$textBox.Text = '1'
|
||||||
|
WriteLog "Threads value was invalid or less than 1. Reset to 1."
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build Tab Event Handlers
|
||||||
|
$State.Controls.btnBrowseFFUDevPath.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select FFU Development Path"
|
||||||
|
if ($selectedPath) {
|
||||||
|
$localState.Controls.txtFFUDevPath.Text = $selectedPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnBrowseFFUCaptureLocation.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select FFU Capture Location"
|
||||||
|
if ($selectedPath) {
|
||||||
|
$localState.Controls.txtFFUCaptureLocation.Text = $selectedPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build USB Drive Settings Event Handlers
|
||||||
|
$State.Controls.chkBuildUSBDriveEnable.Add_Checked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.usbSection.Visibility = 'Visible'
|
||||||
|
$localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $true
|
||||||
|
})
|
||||||
|
$State.Controls.chkBuildUSBDriveEnable.Add_Unchecked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.usbSection.Visibility = 'Collapsed'
|
||||||
|
$localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $false
|
||||||
|
$localState.Controls.chkSelectSpecificUSBDrives.IsChecked = $false
|
||||||
|
$localState.Controls.lstUSBDrives.Items.Clear()
|
||||||
|
})
|
||||||
|
$State.Controls.chkSelectSpecificUSBDrives.Add_Checked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.usbSelectionPanel.Visibility = 'Visible'
|
||||||
|
})
|
||||||
|
$State.Controls.chkSelectSpecificUSBDrives.Add_Unchecked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.usbSelectionPanel.Visibility = 'Collapsed'
|
||||||
|
$localState.Controls.lstUSBDrives.Items.Clear()
|
||||||
|
})
|
||||||
|
$State.Controls.chkAllowExternalHardDiskMedia.Add_Checked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $true
|
||||||
|
})
|
||||||
|
$State.Controls.chkAllowExternalHardDiskMedia.Add_Unchecked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $false
|
||||||
|
$localState.Controls.chkPromptExternalHardDiskMedia.IsChecked = $false
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnCheckUSBDrives.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
$localState.Controls.lstUSBDrives.Items.Clear()
|
||||||
|
$usbDrives = Get-USBDrives
|
||||||
|
foreach ($drive in $usbDrives) {
|
||||||
|
$driveObject = [PSCustomObject]$drive
|
||||||
|
# Explicitly add and initialize the IsSelected property for each new item.
|
||||||
|
$driveObject | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false -Force
|
||||||
|
$localState.Controls.lstUSBDrives.Items.Add($driveObject)
|
||||||
|
}
|
||||||
|
if ($localState.Controls.lstUSBDrives.Items.Count -gt 0) {
|
||||||
|
$localState.Controls.lstUSBDrives.SelectedIndex = 0
|
||||||
|
}
|
||||||
|
WriteLog "Check USB Drives: Found $($localState.Controls.lstUSBDrives.Items.Count) USB drives."
|
||||||
|
# After clearing and repopulating, update the 'Select All' header checkbox state
|
||||||
|
$headerChk = $localState.Controls.chkSelectAllUSBDrivesHeader
|
||||||
|
if ($null -ne $headerChk) {
|
||||||
|
Update-SelectAllHeaderCheckBoxState -ListView $localState.Controls.lstUSBDrives -HeaderCheckBox $headerChk
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
$State.Controls.lstUSBDrives.Add_PreviewKeyDown({
|
||||||
|
param($eventSource, $keyEvent)
|
||||||
|
if ($keyEvent.Key -eq 'Space') {
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Invoke-ListViewItemToggle -ListView $eventSource -State $localState -HeaderCheckBoxKeyName 'chkSelectAllUSBDrivesHeader'
|
||||||
|
$keyEvent.Handled = $true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
$State.Controls.lstUSBDrives.Add_SelectionChanged({
|
||||||
|
param($eventSource, $selChangeEvent)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
# Update the 'Select All' header checkbox state based on current selections
|
||||||
|
$headerChk = $localState.Controls.chkSelectAllUSBDrivesHeader
|
||||||
|
if ($null -ne $headerChk) {
|
||||||
|
Update-SelectAllHeaderCheckBoxState -ListView $localState.Controls.lstUSBDrives -HeaderCheckBox $headerChk
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Hyper-V tab event handlers
|
||||||
|
$State.Controls.cmbVMSwitchName.Add_SelectionChanged({
|
||||||
|
param($eventSource, $selectionChangedEventArgs)
|
||||||
|
# The state object is available via the parent window's Tag property
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
$selectedItem = $eventSource.SelectedItem
|
||||||
|
if ($selectedItem -eq 'Other') {
|
||||||
|
$localState.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
|
||||||
|
$localState.Controls.txtVMHostIPAddress.Text = '' # Clear IP for custom
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$localState.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
|
||||||
|
if ($localState.Data.vmSwitchMap.ContainsKey($selectedItem)) {
|
||||||
|
$localState.Controls.txtVMHostIPAddress.Text = $localState.Data.vmSwitchMap[$selectedItem]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$localState.Controls.txtVMHostIPAddress.Text = '' # Clear IP if not found in map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Windows Settings tab Event Handlers
|
||||||
|
$State.Controls.txtISOPath.Add_TextChanged({
|
||||||
|
param($eventSource, $textChangedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Get-WindowsSettingsCombos -isoPath $localState.Controls.txtISOPath.Text -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.cmbWindowsRelease.Add_SelectionChanged({
|
||||||
|
param($eventSource, $selectionChangedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$selectedReleaseValue = 11 # Default if null
|
||||||
|
if ($null -ne $localState.Controls.cmbWindowsRelease.SelectedItem) {
|
||||||
|
$selectedReleaseValue = $localState.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||||
|
}
|
||||||
|
Update-WindowsVersionCombo -selectedRelease $selectedReleaseValue -isoPath $localState.Controls.txtISOPath.Text -State $localState
|
||||||
|
Update-WindowsSkuCombo -State $localState
|
||||||
|
Update-WindowsArchCombo -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.cmbWindowsVersion.Add_SelectionChanged({
|
||||||
|
param($eventSource, $selectionChangedEventArgs)
|
||||||
|
# This event should only fire on user interaction or after Update-WindowsVersionCombo runs.
|
||||||
|
# We only need to update the architecture, as SKU is dependent only on Release.
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
if ($null -eq $window) { return } # Window might be closing
|
||||||
|
$localState = $window.Tag
|
||||||
|
Update-WindowsArchCombo -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.cmbWindowsSKU.Add_SelectionChanged({
|
||||||
|
param($eventSource, $selectionChangedEventArgs)
|
||||||
|
# This event should only fire on user interaction or after Update-WindowsSkuCombo runs.
|
||||||
|
# We only need to update the architecture.
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
if ($null -eq $window) { return } # Window might be closing
|
||||||
|
$localState = $window.Tag
|
||||||
|
Update-WindowsArchCombo -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnBrowseISO.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title "Select Windows ISO File" -Filter "ISO files (*.iso)|*.iso"
|
||||||
|
if ($selectedPath) {
|
||||||
|
$localState.Controls.txtISOPath.Text = $selectedPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Updates Tab Event Handlers
|
||||||
|
# Define a single handler scriptblock for all update checkboxes that affect the main InstallApps checkbox
|
||||||
|
$updateCheckboxHandler = {
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
if ($null -ne $window) {
|
||||||
|
# The function to call now lives in the Applications module
|
||||||
|
Update-InstallAppsState -State $window.Tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attach the handler to all relevant update checkboxes
|
||||||
|
$State.Controls.chkUpdateLatestDefender.Add_Checked($updateCheckboxHandler)
|
||||||
|
$State.Controls.chkUpdateLatestDefender.Add_Unchecked($updateCheckboxHandler)
|
||||||
|
$State.Controls.chkUpdateEdge.Add_Checked($updateCheckboxHandler)
|
||||||
|
$State.Controls.chkUpdateEdge.Add_Unchecked($updateCheckboxHandler)
|
||||||
|
$State.Controls.chkUpdateOneDrive.Add_Checked($updateCheckboxHandler)
|
||||||
|
$State.Controls.chkUpdateOneDrive.Add_Unchecked($updateCheckboxHandler)
|
||||||
|
$State.Controls.chkUpdateLatestMSRT.Add_Checked($updateCheckboxHandler)
|
||||||
|
$State.Controls.chkUpdateLatestMSRT.Add_Unchecked($updateCheckboxHandler)
|
||||||
|
|
||||||
|
# Also attach the handler to the Office checkbox
|
||||||
|
$State.Controls.chkInstallOffice.Add_Checked($updateCheckboxHandler)
|
||||||
|
$State.Controls.chkInstallOffice.Add_Unchecked($updateCheckboxHandler)
|
||||||
|
|
||||||
|
# CU Interplay Event Handlers
|
||||||
|
$State.Controls.chkLatestCU.Add_Checked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.chkPreviewCU.IsEnabled = $false
|
||||||
|
})
|
||||||
|
$State.Controls.chkLatestCU.Add_Unchecked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.chkPreviewCU.IsEnabled = $true
|
||||||
|
})
|
||||||
|
$State.Controls.chkPreviewCU.Add_Checked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.chkLatestCU.IsEnabled = $false
|
||||||
|
})
|
||||||
|
$State.Controls.chkPreviewCU.Add_Unchecked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.chkLatestCU.IsEnabled = $true
|
||||||
|
})
|
||||||
|
|
||||||
|
# Applications Tab Event Handlers
|
||||||
|
# Define a single handler for interdependent application panel checkboxes
|
||||||
|
$appPanelUpdateHandler = {
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
if ($null -ne $window) {
|
||||||
|
Update-ApplicationPanelVisibility -State $window.Tag -TriggeringControlName $eventSource.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attach the handler to all relevant checkboxes
|
||||||
|
$State.Controls.chkInstallApps.Add_Checked($appPanelUpdateHandler)
|
||||||
|
$State.Controls.chkInstallApps.Add_Unchecked($appPanelUpdateHandler)
|
||||||
|
$State.Controls.chkBringYourOwnApps.Add_Checked($appPanelUpdateHandler)
|
||||||
|
$State.Controls.chkBringYourOwnApps.Add_Unchecked($appPanelUpdateHandler)
|
||||||
|
$State.Controls.chkInstallWingetApps.Add_Checked($appPanelUpdateHandler)
|
||||||
|
$State.Controls.chkInstallWingetApps.Add_Unchecked($appPanelUpdateHandler)
|
||||||
|
|
||||||
|
$State.Controls.btnBrowseApplicationPath.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select Application Path Folder"
|
||||||
|
if ($selectedPath) { $localState.Controls.txtApplicationPath.Text = $selectedPath }
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnBrowseAppListJsonPath.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title "Select AppList.json File" -Filter "JSON files (*.json)|*.json" -AllowNewFile
|
||||||
|
if ($selectedPath) { $localState.Controls.txtAppListJsonPath.Text = $selectedPath }
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnBrowseAppSource.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select Application Source Folder"
|
||||||
|
if ($selectedPath) { $localState.Controls.txtAppSource.Text = $selectedPath }
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnAddApplication.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Add-BYOApplication -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnSaveBYOApplications.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
$initialDir = $localState.Controls.txtApplicationPath.Text
|
||||||
|
if ([string]::IsNullOrWhiteSpace($initialDir) -or -not (Test-Path $initialDir)) { $initialDir = $localState.FFUDevelopmentPath }
|
||||||
|
|
||||||
|
$savePath = Invoke-BrowseAction -Type 'SaveFile' `
|
||||||
|
-Title "Save Application List" `
|
||||||
|
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
|
||||||
|
-InitialDirectory $initialDir `
|
||||||
|
-FileName "UserAppList.json" `
|
||||||
|
-DefaultExt ".json"
|
||||||
|
|
||||||
|
if ($savePath) { Save-BYOApplicationList -Path $savePath -State $localState }
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnLoadBYOApplications.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
$initialDir = $localState.Controls.txtApplicationPath.Text
|
||||||
|
if ([string]::IsNullOrWhiteSpace($initialDir) -or -not (Test-Path $initialDir)) { $initialDir = $localState.FFUDevelopmentPath }
|
||||||
|
|
||||||
|
$loadPath = Invoke-BrowseAction -Type 'OpenFile' `
|
||||||
|
-Title "Import Application List" `
|
||||||
|
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
|
||||||
|
-InitialDirectory $initialDir
|
||||||
|
|
||||||
|
if ($loadPath) {
|
||||||
|
Import-BYOApplicationList -Path $loadPath -State $localState
|
||||||
|
Update-CopyButtonState -State $localState
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnClearBYOApplications.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
Clear-ListViewContent -State $localState `
|
||||||
|
-ListViewControl $localState.Controls.lstApplications `
|
||||||
|
-ConfirmationTitle "Clear BYO Applications" `
|
||||||
|
-ConfirmationMessage "Are you sure you want to clear all 'Bring Your Own' applications?" `
|
||||||
|
-StatusMessage "BYO application list cleared." `
|
||||||
|
-PostClearAction { Update-CopyButtonState -State $State }
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnCopyBYOApps.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Invoke-CopyBYOApps -State $localState -Button $eventSource
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnMoveTop.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Move-ListViewItemTop -ListView $localState.Controls.lstApplications
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnMoveUp.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Move-ListViewItemUp -ListView $localState.Controls.lstApplications
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnMoveDown.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Move-ListViewItemDown -ListView $localState.Controls.lstApplications
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnMoveBottom.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Move-ListViewItemBottom -ListView $localState.Controls.lstApplications
|
||||||
|
})
|
||||||
|
|
||||||
|
# Apps Script Variables Event Handlers
|
||||||
|
# Attach the handler to the script variables checkbox
|
||||||
|
$State.Controls.chkDefineAppsScriptVariables.Add_Checked($appPanelUpdateHandler)
|
||||||
|
$State.Controls.chkDefineAppsScriptVariables.Add_Unchecked($appPanelUpdateHandler)
|
||||||
|
|
||||||
|
$State.Controls.btnAddAppsScriptVariable.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Add-AppsScriptVariable -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnRemoveSelectedAppsScriptVariables.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Remove-SelectedAppsScriptVariable -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnClearAppsScriptVariables.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
$postClearScriptBlock = {
|
||||||
|
$headerChk = $localState.Controls.chkSelectAllAppsScriptVariables
|
||||||
|
if ($null -ne $headerChk) {
|
||||||
|
Update-SelectAllHeaderCheckBoxState -ListView $localState.Controls.lstAppsScriptVariables -HeaderCheckBox $headerChk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Clear-ListViewContent -State $localState `
|
||||||
|
-ListViewControl $localState.Controls.lstAppsScriptVariables `
|
||||||
|
-BackingDataList $localState.Data.appsScriptVariablesDataList `
|
||||||
|
-ConfirmationTitle "Clear Apps Script Variables" `
|
||||||
|
-ConfirmationMessage "Are you sure you want to clear all Apps Script Variables?" `
|
||||||
|
-StatusMessage "Apps Script Variables list cleared." `
|
||||||
|
-TextBoxesToClear @($localState.Controls.txtAppsScriptKey, $localState.Controls.txtAppsScriptValue) `
|
||||||
|
-PostClearAction $postClearScriptBlock
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.lstAppsScriptVariables.Add_PreviewKeyDown({
|
||||||
|
param($eventSource, $keyEvent)
|
||||||
|
if ($keyEvent.Key -eq 'Space') {
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Invoke-ListViewItemToggle -ListView $eventSource -State $localState -HeaderCheckBoxKeyName 'chkSelectAllAppsScriptVariables'
|
||||||
|
$keyEvent.Handled = $true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnCheckWingetModule.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$buttonSender = $eventSource
|
||||||
|
|
||||||
|
$buttonSender.IsEnabled = $false
|
||||||
|
$window.Cursor = [System.Windows.Input.Cursors]::Wait
|
||||||
|
# Initial UI update before calling the core function
|
||||||
|
Update-WingetVersionFields -State $localState -wingetText "Checking..." -moduleText "Checking..."
|
||||||
|
|
||||||
|
$statusResult = $null
|
||||||
|
try {
|
||||||
|
# Call the Core function to perform checks and potential install/update
|
||||||
|
# Pass the UI update function as a callback
|
||||||
|
$statusResult = Confirm-WingetInstallationUI -UiUpdateCallback {
|
||||||
|
param($wingetText, $moduleText)
|
||||||
|
Update-WingetVersionFields -State $localState -wingetText $wingetText -moduleText $moduleText
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display appropriate message based on the result
|
||||||
|
if ($statusResult.Success -and $statusResult.UpdateAttempted) {
|
||||||
|
# Update attempted and successful
|
||||||
|
[System.Windows.MessageBox]::Show("Winget components installed/updated successfully.", "Winget Installation Complete", "OK", "Information")
|
||||||
|
}
|
||||||
|
elseif (-not $statusResult.Success) {
|
||||||
|
# Error occurred
|
||||||
|
$errorMessage = if (-not [string]::IsNullOrWhiteSpace($statusResult.Message)) { $statusResult.Message } else { "An unknown error occurred during Winget check/install." }
|
||||||
|
[System.Windows.MessageBox]::Show($errorMessage, "Winget Error", "OK", "Error")
|
||||||
|
}
|
||||||
|
# If Winget components were already up-to-date ($statusResult.Success -eq $true -and $statusResult.UpdateAttempted -eq $false), no message box is shown.
|
||||||
|
|
||||||
|
# Show search panel only if the final status is successful and checkbox is still checked
|
||||||
|
if ($statusResult.Success -and $localState.Controls.chkInstallWingetApps.IsChecked) {
|
||||||
|
$localState.Controls.wingetSearchPanel.Visibility = 'Visible'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$localState.Controls.wingetSearchPanel.Visibility = 'Collapsed' # Hide if not successful or unchecked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Catch errors from the Confirm-WingetInstallationUI call itself (less likely now)
|
||||||
|
Update-WingetVersionFields -State $localState -wingetText "Error" -moduleText "Error"
|
||||||
|
[System.Windows.MessageBox]::Show("Unexpected error checking/installing Winget components: $($_.Exception.Message)", "Error", "OK", "Error")
|
||||||
|
$localState.Controls.wingetSearchPanel.Visibility = 'Collapsed' # Ensure search is hidden on error
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$buttonSender.IsEnabled = $true
|
||||||
|
$window.Cursor = $null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnWingetSearch.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Search-WingetApps -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.txtWingetSearch.Add_KeyDown({
|
||||||
|
param($eventSource, $keyEvent)
|
||||||
|
if ($keyEvent.Key -eq 'Return') {
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Search-WingetApps -State $localState
|
||||||
|
$keyEvent.Handled = $true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnSaveWingetList.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Save-WingetList -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnImportWingetList.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Import-WingetList -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnClearWingetList.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
$postClearScriptBlock = {
|
||||||
|
$headerChk = $localState.Controls.chkSelectAllWingetResults
|
||||||
|
if ($null -ne $headerChk) {
|
||||||
|
Update-SelectAllHeaderCheckBoxState -ListView $localState.Controls.lstWingetResults -HeaderCheckBox $headerChk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Clear-ListViewContent -State $localState `
|
||||||
|
-ListViewControl $localState.Controls.lstWingetResults `
|
||||||
|
-ConfirmationTitle "Clear Winget List" `
|
||||||
|
-ConfirmationMessage "Are you sure you want to clear the Winget application list and search results?" `
|
||||||
|
-StatusMessage "Winget application list cleared." `
|
||||||
|
-TextBoxesToClear @($localState.Controls.txtWingetSearch) `
|
||||||
|
-PostClearAction $postClearScriptBlock
|
||||||
|
})
|
||||||
|
$State.Controls.lstWingetResults.Add_PreviewKeyDown({
|
||||||
|
param($eventSource, $keyEvent)
|
||||||
|
if ($keyEvent.Key -eq 'Space') {
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Invoke-ListViewItemToggle -ListView $eventSource -State $localState -HeaderCheckBoxKeyName 'chkSelectAllWingetResults'
|
||||||
|
$keyEvent.Handled = $true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnDownloadSelected.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Invoke-WingetDownload -State $localState -Button $eventSource
|
||||||
|
})
|
||||||
|
|
||||||
|
# M365 Apps/Office tab Event
|
||||||
|
$State.Controls.btnBrowseOfficePath.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select Office Path"
|
||||||
|
if ($selectedPath) {
|
||||||
|
$localState.Controls.txtOfficePath.Text = $selectedPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnBrowseOfficeConfigXMLFile.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title "Select Office Configuration XML File" -Filter "XML files (*.xml)|*.xml"
|
||||||
|
if ($selectedPath) {
|
||||||
|
$localState.Controls.txtOfficeConfigXMLFilePath.Text = $selectedPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.chkInstallOffice.Add_Checked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.OfficePathStackPanel.Visibility = 'Visible'
|
||||||
|
$localState.Controls.OfficePathGrid.Visibility = 'Visible'
|
||||||
|
$localState.Controls.CopyOfficeConfigXMLStackPanel.Visibility = 'Visible'
|
||||||
|
# Show/hide XML file path based on checkbox state
|
||||||
|
$localState.Controls.OfficeConfigurationXMLFileStackPanel.Visibility = if ($localState.Controls.chkCopyOfficeConfigXML.IsChecked) { 'Visible' } else { 'Collapsed' }
|
||||||
|
$localState.Controls.OfficeConfigurationXMLFileGrid.Visibility = if ($localState.Controls.chkCopyOfficeConfigXML.IsChecked) { 'Visible' } else { 'Collapsed' }
|
||||||
|
})
|
||||||
|
$State.Controls.chkInstallOffice.Add_Unchecked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$localState.Controls.OfficePathStackPanel.Visibility = 'Collapsed'
|
||||||
|
$localState.Controls.OfficePathGrid.Visibility = 'Collapsed'
|
||||||
|
$localState.Controls.CopyOfficeConfigXMLStackPanel.Visibility = 'Collapsed'
|
||||||
|
$localState.Controls.OfficeConfigurationXMLFileStackPanel.Visibility = 'Collapsed'
|
||||||
|
$localState.Controls.OfficeConfigurationXMLFileGrid.Visibility = 'Collapsed'
|
||||||
|
})
|
||||||
|
$State.Controls.chkCopyOfficeConfigXML.Add_Checked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
$localState.Controls.OfficeConfigurationXMLFileStackPanel.Visibility = 'Visible'
|
||||||
|
$localState.Controls.OfficeConfigurationXMLFileGrid.Visibility = 'Visible'
|
||||||
|
})
|
||||||
|
$State.Controls.chkCopyOfficeConfigXML.Add_Unchecked({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
$localState.Controls.OfficeConfigurationXMLFileStackPanel.Visibility = 'Collapsed'
|
||||||
|
$localState.Controls.OfficeConfigurationXMLFileGrid.Visibility = 'Collapsed'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Drivers Tab Event Handlers
|
||||||
|
# Define a single handler for interdependent driver checkboxes
|
||||||
|
$driverCheckboxHandler = {
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
if ($null -ne $window) {
|
||||||
|
Update-DriverCheckboxStates -State $window.Tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attach the handler to all relevant checkboxes
|
||||||
|
$State.Controls.chkInstallDrivers.Add_Checked($driverCheckboxHandler)
|
||||||
|
$State.Controls.chkInstallDrivers.Add_Unchecked($driverCheckboxHandler)
|
||||||
|
$State.Controls.chkCopyDrivers.Add_Checked($driverCheckboxHandler)
|
||||||
|
$State.Controls.chkCopyDrivers.Add_Unchecked($driverCheckboxHandler)
|
||||||
|
$State.Controls.chkCompressDriversToWIM.Add_Checked($driverCheckboxHandler)
|
||||||
|
$State.Controls.chkCompressDriversToWIM.Add_Unchecked($driverCheckboxHandler)
|
||||||
|
|
||||||
|
$State.Controls.btnBrowseDriversFolder.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$initialDir = Join-Path -Path $localState.FFUDevelopmentPath -ChildPath "Drivers"
|
||||||
|
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select Drivers Folder" -InitialDirectory $initialDir
|
||||||
|
if ($selectedPath) {
|
||||||
|
$localState.Controls.txtDriversFolder.Text = $selectedPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnBrowsePEDriversFolder.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select PE Drivers Folder"
|
||||||
|
if ($selectedPath) {
|
||||||
|
$localState.Controls.txtPEDriversFolder.Text = $selectedPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnBrowseDriversJsonPath.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
$dialogInitialDirectory = $null
|
||||||
|
$currentDriversJsonPath = $localState.Controls.txtDriversJsonPath.Text
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($currentDriversJsonPath)) {
|
||||||
|
try {
|
||||||
|
$parentDir = Split-Path -Path $currentDriversJsonPath -Parent -ErrorAction Stop
|
||||||
|
if (Test-Path -Path $parentDir -PathType Container) {
|
||||||
|
$dialogInitialDirectory = $parentDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Could not determine initial directory from '$currentDriversJsonPath'. Using default."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedPath = Invoke-BrowseAction -Type 'SaveFile' `
|
||||||
|
-Title "Select or Create Drivers.json File" `
|
||||||
|
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
|
||||||
|
-FileName "Drivers.json" `
|
||||||
|
-InitialDirectory $dialogInitialDirectory `
|
||||||
|
-AllowNewFile
|
||||||
|
|
||||||
|
if ($selectedPath) {
|
||||||
|
$localState.Controls.txtDriversJsonPath.Text = $selectedPath
|
||||||
|
WriteLog "User selected or created Drivers.json at: $selectedPath"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "User cancelled SaveFileDialog for Drivers.json."
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Define a single handler for the Download Drivers checkbox
|
||||||
|
$driverDownloadCheckboxHandler = {
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
if ($null -ne $window) {
|
||||||
|
Update-DriverDownloadPanelVisibility -State $window.Tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$State.Controls.chkDownloadDrivers.Add_Checked($driverDownloadCheckboxHandler)
|
||||||
|
$State.Controls.chkDownloadDrivers.Add_Unchecked($driverDownloadCheckboxHandler)
|
||||||
|
|
||||||
|
$State.Controls.btnGetModels.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Invoke-GetModels -State $localState -Button $eventSource
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.txtModelFilter.Add_TextChanged({
|
||||||
|
param($sourceObject, $textChangedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($sourceObject)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Search-DriverModels -filterText $localState.Controls.txtModelFilter.Text -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnDownloadSelectedDrivers.Add_Click({
|
||||||
|
param($buttonSender, $clickEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($buttonSender)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Invoke-DownloadSelectedDrivers -State $localState -Button $buttonSender
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnClearDriverList.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
$postClearScriptBlock = {
|
||||||
|
# This scriptblock inherits the $localState variable from its parent scope.
|
||||||
|
$headerChk = $localState.Controls.chkSelectAllDriverModels
|
||||||
|
if ($null -ne $headerChk) {
|
||||||
|
Update-SelectAllHeaderCheckBoxState -ListView $localState.Controls.lstDriverModels -HeaderCheckBox $headerChk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Clear-ListViewContent -State $localState `
|
||||||
|
-ListViewControl $localState.Controls.lstDriverModels `
|
||||||
|
-BackingDataList $localState.Data.allDriverModels `
|
||||||
|
-ConfirmationTitle "Clear Driver List" `
|
||||||
|
-ConfirmationMessage "Are you sure you want to clear the driver list?" `
|
||||||
|
-StatusMessage "Driver list cleared." `
|
||||||
|
-TextBoxesToClear @($localState.Controls.txtModelFilter)`
|
||||||
|
-PostClearAction $postClearScriptBlock
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.lstDriverModels.Add_PreviewKeyDown({
|
||||||
|
param($eventSource, $keyEvent)
|
||||||
|
if ($keyEvent.Key -eq 'Space') {
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Invoke-ListViewItemToggle -ListView $eventSource -State $localState -HeaderCheckBoxKeyName 'chkSelectAllDriverModels'
|
||||||
|
$keyEvent.Handled = $true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnSaveDriversJson.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Save-DriversJson -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnImportDriversJson.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Import-DriversJson -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.btnLoadConfig.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Invoke-LoadConfiguration -State $localState
|
||||||
|
})
|
||||||
|
$State.Controls.btnBuildConfig.Add_Click({
|
||||||
|
param($eventSource, $routedEventArgs)
|
||||||
|
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||||
|
$localState = $window.Tag
|
||||||
|
Invoke-SaveConfiguration -State $localState
|
||||||
|
})
|
||||||
|
|
||||||
|
# Monitor Tab Event Handlers
|
||||||
|
$State.Controls.lstLogOutput.Add_KeyDown({
|
||||||
|
param($eventSource, $keyEventArgs)
|
||||||
|
# Check for Ctrl+C
|
||||||
|
if ($keyEventArgs.Key -eq 'C' -and ($keyEventArgs.KeyboardDevice.Modifiers -band [System.Windows.Input.ModifierKeys]::Control)) {
|
||||||
|
$listBox = $eventSource
|
||||||
|
if ($listBox.SelectedItems.Count -gt 0) {
|
||||||
|
$selectedLines = $listBox.SelectedItems | ForEach-Object { $_.ToString() }
|
||||||
|
$clipboardText = $selectedLines -join [System.Environment]::NewLine
|
||||||
|
|
||||||
|
try {
|
||||||
|
[System.Windows.Clipboard]::SetText($clipboardText)
|
||||||
|
WriteLog "Copied $($listBox.SelectedItems.Count) log lines to clipboard."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error copying to clipboard: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$keyEventArgs.Handled = $true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls.lstLogOutput.Add_SelectionChanged({
|
||||||
|
param($eventSource, $selectionChangedEventArgs)
|
||||||
|
$listBox = $eventSource
|
||||||
|
$window = [System.Windows.Window]::GetWindow($listBox)
|
||||||
|
if ($null -eq $window) { return }
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
# If nothing is selected or the list is empty, do nothing.
|
||||||
|
if ($listBox.SelectedIndex -eq -1 -or $listBox.Items.Count -eq 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if the last item is selected
|
||||||
|
$isLastItemSelected = ($listBox.SelectedIndex -eq ($listBox.Items.Count - 1))
|
||||||
|
|
||||||
|
# Update the flag
|
||||||
|
$localState.Flags.autoScrollLog = $isLastItemSelected
|
||||||
|
if ($isLastItemSelected) {
|
||||||
|
# WriteLog "Monitor tab autoscroll enabled (last item selected)."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Monitor tab autoscroll disabled (user selected item #$($listBox.SelectedIndex))."
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Export-ModuleMember -Function *
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
<#
|
||||||
|
.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.txtOptionalFeatures = $window.FindName('txtOptionalFeatures')
|
||||||
|
$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.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.btnAddApplication = $window.FindName('btnAddApplication')
|
||||||
|
$State.Controls.btnSaveBYOApplications = $window.FindName('btnSaveBYOApplications')
|
||||||
|
$State.Controls.btnLoadBYOApplications = $window.FindName('btnLoadBYOApplications')
|
||||||
|
$State.Controls.btnClearBYOApplications = $window.FindName('btnClearBYOApplications')
|
||||||
|
$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.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.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.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.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 ($State.Data.vmSwitchMap.ContainsKey($firstSwitch)) {
|
||||||
|
$State.Controls.txtVMHostIPAddress.Text = $State.Data.vmSwitchMap[$firstSwitch]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default if IP not found
|
||||||
|
}
|
||||||
|
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
|
||||||
|
}
|
||||||
|
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.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.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
|
||||||
|
|
||||||
|
# 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.txtOptionalFeatures.Text = $State.Defaults.windowsSettingsDefaults.DefaultOptionalFeatures
|
||||||
|
$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.chkCompressDriversToWIM.IsChecked = $State.Defaults.generalDefaults.CompressDownloadedDriversToWim
|
||||||
|
|
||||||
|
# Drivers tab UI logic
|
||||||
|
$makeList = @('Microsoft', 'Dell', 'HP', 'Lenovo')
|
||||||
|
foreach ($m in $makeList) {
|
||||||
|
[void]$State.Controls.cmbMake.Items.Add($m)
|
||||||
|
}
|
||||||
|
if ($State.Controls.cmbMake.Items.Count -gt 0) {
|
||||||
|
$State.Controls.cmbMake.SelectedIndex = 0
|
||||||
|
}
|
||||||
|
Update-DriverDownloadPanelVisibility -State $State
|
||||||
|
|
||||||
|
# Set initial state for driver checkbox interplay
|
||||||
|
Update-DriverCheckboxStates -State $State
|
||||||
|
|
||||||
|
# Set initial state for InstallApps checkbox based on updates
|
||||||
|
Update-InstallAppsState -State $State
|
||||||
|
|
||||||
|
# Set initial state for Office panel visibility
|
||||||
|
Update-OfficePanelVisibility -State $State
|
||||||
|
|
||||||
|
# Set initial state for Application panel visibility
|
||||||
|
Update-ApplicationPanelVisibility -State $State
|
||||||
|
|
||||||
|
# Set initial state for BYO Apps copy button
|
||||||
|
Update-CopyButtonState -State $State
|
||||||
|
}
|
||||||
|
|
||||||
|
function Initialize-DynamicUIElements {
|
||||||
|
param([PSCustomObject]$State)
|
||||||
|
WriteLog "Initializing dynamic UI elements (Grids, Columns)..."
|
||||||
|
|
||||||
|
# Driver Models ListView setup
|
||||||
|
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||||
|
$itemStyleDriverModels = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||||
|
$itemStyleDriverModels.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||||
|
$State.Controls.lstDriverModels.ItemContainerStyle = $itemStyleDriverModels
|
||||||
|
|
||||||
|
$driverModelsGridView = New-Object System.Windows.Controls.GridView
|
||||||
|
$State.Controls.lstDriverModels.View = $driverModelsGridView # Assign GridView to ListView first
|
||||||
|
|
||||||
|
# Add the selectable column using the new function
|
||||||
|
Add-SelectableGridViewColumn -ListView $State.Controls.lstDriverModels -State $State -HeaderCheckBoxKeyName "chkSelectAllDriverModels" -ColumnWidth 70
|
||||||
|
|
||||||
|
# Add other sortable columns with left-aligned headers
|
||||||
|
Add-SortableColumn -gridView $driverModelsGridView -header "Make" -binding "Make" -width 100 -headerHorizontalAlignment Left
|
||||||
|
Add-SortableColumn -gridView $driverModelsGridView -header "Model" -binding "Model" -width 200 -headerHorizontalAlignment Left
|
||||||
|
Add-SortableColumn -gridView $driverModelsGridView -header "Status" -binding "DownloadStatus" -width 150 -headerHorizontalAlignment Left
|
||||||
|
$State.Controls.lstDriverModels.AddHandler(
|
||||||
|
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
|
||||||
|
[System.Windows.RoutedEventHandler] {
|
||||||
|
param($eventSource, $e) # $eventSource is the ListView control
|
||||||
|
$header = $e.OriginalSource
|
||||||
|
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
|
||||||
|
# Retrieve the main UI state object from the window's Tag property
|
||||||
|
$listViewControl = $eventSource
|
||||||
|
$window = [System.Windows.Window]::GetWindow($listViewControl)
|
||||||
|
$uiStateFromWindowTag = $window.Tag
|
||||||
|
|
||||||
|
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Winget Search ListView setup
|
||||||
|
$wingetGridView = New-Object System.Windows.Controls.GridView
|
||||||
|
$State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first
|
||||||
|
|
||||||
|
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||||
|
$itemStyleWingetResults = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||||
|
$itemStyleWingetResults.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||||
|
$State.Controls.lstWingetResults.ItemContainerStyle = $itemStyleWingetResults
|
||||||
|
|
||||||
|
# Add the selectable column using the new function
|
||||||
|
Add-SelectableGridViewColumn -ListView $State.Controls.lstWingetResults -State $State -HeaderCheckBoxKeyName "chkSelectAllWingetResults" -ColumnWidth 60
|
||||||
|
|
||||||
|
# Add other sortable columns with left-aligned headers
|
||||||
|
Add-SortableColumn -gridView $wingetGridView -header "Name" -binding "Name" -width 200 -headerHorizontalAlignment Left
|
||||||
|
Add-SortableColumn -gridView $wingetGridView -header "Id" -binding "Id" -width 200 -headerHorizontalAlignment Left
|
||||||
|
Add-SortableColumn -gridView $wingetGridView -header "Version" -binding "Version" -width 100 -headerHorizontalAlignment Left
|
||||||
|
Add-SortableColumn -gridView $wingetGridView -header "Source" -binding "Source" -width 100 -headerHorizontalAlignment Left
|
||||||
|
|
||||||
|
# --- START: Add Architecture Column ---
|
||||||
|
$archColumn = New-Object System.Windows.Controls.GridViewColumn
|
||||||
|
$archHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||||
|
$archHeader.Tag = "Architecture" # For sorting
|
||||||
|
$archHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||||
|
|
||||||
|
# Create header content with correct padding to match other columns
|
||||||
|
$commonPaddingForHeader = New-Object System.Windows.Thickness(5, 2, 5, 2)
|
||||||
|
$headerTextElementFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
|
||||||
|
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Architecture")
|
||||||
|
$headerTextBlockPadding = New-Object System.Windows.Thickness($commonPaddingForHeader.Left, $commonPaddingForHeader.Top, $commonPaddingForHeader.Right, $commonPaddingForHeader.Bottom)
|
||||||
|
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $headerTextBlockPadding)
|
||||||
|
$headerTextElementFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||||
|
|
||||||
|
$headerDataTemplate = New-Object System.Windows.DataTemplate
|
||||||
|
$headerDataTemplate.VisualTree = $headerTextElementFactory
|
||||||
|
$archHeader.ContentTemplate = $headerDataTemplate
|
||||||
|
|
||||||
|
$archColumn.Header = $archHeader
|
||||||
|
$archColumn.Width = 120
|
||||||
|
|
||||||
|
# Create the CellTemplate with a ComboBox
|
||||||
|
$archCellTemplate = New-Object System.Windows.DataTemplate
|
||||||
|
$comboBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.ComboBox])
|
||||||
|
|
||||||
|
# The ItemsSource for the ComboBox
|
||||||
|
$availableArchitectures = @('x86', 'x64', 'arm64', 'x86 x64', 'NA')
|
||||||
|
$comboBoxFactory.SetValue([System.Windows.Controls.ItemsControl]::ItemsSourceProperty, $availableArchitectures)
|
||||||
|
|
||||||
|
# Bind the text property to the 'Architecture' property of the data item.
|
||||||
|
# This ensures the initial value is displayed correctly.
|
||||||
|
$binding = New-Object System.Windows.Data.Binding("Architecture")
|
||||||
|
$binding.Mode = [System.Windows.Data.BindingMode]::TwoWay
|
||||||
|
$comboBoxFactory.SetBinding([System.Windows.Controls.ComboBox]::TextProperty, $binding)
|
||||||
|
|
||||||
|
# Create a style to disable the ComboBox for 'msstore' source
|
||||||
|
$comboBoxStyle = New-Object System.Windows.Style
|
||||||
|
$comboBoxStyle.TargetType = [System.Windows.Controls.ComboBox]
|
||||||
|
|
||||||
|
$dataTrigger = New-Object System.Windows.DataTrigger
|
||||||
|
$dataTrigger.Binding = New-Object System.Windows.Data.Binding("Source")
|
||||||
|
$dataTrigger.Value = "msstore"
|
||||||
|
$dataTrigger.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ComboBox]::IsEnabledProperty, $false)))
|
||||||
|
|
||||||
|
$comboBoxStyle.Triggers.Add($dataTrigger)
|
||||||
|
$comboBoxFactory.SetValue([System.Windows.FrameworkElement]::StyleProperty, $comboBoxStyle)
|
||||||
|
|
||||||
|
$archCellTemplate.VisualTree = $comboBoxFactory
|
||||||
|
$archColumn.CellTemplate = $archCellTemplate
|
||||||
|
$wingetGridView.Columns.Add($archColumn)
|
||||||
|
# --- END: Add Architecture Column ---
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apps Script Variables ListView setup
|
||||||
|
# Bind ItemsSource to the data list
|
||||||
|
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||||
|
|
||||||
|
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||||
|
$itemStyleAppsScriptVars = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||||
|
$itemStyleAppsScriptVars.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||||
|
$State.Controls.lstAppsScriptVariables.ItemContainerStyle = $itemStyleAppsScriptVars
|
||||||
|
|
||||||
|
# The GridView for lstAppsScriptVariables is defined in XAML. We need to get it and add the column.
|
||||||
|
if ($State.Controls.lstAppsScriptVariables.View -is [System.Windows.Controls.GridView]) {
|
||||||
|
Add-SelectableGridViewColumn -ListView $State.Controls.lstAppsScriptVariables -State $State -HeaderCheckBoxKeyName "chkSelectAllAppsScriptVariables" -ColumnWidth 60
|
||||||
|
|
||||||
|
# Make Key and Value columns sortable
|
||||||
|
$appsScriptVarsGridView = $State.Controls.lstAppsScriptVariables.View
|
||||||
|
|
||||||
|
# Key Column (should be at index 1 after selectable column is inserted at 0)
|
||||||
|
if ($appsScriptVarsGridView.Columns.Count -gt 1) {
|
||||||
|
$keyColumn = $appsScriptVarsGridView.Columns[1]
|
||||||
|
$keyHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||||
|
$keyHeader.Content = "Key"
|
||||||
|
$keyHeader.Tag = "Key" # Property to sort by
|
||||||
|
$keyHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||||
|
$keyColumn.Header = $keyHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
# Value Column (should be at index 2)
|
||||||
|
if ($appsScriptVarsGridView.Columns.Count -gt 2) {
|
||||||
|
$valueColumn = $appsScriptVarsGridView.Columns[2]
|
||||||
|
$valueHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||||
|
$valueHeader.Content = "Value"
|
||||||
|
$valueHeader.Tag = "Value" # Property to sort by
|
||||||
|
$valueHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||||
|
$valueColumn.Header = $valueHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add Click event handler for sorting
|
||||||
|
$State.Controls.lstAppsScriptVariables.AddHandler(
|
||||||
|
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
|
||||||
|
[System.Windows.RoutedEventHandler] {
|
||||||
|
param($eventSource, $e) # $eventSource is the ListView control
|
||||||
|
$header = $e.OriginalSource
|
||||||
|
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
|
||||||
|
# Retrieve the main UI state object from the window's Tag property
|
||||||
|
$listViewControl = $eventSource
|
||||||
|
$window = [System.Windows.Window]::GetWindow($listViewControl)
|
||||||
|
$uiStateFromWindowTag = $window.Tag
|
||||||
|
|
||||||
|
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build dynamic multi-column checkboxes for optional features
|
||||||
|
if ($State.Controls.featuresPanel -and $State.Defaults.windowsSettingsDefaults) {
|
||||||
|
BuildFeaturesGrid -parent $State.Controls.featuresPanel -allowedFeatures $State.Defaults.windowsSettingsDefaults.AllowedFeatures -State $State
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Initialize-DynamicUIElements: Could not build features grid. Panel or defaults missing."
|
||||||
|
}
|
||||||
|
|
||||||
|
# USB Drives ListView setup
|
||||||
|
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||||
|
$itemStyleUSBDrives = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||||
|
$itemStyleUSBDrives.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||||
|
$State.Controls.lstUSBDrives.ItemContainerStyle = $itemStyleUSBDrives
|
||||||
|
|
||||||
|
if ($State.Controls.lstUSBDrives.View -is [System.Windows.Controls.GridView]) {
|
||||||
|
# Add the selectable column using the shared function
|
||||||
|
Add-SelectableGridViewColumn -ListView $State.Controls.lstUSBDrives -State $State -HeaderCheckBoxKeyName "chkSelectAllUSBDrivesHeader" -ColumnWidth 70
|
||||||
|
|
||||||
|
# Make other columns sortable
|
||||||
|
$usbDrivesGridView = $State.Controls.lstUSBDrives.View
|
||||||
|
|
||||||
|
# Model Column (index 0 in XAML, now 1)
|
||||||
|
if ($usbDrivesGridView.Columns.Count -gt 1) {
|
||||||
|
$modelColumn = $usbDrivesGridView.Columns[1]
|
||||||
|
$modelHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||||
|
$modelHeader.Content = "Model"
|
||||||
|
$modelHeader.Tag = "Model" # Property to sort by
|
||||||
|
$modelHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||||
|
$modelColumn.Header = $modelHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serial Number Column (index 1 in XAML, now 2)
|
||||||
|
if ($usbDrivesGridView.Columns.Count -gt 2) {
|
||||||
|
$serialColumn = $usbDrivesGridView.Columns[2]
|
||||||
|
$serialHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||||
|
$serialHeader.Content = "Serial Number"
|
||||||
|
$serialHeader.Tag = "SerialNumber" # Property to sort by
|
||||||
|
$serialHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||||
|
$serialColumn.Header = $serialHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
# Size Column (index 2 in XAML, now 3)
|
||||||
|
if ($usbDrivesGridView.Columns.Count -gt 3) {
|
||||||
|
$sizeColumn = $usbDrivesGridView.Columns[3]
|
||||||
|
$sizeHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||||
|
$sizeHeader.Content = "Size (GB)"
|
||||||
|
$sizeHeader.Tag = "Size" # Property to sort by
|
||||||
|
$sizeHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||||
|
$sizeColumn.Header = $sizeHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add Click event handler for sorting
|
||||||
|
$State.Controls.lstUSBDrives.AddHandler(
|
||||||
|
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
|
||||||
|
[System.Windows.RoutedEventHandler] {
|
||||||
|
param($eventSource, $e) # $eventSource is the ListView control
|
||||||
|
$header = $e.OriginalSource
|
||||||
|
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
|
||||||
|
# Retrieve the main UI state object from the window's Tag property
|
||||||
|
$listViewControl = $eventSource
|
||||||
|
$window = [System.Windows.Window]::GetWindow($listViewControl)
|
||||||
|
$uiStateFromWindowTag = $window.Tag
|
||||||
|
|
||||||
|
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Export-ModuleMember -Function *
|
||||||
@@ -0,0 +1,954 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Provides a collection of shared helper functions for manipulating WPF UI controls, handling asynchronous updates, and managing common UI interactions.
|
||||||
|
.DESCRIPTION
|
||||||
|
This module contains a variety of reusable functions designed to support the FFU Builder UI. It includes utilities for managing ListView controls, such as sorting, reordering items, and handling 'Select All' functionality. It also provides thread-safe mechanisms for updating the UI from background tasks, wrappers for modern and classic file/folder dialogs, and generic functions for clearing UI content. These shared functions help to reduce code duplication and ensure consistent behavior across different parts of the application.
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Function to update priorities sequentially in a ListView
|
||||||
|
function Update-ListViewPriorities {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[System.Windows.Controls.ListView]$ListView
|
||||||
|
)
|
||||||
|
|
||||||
|
$currentPriority = 1
|
||||||
|
foreach ($item in $ListView.Items) {
|
||||||
|
if ($null -ne $item -and $item.PSObject.Properties['Priority']) {
|
||||||
|
$item.Priority = $currentPriority
|
||||||
|
$currentPriority++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ListView.Items.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to move selected item to the top
|
||||||
|
function Move-ListViewItemTop {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[System.Windows.Controls.ListView]$ListView
|
||||||
|
)
|
||||||
|
|
||||||
|
$selectedItem = $ListView.SelectedItem
|
||||||
|
if ($null -eq $selectedItem) { return }
|
||||||
|
|
||||||
|
$currentIndex = $ListView.Items.IndexOf($selectedItem)
|
||||||
|
if ($currentIndex -gt 0) {
|
||||||
|
$ListView.Items.RemoveAt($currentIndex)
|
||||||
|
$ListView.Items.Insert(0, $selectedItem)
|
||||||
|
$ListView.SelectedItem = $selectedItem
|
||||||
|
Update-ListViewPriorities -ListView $ListView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to move selected item up one position
|
||||||
|
function Move-ListViewItemUp {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[System.Windows.Controls.ListView]$ListView
|
||||||
|
)
|
||||||
|
|
||||||
|
$selectedItem = $ListView.SelectedItem
|
||||||
|
if ($null -eq $selectedItem) { return }
|
||||||
|
|
||||||
|
$currentIndex = $ListView.Items.IndexOf($selectedItem)
|
||||||
|
if ($currentIndex -gt 0) {
|
||||||
|
$ListView.Items.RemoveAt($currentIndex)
|
||||||
|
$ListView.Items.Insert($currentIndex - 1, $selectedItem)
|
||||||
|
$ListView.SelectedItem = $selectedItem
|
||||||
|
Update-ListViewPriorities -ListView $ListView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to move selected item down one position
|
||||||
|
function Move-ListViewItemDown {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[System.Windows.Controls.ListView]$ListView
|
||||||
|
)
|
||||||
|
|
||||||
|
$selectedItem = $ListView.SelectedItem
|
||||||
|
if ($null -eq $selectedItem) { return }
|
||||||
|
|
||||||
|
$currentIndex = $ListView.Items.IndexOf($selectedItem)
|
||||||
|
if ($currentIndex -lt ($ListView.Items.Count - 1)) {
|
||||||
|
$ListView.Items.RemoveAt($currentIndex)
|
||||||
|
$ListView.Items.Insert($currentIndex + 1, $selectedItem)
|
||||||
|
$ListView.SelectedItem = $selectedItem
|
||||||
|
Update-ListViewPriorities -ListView $ListView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to move selected item to the bottom
|
||||||
|
function Move-ListViewItemBottom {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[System.Windows.Controls.ListView]$ListView
|
||||||
|
)
|
||||||
|
|
||||||
|
$selectedItem = $ListView.SelectedItem
|
||||||
|
if ($null -eq $selectedItem) { return }
|
||||||
|
|
||||||
|
$currentIndex = $ListView.Items.IndexOf($selectedItem)
|
||||||
|
if ($currentIndex -lt ($ListView.Items.Count - 1)) {
|
||||||
|
$ListView.Items.RemoveAt($currentIndex)
|
||||||
|
$ListView.Items.Add($selectedItem)
|
||||||
|
$ListView.SelectedItem = $selectedItem
|
||||||
|
Update-ListViewPriorities -ListView $ListView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to update status of a specific item in a ListView
|
||||||
|
function Update-ListViewItemStatus {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[object]$WindowObject,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[object]$ListView,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$IdentifierProperty,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$IdentifierValue,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$StatusProperty,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$StatusValue
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure we are in UI mode and objects are of correct WPF types
|
||||||
|
if ($WindowObject -is [System.Windows.Window] -and $ListView -is [System.Windows.Controls.ListView]) {
|
||||||
|
# Directly update UI elements as this function is now called on the UI thread
|
||||||
|
try {
|
||||||
|
# Determine which collection to search: ItemsSource (preferred) or Items.
|
||||||
|
$collectionToSearch = $null
|
||||||
|
if ($null -ne $ListView.ItemsSource) {
|
||||||
|
$collectionToSearch = $ListView.ItemsSource
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$collectionToSearch = $ListView.Items
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemToUpdate = $collectionToSearch | Where-Object { $_.$IdentifierProperty -eq $IdentifierValue } | Select-Object -First 1
|
||||||
|
if ($null -ne $itemToUpdate) {
|
||||||
|
$itemToUpdate.$StatusProperty = $StatusValue
|
||||||
|
$ListView.Items.Refresh() # Refresh the view to show the change
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Log if item not found (for debugging)
|
||||||
|
WriteLog "Update-ListViewItemStatus: Item with $IdentifierProperty '$IdentifierValue' not found in ListView."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Update-ListViewItemStatus: Error updating ListView: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Log if called in non-UI mode or with incorrect types (should not happen if Invoke-ParallelProcessing $isUiMode is correct)
|
||||||
|
WriteLog "Update-ListViewItemStatus: Skipped UI update for $IdentifierValue due to non-UI mode or incorrect object types."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to update overall progress bar and status text label
|
||||||
|
function Update-OverallProgress {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[object]$WindowObject,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[int]$CompletedCount,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[int]$TotalCount,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$StatusText,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$ProgressBarName,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$StatusLabelName
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure we are in UI mode and WindowObject is of correct WPF type
|
||||||
|
if ($WindowObject -is [System.Windows.Window]) {
|
||||||
|
# Directly update UI elements as this function is now called on the UI thread
|
||||||
|
try {
|
||||||
|
# Find controls by name using the $WindowObject
|
||||||
|
$pb = $WindowObject.FindName($ProgressBarName)
|
||||||
|
$lbl = $WindowObject.FindName($StatusLabelName)
|
||||||
|
|
||||||
|
if ($null -eq $pb) {
|
||||||
|
WriteLog "Update-OverallProgress: ProgressBar '$ProgressBarName' not found."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ($null -eq $lbl) {
|
||||||
|
WriteLog "Update-OverallProgress: StatusLabel '$StatusLabelName' not found."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update the progress bar
|
||||||
|
if ($TotalCount -gt 0) {
|
||||||
|
$percentComplete = ($CompletedCount / $TotalCount) * 100
|
||||||
|
$pb.Value = $percentComplete
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$pb.Value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update the status label
|
||||||
|
$lbl.Text = $StatusText
|
||||||
|
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Update-OverallProgress: Error updating progress: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Log if called in non-UI mode or with incorrect types
|
||||||
|
WriteLog "Update-OverallProgress: Skipped UI update ($StatusText) due to non-UI mode or incorrect WindowObject type."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper function to enqueue progress updates to the UI thread
|
||||||
|
function Invoke-ProgressUpdate {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Identifier,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Status
|
||||||
|
)
|
||||||
|
$ProgressQueue.Enqueue(@{ Identifier = $Identifier; Status = $Status })
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add a function to create a sortable list view
|
||||||
|
function Add-SortableColumn {
|
||||||
|
param(
|
||||||
|
[System.Windows.Controls.GridView]$gridView,
|
||||||
|
[string]$header,
|
||||||
|
[string]$binding,
|
||||||
|
[int]$width = 'Auto',
|
||||||
|
[bool]$isCheckbox = $false,
|
||||||
|
[System.Windows.HorizontalAlignment]$headerHorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
|
||||||
|
)
|
||||||
|
|
||||||
|
$column = New-Object System.Windows.Controls.GridViewColumn
|
||||||
|
$commonPadding = New-Object System.Windows.Thickness(5, 2, 5, 2)
|
||||||
|
|
||||||
|
$headerControl = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||||
|
$headerControl.Tag = $binding # Used for sorting
|
||||||
|
|
||||||
|
if ($isCheckbox) {
|
||||||
|
# Cell template for a column of checkboxes
|
||||||
|
$cellTemplate = New-Object System.Windows.DataTemplate
|
||||||
|
$gridFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Grid])
|
||||||
|
|
||||||
|
$checkBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox])
|
||||||
|
$checkBoxFactory.SetBinding([System.Windows.Controls.CheckBox]::IsCheckedProperty, (New-Object System.Windows.Data.Binding("IsSelected")))
|
||||||
|
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
|
||||||
|
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||||
|
|
||||||
|
$checkBoxFactory.AddHandler([System.Windows.Controls.CheckBox]::ClickEvent, [System.Windows.RoutedEventHandler] {
|
||||||
|
param($eventSourceLocal, $eventArgsLocal)
|
||||||
|
})
|
||||||
|
$gridFactory.AppendChild($checkBoxFactory)
|
||||||
|
$cellTemplate.VisualTree = $gridFactory
|
||||||
|
$column.CellTemplate = $cellTemplate
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# For regular text columns
|
||||||
|
$headerControl.HorizontalContentAlignment = $headerHorizontalAlignment
|
||||||
|
$headerControl.Content = $header
|
||||||
|
|
||||||
|
$headerTextElementFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
|
||||||
|
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, $header)
|
||||||
|
$headerTextBlockPadding = New-Object System.Windows.Thickness($commonPadding.Left, $commonPadding.Top, $commonPadding.Right, $commonPadding.Bottom)
|
||||||
|
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $headerTextBlockPadding)
|
||||||
|
$headerTextElementFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||||
|
|
||||||
|
$headerDataTemplate = New-Object System.Windows.DataTemplate
|
||||||
|
$headerDataTemplate.VisualTree = $headerTextElementFactory
|
||||||
|
$headerControl.ContentTemplate = $headerDataTemplate
|
||||||
|
|
||||||
|
$cellTemplate = New-Object System.Windows.DataTemplate
|
||||||
|
$textBlockFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
|
||||||
|
$textBlockFactory.SetBinding([System.Windows.Controls.TextBlock]::TextProperty, (New-Object System.Windows.Data.Binding($binding)))
|
||||||
|
# Adjust left padding to 0 for cell text to align with header text
|
||||||
|
$cellTextBlockPadding = New-Object System.Windows.Thickness(0, $commonPadding.Top, $commonPadding.Right, $commonPadding.Bottom)
|
||||||
|
$textBlockFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $cellTextBlockPadding)
|
||||||
|
$textBlockFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Left)
|
||||||
|
$textBlockFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||||
|
|
||||||
|
$cellTemplate.VisualTree = $textBlockFactory
|
||||||
|
$column.CellTemplate = $cellTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
$column.Header = $headerControl
|
||||||
|
|
||||||
|
if ($width -ne 'Auto') {
|
||||||
|
$column.Width = $width
|
||||||
|
}
|
||||||
|
|
||||||
|
$gridView.Columns.Add($column)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to add a selectable GridViewColumn with a "Select All" header CheckBox
|
||||||
|
function Add-SelectableGridViewColumn {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[System.Windows.Controls.ListView]$ListView,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[psobject]$State,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$HeaderCheckBoxKeyName,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[double]$ColumnWidth,
|
||||||
|
[string]$IsSelectedPropertyName = "IsSelected"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the ListView has a GridView
|
||||||
|
if ($null -eq $ListView.View -or -not ($ListView.View -is [System.Windows.Controls.GridView])) {
|
||||||
|
WriteLog "Add-SelectableGridViewColumn: ListView '$($ListView.Name)' does not have a GridView or View is null. Cannot add column."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$gridView = $ListView.View
|
||||||
|
|
||||||
|
# Create the "Select All" CheckBox for the header
|
||||||
|
$headerCheckBox = New-Object System.Windows.Controls.CheckBox
|
||||||
|
$headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center
|
||||||
|
|
||||||
|
# MODIFICATION: Store the actual ListView object in the header's Tag
|
||||||
|
$headerTagObject = [PSCustomObject]@{
|
||||||
|
PropertyName = $IsSelectedPropertyName
|
||||||
|
ListViewControl = $ListView
|
||||||
|
}
|
||||||
|
$headerCheckBox.Tag = $headerTagObject
|
||||||
|
|
||||||
|
$headerCheckBox.Add_Checked({
|
||||||
|
param($senderCheckBoxLocal, $eventArgsCheckedLocal)
|
||||||
|
$tagData = $senderCheckBoxLocal.Tag
|
||||||
|
$localPropertyName = $tagData.PropertyName
|
||||||
|
$actualListView = $tagData.ListViewControl
|
||||||
|
|
||||||
|
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items }
|
||||||
|
if ($null -ne $collectionToUpdate) {
|
||||||
|
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $true }
|
||||||
|
$actualListView.Items.Refresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$headerCheckBox.Add_Unchecked({
|
||||||
|
param($senderCheckBoxLocal, $eventArgsUncheckedLocal)
|
||||||
|
if ($senderCheckBoxLocal.IsChecked -eq $false) {
|
||||||
|
$tagData = $senderCheckBoxLocal.Tag
|
||||||
|
$localPropertyName = $tagData.PropertyName
|
||||||
|
$actualListView = $tagData.ListViewControl
|
||||||
|
|
||||||
|
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items }
|
||||||
|
if ($null -ne $collectionToUpdate) {
|
||||||
|
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $false }
|
||||||
|
$actualListView.Items.Refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$State.Controls[$HeaderCheckBoxKeyName] = $headerCheckBox
|
||||||
|
WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in State.Controls with key '$HeaderCheckBoxKeyName'."
|
||||||
|
|
||||||
|
$selectableColumn = New-Object System.Windows.Controls.GridViewColumn
|
||||||
|
$selectableColumn.Header = $headerCheckBox
|
||||||
|
$selectableColumn.Width = $ColumnWidth
|
||||||
|
|
||||||
|
$cellTemplate = New-Object System.Windows.DataTemplate
|
||||||
|
$borderFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Border])
|
||||||
|
$borderFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)
|
||||||
|
$borderFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Stretch)
|
||||||
|
|
||||||
|
$checkBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox])
|
||||||
|
$checkBoxFactory.SetBinding([System.Windows.Controls.CheckBox]::IsCheckedProperty, (New-Object System.Windows.Data.Binding($IsSelectedPropertyName)))
|
||||||
|
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
|
||||||
|
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||||
|
|
||||||
|
# MODIFICATION: Store the actual ListView object in the item checkbox's Tag
|
||||||
|
$tagObject = [PSCustomObject]@{
|
||||||
|
HeaderCheckboxKeyName = $HeaderCheckBoxKeyName
|
||||||
|
ListViewControl = $ListView
|
||||||
|
}
|
||||||
|
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::TagProperty, $tagObject)
|
||||||
|
|
||||||
|
$checkBoxFactory.AddHandler([System.Windows.Controls.CheckBox]::ClickEvent, [System.Windows.RoutedEventHandler] {
|
||||||
|
param($eventSourceLocal, $eventArgsLocal)
|
||||||
|
$itemCheckBox = $eventSourceLocal -as [System.Windows.Controls.CheckBox]
|
||||||
|
$tagData = $itemCheckBox.Tag
|
||||||
|
|
||||||
|
$headerCheckboxKeyFromTag = $tagData.HeaderCheckboxKeyName
|
||||||
|
$targetListView = $tagData.ListViewControl
|
||||||
|
|
||||||
|
# Get the state from the window tag
|
||||||
|
$window = [System.Windows.Window]::GetWindow($targetListView)
|
||||||
|
if ($null -eq $window -or $null -eq $window.Tag) {
|
||||||
|
WriteLog "Add-SelectableGridViewColumn: ERROR - Could not get window or state from window tag."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$localState = $window.Tag
|
||||||
|
|
||||||
|
WriteLog "Add-SelectableGridViewColumn: Item Click. ListView: '$($targetListView.Name)', HeaderChkKey: '$headerCheckboxKeyFromTag'"
|
||||||
|
|
||||||
|
$headerChk = $localState.Controls[$headerCheckboxKeyFromTag]
|
||||||
|
if ($null -ne $headerChk) {
|
||||||
|
Update-SelectAllHeaderCheckBoxState -ListView $targetListView -HeaderCheckBox $headerChk
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Add-SelectableGridViewColumn: Error - Could not retrieve header checkbox from state with key '$headerCheckboxKeyFromTag'."
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$borderFactory.AppendChild($checkBoxFactory)
|
||||||
|
$cellTemplate.VisualTree = $borderFactory
|
||||||
|
$selectableColumn.CellTemplate = $cellTemplate
|
||||||
|
|
||||||
|
$gridView.Columns.Insert(0, $selectableColumn)
|
||||||
|
WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to update the IsChecked state of a "Select All" header CheckBox
|
||||||
|
function Update-SelectAllHeaderCheckBoxState {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[System.Windows.Controls.ListView]$ListView,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[System.Windows.Controls.CheckBox]$HeaderCheckBox
|
||||||
|
)
|
||||||
|
|
||||||
|
$collectionToInspect = $null
|
||||||
|
if ($null -ne $ListView.ItemsSource) {
|
||||||
|
$collectionToInspect = @($ListView.ItemsSource)
|
||||||
|
}
|
||||||
|
elseif ($ListView.HasItems) {
|
||||||
|
# Check if Items collection has items and ItemsSource is null
|
||||||
|
$collectionToInspect = @($ListView.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
# If no items to inspect (either ItemsSource was null and Items was empty, or ItemsSource was empty)
|
||||||
|
if ($null -eq $collectionToInspect -or $collectionToInspect.Count -eq 0) {
|
||||||
|
$HeaderCheckBox.IsChecked = $false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedCount = ($collectionToInspect | Where-Object { $_.IsSelected }).Count
|
||||||
|
WriteLog "Update-SelectAllHeaderCheckBoxState: Selected count is $selectedCount for ListView '$($ListView.Name)'."
|
||||||
|
$totalItemCount = $collectionToInspect.Count # Get the total count from the collection being inspected
|
||||||
|
WriteLog "Update-SelectAllHeaderCheckBoxState: Total item count is $totalItemCount for ListView '$($ListView.Name)'."
|
||||||
|
|
||||||
|
if ($totalItemCount -eq 0) {
|
||||||
|
# Handle empty list case specifically
|
||||||
|
$HeaderCheckBox.IsChecked = $false
|
||||||
|
}
|
||||||
|
elseif ($selectedCount -eq $totalItemCount) {
|
||||||
|
$HeaderCheckBox.IsChecked = $true
|
||||||
|
}
|
||||||
|
elseif ($selectedCount -eq 0) {
|
||||||
|
$HeaderCheckBox.IsChecked = $false
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Indeterminate state
|
||||||
|
$HeaderCheckBox.IsChecked = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to toggle the IsSelected state of the currently selected ListView item
|
||||||
|
function Invoke-ListViewItemToggle {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[System.Windows.Controls.ListView]$ListView,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[psobject]$State,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$HeaderCheckBoxKeyName
|
||||||
|
)
|
||||||
|
|
||||||
|
$selectedItem = $ListView.SelectedItem
|
||||||
|
if ($null -eq $selectedItem) { return }
|
||||||
|
|
||||||
|
# Store the current index to restore focus later
|
||||||
|
$currentIndex = $ListView.SelectedIndex
|
||||||
|
|
||||||
|
# Toggle the IsSelected property
|
||||||
|
$selectedItem.IsSelected = -not $selectedItem.IsSelected
|
||||||
|
$ListView.Items.Refresh()
|
||||||
|
|
||||||
|
# Update the 'Select All' header checkbox state
|
||||||
|
$headerChk = $State.Controls[$HeaderCheckBoxKeyName]
|
||||||
|
if ($null -ne $headerChk) {
|
||||||
|
Update-SelectAllHeaderCheckBoxState -ListView $ListView -HeaderCheckBox $headerChk
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restore selection and focus to the item that was just toggled
|
||||||
|
if ($currentIndex -ge 0 -and $ListView.Items.Count -gt $currentIndex) {
|
||||||
|
$ListView.SelectedIndex = $currentIndex
|
||||||
|
|
||||||
|
# Ensure the UI is updated before trying to find the container
|
||||||
|
$ListView.UpdateLayout()
|
||||||
|
|
||||||
|
$listViewItem = $ListView.ItemContainerGenerator.ContainerFromIndex($currentIndex)
|
||||||
|
if ($null -ne $listViewItem) {
|
||||||
|
$listViewItem.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to sort ListView items
|
||||||
|
function Invoke-ListViewSort {
|
||||||
|
param(
|
||||||
|
[System.Windows.Controls.ListView]$listView,
|
||||||
|
[string]$property,
|
||||||
|
[PSCustomObject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure $State.Flags is a hashtable and contains the required sort properties
|
||||||
|
if ($State.Flags -is [hashtable]) {
|
||||||
|
if (-not $State.Flags.ContainsKey('lastSortProperty')) {
|
||||||
|
$State.Flags['lastSortProperty'] = $null
|
||||||
|
}
|
||||||
|
if (-not $State.Flags.ContainsKey('lastSortAscending')) {
|
||||||
|
$State.Flags['lastSortAscending'] = $true # Default to ascending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Warning "Invoke-ListViewSort: \$State.Flags is not a hashtable or is null. Sort state may not work correctly."
|
||||||
|
# Attempt to initialize if $State.Flags is null or unexpectedly not a hashtable,
|
||||||
|
# though this might indicate a deeper issue with $State.Flags initialization.
|
||||||
|
if ($null -eq $State.Flags) { $State.Flags = @{} }
|
||||||
|
if ($State.Flags -is [hashtable]) { # Check again after potential initialization
|
||||||
|
if (-not $State.Flags.ContainsKey('lastSortProperty')) { $State.Flags['lastSortProperty'] = $null }
|
||||||
|
if (-not $State.Flags.ContainsKey('lastSortAscending')) { $State.Flags['lastSortAscending'] = $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Toggle sort direction if clicking the same column
|
||||||
|
if ($State.Flags.lastSortProperty -eq $property) {
|
||||||
|
$State.Flags.lastSortAscending = -not $State.Flags.lastSortAscending
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$State.Flags.lastSortAscending = $true
|
||||||
|
}
|
||||||
|
$State.Flags.lastSortProperty = $property
|
||||||
|
|
||||||
|
# Get items from ItemsSource or Items collection
|
||||||
|
$currentItemsSource = $listView.ItemsSource
|
||||||
|
$itemsToSort = @()
|
||||||
|
if ($null -ne $currentItemsSource) {
|
||||||
|
$itemsToSort = @($currentItemsSource)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$itemsToSort = @($listView.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($itemsToSort.Count -eq 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedItems = @($itemsToSort | Where-Object { $_.IsSelected })
|
||||||
|
$unselectedItems = @($itemsToSort | Where-Object { -not $_.IsSelected })
|
||||||
|
|
||||||
|
# Define the primary sort criterion
|
||||||
|
$primarySortDefinition = @{
|
||||||
|
Expression = {
|
||||||
|
$val = $_.$property
|
||||||
|
if ($null -eq $val) { '' } else { $val }
|
||||||
|
}
|
||||||
|
Ascending = $State.Flags.lastSortAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortCriteria = [System.Collections.Generic.List[hashtable]]::new()
|
||||||
|
$sortCriteria.Add($primarySortDefinition)
|
||||||
|
|
||||||
|
# Determine secondary sort property based on the ListView
|
||||||
|
$secondarySortPropertyName = $null
|
||||||
|
if ($listView.Name -eq 'lstDriverModels') {
|
||||||
|
$secondarySortPropertyName = "Model"
|
||||||
|
}
|
||||||
|
elseif ($listView.Name -eq 'lstWingetResults') {
|
||||||
|
$secondarySortPropertyName = "Name"
|
||||||
|
}
|
||||||
|
elseif ($listView.Name -eq 'lstAppsScriptVariables') {
|
||||||
|
if ($property -eq "Key") {
|
||||||
|
$secondarySortPropertyName = "Value"
|
||||||
|
}
|
||||||
|
elseif ($property -eq "Value") {
|
||||||
|
$secondarySortPropertyName = "Key"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Default secondary sort for IsSelected or other properties
|
||||||
|
$secondarySortPropertyName = "Key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $secondarySortPropertyName -and $property -ne $secondarySortPropertyName) {
|
||||||
|
$itemsHaveSecondaryProperty = $false
|
||||||
|
if ($unselectedItems.Count -gt 0) {
|
||||||
|
if ($null -ne $unselectedItems[0].PSObject.Properties[$secondarySortPropertyName]) {
|
||||||
|
$itemsHaveSecondaryProperty = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($selectedItems.Count -gt 0) {
|
||||||
|
if ($null -ne $selectedItems[0].PSObject.Properties[$secondarySortPropertyName]) {
|
||||||
|
$itemsHaveSecondaryProperty = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($itemsHaveSecondaryProperty) {
|
||||||
|
# Create a scriptblock for the secondary sort expression dynamically
|
||||||
|
$expressionScriptBlock = [scriptblock]::Create("`$_.$secondarySortPropertyName")
|
||||||
|
|
||||||
|
$secondarySortDefinition = @{
|
||||||
|
Expression = {
|
||||||
|
$val = Invoke-Command -ScriptBlock $expressionScriptBlock -ArgumentList $_
|
||||||
|
if ($null -eq $val) { '' } else { $val }
|
||||||
|
}
|
||||||
|
Ascending = $true # Secondary sort always ascending
|
||||||
|
}
|
||||||
|
$sortCriteria.Add($secondarySortDefinition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortedUnselected = $unselectedItems | Sort-Object -Property $sortCriteria.ToArray()
|
||||||
|
# Ensure $sortedUnselected is not null before attempting to add its range
|
||||||
|
if ($null -eq $sortedUnselected) {
|
||||||
|
$sortedUnselected = @()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Combine sorted items: selected items first, then sorted unselected items
|
||||||
|
$newSortedList = [System.Collections.Generic.List[object]]::new()
|
||||||
|
$newSortedList.AddRange($selectedItems)
|
||||||
|
$newSortedList.AddRange($sortedUnselected)
|
||||||
|
|
||||||
|
# Set the new sorted list as the ItemsSource
|
||||||
|
# Try nulling out ItemsSource first to force a more complete refresh
|
||||||
|
$listView.ItemsSource = $null
|
||||||
|
$listView.ItemsSource = $newSortedList.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# SECTION: Modern Folder Picker
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 1) Define a C# class that uses the correct GUIDs for IFileDialog, IFileOpenDialog, and FileOpenDialog,
|
||||||
|
# while omitting conflicting "GetResults/GetSelectedItems" from IFileDialog.
|
||||||
|
if (-not ("ModernFolderBrowser" -as [type])) {
|
||||||
|
$modernFolderBrowserCode = @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
public static class ModernFolderBrowser
|
||||||
|
{
|
||||||
|
// Flags for IFileDialog
|
||||||
|
[Flags]
|
||||||
|
private enum FileDialogOptions : uint
|
||||||
|
|
||||||
|
{
|
||||||
|
OverwritePrompt = 0x00000002,
|
||||||
|
StrictFileTypes = 0x00000004,
|
||||||
|
NoChangeDir = 0x00000008,
|
||||||
|
PickFolders = 0x00000020,
|
||||||
|
ForceFileSystem = 0x00000040,
|
||||||
|
AllNonStorageItems = 0x00000080,
|
||||||
|
NoValidate = 0x00000100,
|
||||||
|
AllowMultiSelect = 0x00000200,
|
||||||
|
PathMustExist = 0x00000800,
|
||||||
|
FileMustExist = 0x00001000,
|
||||||
|
CreatePrompt = 0x00002000,
|
||||||
|
ShareAware = 0x00004000,
|
||||||
|
NoReadOnlyReturn = 0x00008000,
|
||||||
|
NoTestFileCreate = 0x00010000,
|
||||||
|
DontAddToRecent = 0x02000000,
|
||||||
|
ForceShowHidden = 0x10000000
|
||||||
|
}
|
||||||
|
|
||||||
|
// IFileDialog (GUID from Windows SDK)
|
||||||
|
// - Omitting GetResults / GetSelectedItems to avoid overshadow.
|
||||||
|
[ComImport]
|
||||||
|
[Guid("42F85136-DB7E-439C-85F1-E4075D135FC8")]
|
||||||
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
private interface IFileDialog
|
||||||
|
{
|
||||||
|
[PreserveSig]
|
||||||
|
int Show(IntPtr parent);
|
||||||
|
|
||||||
|
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
|
||||||
|
void SetFileTypeIndex(uint iFileType);
|
||||||
|
void GetFileTypeIndex(out uint piFileType);
|
||||||
|
void Advise(IntPtr pfde, out uint pdwCookie);
|
||||||
|
void Unadvise(uint dwCookie);
|
||||||
|
void SetOptions(FileDialogOptions fos);
|
||||||
|
void GetOptions(out FileDialogOptions pfos);
|
||||||
|
void SetDefaultFolder(IShellItem psi);
|
||||||
|
void SetFolder(IShellItem psi);
|
||||||
|
void GetFolder(out IShellItem ppsi);
|
||||||
|
void GetCurrentSelection(out IShellItem ppsi);
|
||||||
|
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
||||||
|
void GetFileName(out IntPtr pszName);
|
||||||
|
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
|
||||||
|
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
|
||||||
|
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
|
||||||
|
void GetResult(out IShellItem ppsi);
|
||||||
|
void AddPlace(IShellItem psi, int fdap);
|
||||||
|
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
|
||||||
|
void Close(int hr);
|
||||||
|
void SetClientGuid(ref Guid guid);
|
||||||
|
void ClearClientData();
|
||||||
|
void SetFilter(IntPtr pFilter);
|
||||||
|
|
||||||
|
// NOTE: We intentionally do NOT define GetResults and GetSelectedItems here,
|
||||||
|
// because they cause overshadow warnings in IFileOpenDialog.
|
||||||
|
}
|
||||||
|
|
||||||
|
// IFileOpenDialog extends IFileDialog by adding 2 new methods with the same name,
|
||||||
|
// which otherwise cause overshadow warnings. We'll define them only here.
|
||||||
|
[ComImport]
|
||||||
|
[Guid("D57C7288-D4AD-4768-BE02-9D969532D960")]
|
||||||
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
private interface IFileOpenDialog : IFileDialog
|
||||||
|
{
|
||||||
|
// These two come after the parent's vtable:
|
||||||
|
void GetResults(out IntPtr ppenum);
|
||||||
|
void GetSelectedItems(out IntPtr ppsai);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The coclass for creating an IFileOpenDialog
|
||||||
|
[ComImport]
|
||||||
|
[Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
|
||||||
|
private class FileOpenDialog
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// IShellItem
|
||||||
|
[ComImport]
|
||||||
|
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
|
||||||
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
private interface IShellItem
|
||||||
|
{
|
||||||
|
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
||||||
|
void GetParent(out IShellItem ppsi);
|
||||||
|
void GetDisplayName(uint sigdnName, out IntPtr ppszName);
|
||||||
|
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
|
||||||
|
void Compare(IShellItem psi, uint hint, out int piOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern int SHCreateItemFromParsingName([MarshalAs(UnmanagedType.LPWStr)] string pszPath, IntPtr pbc, ref Guid riid, [MarshalAs(UnmanagedType.Interface, IidParameterIndex = 2)] out IShellItem ppv);
|
||||||
|
|
||||||
|
private const uint SIGDN_FILESYSPATH = 0x80058000;
|
||||||
|
private static readonly Guid IID_IShellItem = new Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE");
|
||||||
|
|
||||||
|
public static string ShowDialog(string title, IntPtr parentHandle, string initialDirectory)
|
||||||
|
{
|
||||||
|
// Create COM dialog instance
|
||||||
|
IFileOpenDialog dialog = (IFileOpenDialog)(new FileOpenDialog());
|
||||||
|
|
||||||
|
// Get current options
|
||||||
|
FileDialogOptions opts;
|
||||||
|
dialog.GetOptions(out opts);
|
||||||
|
|
||||||
|
// Add flags for picking folders
|
||||||
|
opts |= FileDialogOptions.PickFolders | FileDialogOptions.PathMustExist | FileDialogOptions.ForceFileSystem;
|
||||||
|
dialog.SetOptions(opts);
|
||||||
|
|
||||||
|
// Set initial directory if provided
|
||||||
|
if (!string.IsNullOrEmpty(initialDirectory))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Guid iid = IID_IShellItem; // Create a local copy to pass by ref
|
||||||
|
if (SHCreateItemFromParsingName(initialDirectory, IntPtr.Zero, ref iid, out IShellItem initialFolder) == 0)
|
||||||
|
{
|
||||||
|
dialog.SetFolder(initialFolder);
|
||||||
|
Marshal.ReleaseComObject(initialFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore errors in setting initial directory (e.g., path doesn't exist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set title
|
||||||
|
if (!string.IsNullOrEmpty(title))
|
||||||
|
{
|
||||||
|
dialog.SetTitle(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the dialog
|
||||||
|
int hr = dialog.Show(parentHandle);
|
||||||
|
// 0 = S_OK. 1 or 0x800704C7 often means user canceled. Return null if so.
|
||||||
|
if (hr != 0)
|
||||||
|
{
|
||||||
|
if ((uint)hr == 0x800704C7 || hr == 1)
|
||||||
|
{
|
||||||
|
return null; // Canceled
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Marshal.ThrowExceptionForHR(hr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the selection (IShellItem)
|
||||||
|
IShellItem shellItem;
|
||||||
|
dialog.GetResult(out shellItem);
|
||||||
|
if (shellItem == null) return null;
|
||||||
|
|
||||||
|
// Convert to file system path
|
||||||
|
IntPtr pszPath = IntPtr.Zero;
|
||||||
|
shellItem.GetDisplayName(SIGDN_FILESYSPATH, out pszPath);
|
||||||
|
if (pszPath == IntPtr.Zero) return null;
|
||||||
|
|
||||||
|
string folderPath = Marshal.PtrToStringAuto(pszPath);
|
||||||
|
Marshal.FreeCoTaskMem(pszPath);
|
||||||
|
|
||||||
|
return folderPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
Add-Type -TypeDefinition $modernFolderBrowserCode -Language CSharp
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2) Define a PowerShell function that invokes our C# wrapper
|
||||||
|
function Show-ModernFolderPicker {
|
||||||
|
param(
|
||||||
|
[string]$Title = "Select a folder",
|
||||||
|
[string]$InitialDirectory
|
||||||
|
)
|
||||||
|
# For a simple test, pass IntPtr.Zero as the parent window handle
|
||||||
|
return [ModernFolderBrowser]::ShowDialog($Title, [IntPtr]::Zero, $InitialDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-BrowseAction {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet('Folder', 'OpenFile', 'SaveFile')]
|
||||||
|
[string]$Type,
|
||||||
|
|
||||||
|
[string]$Title,
|
||||||
|
[string]$Filter,
|
||||||
|
[string]$InitialDirectory,
|
||||||
|
[string]$FileName,
|
||||||
|
[string]$DefaultExt,
|
||||||
|
[switch]$AllowNewFile
|
||||||
|
)
|
||||||
|
|
||||||
|
switch ($Type) {
|
||||||
|
'Folder' {
|
||||||
|
return Show-ModernFolderPicker -Title $Title -InitialDirectory $InitialDirectory
|
||||||
|
}
|
||||||
|
'OpenFile' {
|
||||||
|
$dialog = New-Object Microsoft.Win32.OpenFileDialog
|
||||||
|
$dialog.Title = $Title
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($Filter)) { $dialog.Filter = $Filter }
|
||||||
|
if ($AllowNewFile) { $dialog.CheckFileExists = $false }
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
|
||||||
|
$dialog.InitialDirectory = $InitialDirectory
|
||||||
|
}
|
||||||
|
if ($dialog.ShowDialog()) {
|
||||||
|
return $dialog.FileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'SaveFile' {
|
||||||
|
$dialog = New-Object Microsoft.Win32.SaveFileDialog
|
||||||
|
$dialog.Title = $Title
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($Filter)) { $dialog.Filter = $Filter }
|
||||||
|
if ($AllowNewFile) { $dialog.CheckFileExists = $false } # This property is obsolete but used in existing code.
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
|
||||||
|
$dialog.InitialDirectory = $InitialDirectory
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($FileName)) {
|
||||||
|
$dialog.FileName = $FileName
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($DefaultExt)) {
|
||||||
|
$dialog.DefaultExt = $DefaultExt
|
||||||
|
}
|
||||||
|
if ($dialog.ShowDialog()) {
|
||||||
|
return $dialog.FileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Clear-ListViewContent {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[System.Windows.Controls.ListView]$ListViewControl,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ConfirmationTitle,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ConfirmationMessage,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[System.Collections.IList]$BackingDataList,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$StatusMessage,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[System.Windows.Controls.TextBox[]]$TextBoxesToClear,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[scriptblock]$PostClearAction
|
||||||
|
)
|
||||||
|
|
||||||
|
$result = [System.Windows.MessageBox]::Show($ConfirmationMessage, $ConfirmationTitle, [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
|
||||||
|
if ($result -ne [System.Windows.MessageBoxResult]::Yes) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
# If a backing data list is provided, clear it and rebind. This is the preferred method.
|
||||||
|
if ($null -ne $BackingDataList) {
|
||||||
|
$BackingDataList.Clear()
|
||||||
|
$ListViewControl.ItemsSource = $BackingDataList.ToArray()
|
||||||
|
}
|
||||||
|
# If no backing list, determine how to clear the control.
|
||||||
|
else {
|
||||||
|
# If ItemsSource is in use, the only valid way to clear is to set it to null or an empty collection.
|
||||||
|
if ($null -ne $ListViewControl.ItemsSource) {
|
||||||
|
$ListViewControl.ItemsSource = $null
|
||||||
|
}
|
||||||
|
# If ItemsSource is NOT in use, we can safely clear the Items collection directly (for BYO Apps).
|
||||||
|
elseif ($null -ne $ListViewControl.Items) {
|
||||||
|
$ListViewControl.Items.Clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ListViewControl.Items.Refresh()
|
||||||
|
|
||||||
|
# Clear any specified textboxes
|
||||||
|
if ($null -ne $TextBoxesToClear) {
|
||||||
|
foreach ($textBox in $TextBoxesToClear) {
|
||||||
|
$textBox.Clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update the status message if provided
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($StatusMessage) -and $null -ne $State.Controls.txtStatus) {
|
||||||
|
$State.Controls.txtStatus.Text = $StatusMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute any post-clear custom actions. The scriptblock will have access to the $State and $ListViewControl variables from this function's scope.
|
||||||
|
if ($null -ne $PostClearAction) {
|
||||||
|
& $PostClearAction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error in Clear-ListViewContent for $($ListViewControl.Name): $($_.Exception.Message)"
|
||||||
|
[System.Windows.MessageBox]::Show("An error occurred while clearing the list: $($_.Exception.Message)", "Error", "OK", "Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function *
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Manages the business logic for the 'Windows Settings' tab in the FFU Builder UI.
|
||||||
|
.DESCRIPTION
|
||||||
|
This module contains all the functions and data required to populate and manage the controls on the 'Windows Settings' tab. It handles dynamic updates for Windows Release, Version, SKU, and Architecture ComboBoxes based on user input, such as specifying an ISO path. It also includes logic for populating available languages, media types, and the grid of optional Windows features.
|
||||||
|
#>
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# SECTION: Module Variables (Static Data)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$script:allowedFeatures = @(
|
||||||
|
"AppServerClient", "Client-DeviceLockdown", "Client-EmbeddedBootExp", "Client-EmbeddedLogon",
|
||||||
|
"Client-EmbeddedShellLauncher", "Client-KeyboardFilter", "Client-ProjFS", "Client-UnifiedWriteFilter",
|
||||||
|
"Containers", "Containers-DisposableClientVM", "Containers-HNS", "Containers-SDN", "DataCenterBridging",
|
||||||
|
"DirectoryServices-ADAM-Client", "DirectPlay", "HostGuardian", "HypervisorPlatform", "IIS-ApplicationDevelopment",
|
||||||
|
"IIS-ApplicationInit", "IIS-ASP", "IIS-ASPNET45", "IIS-BasicAuthentication", "IIS-CertProvider",
|
||||||
|
"IIS-CGI", "IIS-ClientCertificateMappingAuthentication", "IIS-CommonHttpFeatures", "IIS-CustomLogging",
|
||||||
|
"IIS-DefaultDocument", "IIS-DirectoryBrowsing", "IIS-DigestAuthentication", "IIS-ESP", "IIS-FTPServer",
|
||||||
|
"IIS-FTPExtensibility", "IIS-FTPSvc", "IIS-HealthAndDiagnostics", "IIS-HostableWebCore", "IIS-HttpCompressionDynamic",
|
||||||
|
"IIS-HttpCompressionStatic", "IIS-HttpErrors", "IIS-HttpLogging", "IIS-HttpRedirect", "IIS-HttpTracing",
|
||||||
|
"IIS-IPSecurity", "IIS-IIS6ManagementCompatibility", "IIS-IISCertificateMappingAuthentication",
|
||||||
|
"IIS-ISAPIExtensions", "IIS-ISAPIFilter", "IIS-LoggingLibraries", "IIS-ManagementConsole", "IIS-ManagementService",
|
||||||
|
"IIS-ManagementScriptingTools", "IIS-Metabase", "IIS-NetFxExtensibility", "IIS-NetFxExtensibility45",
|
||||||
|
"IIS-ODBCLogging", "IIS-Performance", "IIS-RequestFiltering", "IIS-RequestMonitor", "IIS-Security", "IIS-ServerSideIncludes",
|
||||||
|
"IIS-StaticContent", "IIS-URLAuthorization", "IIS-WebDAV", "IIS-WebServer", "IIS-WebServerManagementTools",
|
||||||
|
"IIS-WebServerRole", "IIS-WebSockets", "LegacyComponents", "MediaPlayback", "Microsoft-Hyper-V", "Microsoft-Hyper-V-All",
|
||||||
|
"Microsoft-Hyper-V-Hypervisor", "Microsoft-Hyper-V-Management-Clients", "Microsoft-Hyper-V-Management-PowerShell",
|
||||||
|
"Microsoft-Hyper-V-Services", "Microsoft-Windows-Subsystem-Linux", "MSMQ-ADIntegration", "MSMQ-Container", "MSMQ-DCOMProxy",
|
||||||
|
"MSMQ-HTTP", "MSMQ-Multicast", "MSMQ-Server", "MSMQ-Triggers", "MultiPoint-Connector", "MultiPoint-Connector-Services",
|
||||||
|
"MultiPoint-Tools", "NetFx3", "NetFx4-AdvSrvs", "NetFx4Extended-ASPNET45", "NFS-Administration", "Printing-Foundation-Features",
|
||||||
|
"Printing-Foundation-InternetPrinting-Client", "Printing-Foundation-LPDPrintService", "Printing-Foundation-LPRPortMonitor",
|
||||||
|
"Printing-PrintToPDFServices-Features", "Printing-XPSServices-Features", "SearchEngine-Client-Package",
|
||||||
|
"ServicesForNFS-ClientOnly", "SimpleTCP", "SMB1Protocol", "SMB1Protocol-Client", "SMB1Protocol-Deprecation",
|
||||||
|
"SMB1Protocol-Server", "SmbDirect", "TFTP", "TelnetClient", "TIFFIFilter", "VirtualMachinePlatform", "WAS-ConfigurationAPI",
|
||||||
|
"WAS-NetFxEnvironment", "WAS-ProcessModel", "WAS-WindowsActivationService", "WCF-HTTP-Activation", "WCF-HTTP-Activation45",
|
||||||
|
"WCF-MSMQ-Activation45", "WCF-MSMQ-Activation", "WCF-NonHTTP-Activation", "WCF-Pipe-Activation45", "WCF-Services45",
|
||||||
|
"WCF-TCP-Activation45", "WCF-TCP-PortSharing45", "Windows-Defender-ApplicationGuard",
|
||||||
|
"Windows-Defender-Default-Definitions", "Windows-Identity-Foundation", "WindowsMediaPlayer", "WorkFolders-Client"
|
||||||
|
)
|
||||||
|
|
||||||
|
$script:skuList = @(
|
||||||
|
'Home',
|
||||||
|
'Home N',
|
||||||
|
'Home Single Language',
|
||||||
|
'Education',
|
||||||
|
'Education N',
|
||||||
|
'Pro',
|
||||||
|
'Pro N',
|
||||||
|
'Pro Education',
|
||||||
|
'Pro Education N',
|
||||||
|
'Pro for Workstations',
|
||||||
|
'Pro N for Workstations',
|
||||||
|
'Enterprise',
|
||||||
|
'Enterprise N',
|
||||||
|
'Enterprise 2016 LTSB',
|
||||||
|
'Enterprise N 2016 LTSB',
|
||||||
|
'Enterprise LTSC',
|
||||||
|
'Enterprise N LTSC',
|
||||||
|
'IoT Enterprise LTSC',
|
||||||
|
'IoT Enterprise N LTSC',
|
||||||
|
'Standard',
|
||||||
|
'Standard (Desktop Experience)',
|
||||||
|
'Datacenter',
|
||||||
|
'Datacenter (Desktop Experience)'
|
||||||
|
)
|
||||||
|
|
||||||
|
$script:allowedLangs = @(
|
||||||
|
'ar-sa',
|
||||||
|
'bg-bg',
|
||||||
|
'cs-cz',
|
||||||
|
'da-dk',
|
||||||
|
'de-de',
|
||||||
|
'el-gr',
|
||||||
|
'en-gb',
|
||||||
|
'en-us',
|
||||||
|
'es-es',
|
||||||
|
'es-mx',
|
||||||
|
'et-ee',
|
||||||
|
'fi-fi',
|
||||||
|
'fr-ca',
|
||||||
|
'fr-fr',
|
||||||
|
'he-il',
|
||||||
|
'hr-hr',
|
||||||
|
'hu-hu',
|
||||||
|
'it-it',
|
||||||
|
'ja-jp',
|
||||||
|
'ko-kr',
|
||||||
|
'lt-lt',
|
||||||
|
'lv-lv',
|
||||||
|
'nb-no',
|
||||||
|
'nl-nl',
|
||||||
|
'pl-pl',
|
||||||
|
'pt-br',
|
||||||
|
'pt-pt',
|
||||||
|
'ro-ro',
|
||||||
|
'ru-ru',
|
||||||
|
'sk-sk',
|
||||||
|
'sl-si',
|
||||||
|
'sr-latn-rs',
|
||||||
|
'sv-se',
|
||||||
|
'th-th',
|
||||||
|
'tr-tr',
|
||||||
|
'uk-ua',
|
||||||
|
'zh-cn',
|
||||||
|
'zh-tw'
|
||||||
|
)
|
||||||
|
|
||||||
|
$script:allWindowsReleases = @(
|
||||||
|
[PSCustomObject]@{ Display = "Windows 10"; Value = 10 },
|
||||||
|
[PSCustomObject]@{ Display = "Windows 11"; Value = 11 },
|
||||||
|
[PSCustomObject]@{ Display = "Windows Server 2016"; Value = 2016 },
|
||||||
|
[PSCustomObject]@{ Display = "Windows Server 2019"; Value = 2019 },
|
||||||
|
[PSCustomObject]@{ Display = "Windows Server 2022"; Value = 2022 },
|
||||||
|
[PSCustomObject]@{ Display = "Windows Server 2025"; Value = 2025 },
|
||||||
|
[PSCustomObject]@{ Display = "Windows 10 LTSB 2016"; Value = 2016 }, # Changed Value from 1607
|
||||||
|
[PSCustomObject]@{ Display = "Windows 10 LTSC 2019"; Value = 2019 }, # Changed Value from 1809
|
||||||
|
[PSCustomObject]@{ Display = "Windows 10 LTSC 2021"; Value = 2021 },
|
||||||
|
[PSCustomObject]@{ Display = "Windows 11 LTSC 2024"; Value = 2024 }
|
||||||
|
)
|
||||||
|
|
||||||
|
$script:mctWindowsReleases = @(
|
||||||
|
[PSCustomObject]@{ Display = "Windows 10"; Value = 10 },
|
||||||
|
[PSCustomObject]@{ Display = "Windows 11"; Value = 11 }
|
||||||
|
)
|
||||||
|
|
||||||
|
$script:windowsVersionMap = @{
|
||||||
|
10 = @("22H2")
|
||||||
|
11 = @("22H2", "23H2", "24H2")
|
||||||
|
2016 = @("1607") # Windows 10 LTSB 2016 & Server 2016
|
||||||
|
2019 = @("1809") # Windows 10 LTSC 2019 & Server 2019
|
||||||
|
# Note: Server 2016 and LTSB 2016 now share the key 2016, mapping to version "1607"
|
||||||
|
# Note: Server 2019 and LTSC 2019 now share the key 2019, mapping to version "1809"
|
||||||
|
2021 = @("21H2") # LTSC 2021
|
||||||
|
2022 = @("21H2") # Server 2022
|
||||||
|
2024 = @("24H2") # LTSC 2024
|
||||||
|
2025 = @("24H2") # Server 2025
|
||||||
|
}
|
||||||
|
|
||||||
|
# SKU Groups
|
||||||
|
$script:clientSKUs = @(
|
||||||
|
'Home',
|
||||||
|
'Home N',
|
||||||
|
'Home Single Language',
|
||||||
|
'Education',
|
||||||
|
'Education N',
|
||||||
|
'Pro',
|
||||||
|
'Pro N',
|
||||||
|
'Pro Education',
|
||||||
|
'Pro Education N',
|
||||||
|
'Pro for Workstations',
|
||||||
|
'Pro N for Workstations',
|
||||||
|
'Enterprise',
|
||||||
|
'Enterprise N'
|
||||||
|
)
|
||||||
|
|
||||||
|
$script:serverSKUs = @(
|
||||||
|
'Standard',
|
||||||
|
'Standard (Desktop Experience)',
|
||||||
|
'Datacenter',
|
||||||
|
'Datacenter (Desktop Experience)'
|
||||||
|
)
|
||||||
|
|
||||||
|
$script:ltsc2016SKUs = @(
|
||||||
|
'Enterprise 2016 LTSB',
|
||||||
|
'Enterprise N 2016 LTSB'
|
||||||
|
)
|
||||||
|
|
||||||
|
$script:ltscGenericSKUs = @( # For LTSC 2019, 2021, 2024
|
||||||
|
'Enterprise LTSC',
|
||||||
|
'Enterprise N LTSC'
|
||||||
|
)
|
||||||
|
|
||||||
|
$script:iotLtscSKUs = @(
|
||||||
|
'IoT Enterprise LTSC',
|
||||||
|
'IoT Enterprise N LTSC'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map Windows Release Values to their corresponding SKU lists
|
||||||
|
$script:windowsReleaseSkuMap = @{
|
||||||
|
10 = $script:clientSKUs # Windows 10 Client
|
||||||
|
11 = $script:clientSKUs # Windows 11 Client
|
||||||
|
2016 = $script:serverSKUs # Windows Server 2016 (LTSB 2016 handled by Get-AvailableSkusForRelease)
|
||||||
|
2019 = $script:serverSKUs # Windows Server 2019 (LTSC 2019 handled by Get-AvailableSkusForRelease)
|
||||||
|
2022 = $script:serverSKUs # Windows Server 2022
|
||||||
|
2025 = $script:serverSKUs # Windows Server 2025
|
||||||
|
2021 = $script:ltscGenericSKUs + $script:iotLtscSKUs # Windows 10 LTSC 2021
|
||||||
|
2024 = $script:ltscGenericSKUs + $script:iotLtscSKUs # Windows 10 LTSC 2024
|
||||||
|
# Note: LTSC 2016 and LTSC 2019 SKUs are now conditionally returned by Get-AvailableSkusForRelease
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# SECTION: Functions
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Function to return the default settings and static lists
|
||||||
|
function Get-WindowsSettingsDefaults {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
DefaultISOPath = ""
|
||||||
|
DefaultWindowsArch = "x64"
|
||||||
|
DefaultWindowsLang = "en-us"
|
||||||
|
DefaultWindowsSKU = "Pro"
|
||||||
|
DefaultMediaType = "Consumer"
|
||||||
|
DefaultOptionalFeatures = ""
|
||||||
|
DefaultProductKey = ""
|
||||||
|
AllowedFeatures = $script:allowedFeatures
|
||||||
|
AllowedLanguages = $script:allowedLangs
|
||||||
|
AllowedArchitectures = @('x86', 'x64', 'arm64')
|
||||||
|
AllowedMediaTypes = @('Consumer', 'Business')
|
||||||
|
SkuList = $script:skuList
|
||||||
|
AllWindowsReleases = $script:allWindowsReleases
|
||||||
|
MctWindowsReleases = $script:mctWindowsReleases
|
||||||
|
WindowsVersionMap = $script:windowsVersionMap
|
||||||
|
ClientSKUs = $script:clientSKUs
|
||||||
|
ServerSKUs = $script:serverSKUs
|
||||||
|
Ltsc2016SKUs = $script:ltsc2016SKUs
|
||||||
|
LtscGenericSKUs = $script:ltscGenericSKUs
|
||||||
|
IotLtscSKUs = $script:iotLtscSKUs
|
||||||
|
WindowsReleaseSkuMap = $script:windowsReleaseSkuMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get the appropriate list of Windows Releases based on ISO path
|
||||||
|
function Get-AvailableWindowsReleases {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$IsoPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrEmpty($IsoPath) -and $IsoPath.EndsWith('.iso', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
return $State.Defaults.WindowsSettingsDefaults.AllWindowsReleases
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return $State.Defaults.WindowsSettingsDefaults.MctWindowsReleases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get available Windows Versions for a given release and ISO path
|
||||||
|
function Get-AvailableWindowsVersions {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[int]$SelectedRelease,
|
||||||
|
|
||||||
|
[string]$IsoPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
$result = [PSCustomObject]@{
|
||||||
|
Versions = @()
|
||||||
|
DefaultVersion = $null
|
||||||
|
IsEnabled = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $State.Defaults.WindowsSettingsDefaults.WindowsVersionMap.ContainsKey($SelectedRelease)) {
|
||||||
|
return $result
|
||||||
|
}
|
||||||
|
|
||||||
|
$validVersions = $State.Defaults.WindowsSettingsDefaults.WindowsVersionMap[$SelectedRelease]
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrEmpty($IsoPath) -and $IsoPath.EndsWith('.iso', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
# Logic for when an ISO is specified
|
||||||
|
$result.Versions = $validVersions
|
||||||
|
# Set default selection logic (e.g., latest for Win11)
|
||||||
|
if ($SelectedRelease -eq 11 -and $validVersions -contains "24H2") {
|
||||||
|
$result.DefaultVersion = "24H2"
|
||||||
|
}
|
||||||
|
elseif ($validVersions.Count -gt 0) {
|
||||||
|
$result.DefaultVersion = $validVersions[0]
|
||||||
|
}
|
||||||
|
$result.IsEnabled = $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Logic for when no ISO is specified (MCT scenario)
|
||||||
|
switch ($SelectedRelease) {
|
||||||
|
10 { $result.DefaultVersion = "22H2" }
|
||||||
|
11 { $result.DefaultVersion = "24H2" }
|
||||||
|
# Server versions typically require an ISO, but handle just in case
|
||||||
|
2016 { $result.DefaultVersion = "1607" }
|
||||||
|
2019 { $result.DefaultVersion = "1809" }
|
||||||
|
2022 { $result.DefaultVersion = "21H2" }
|
||||||
|
2025 { $result.DefaultVersion = "24H2" }
|
||||||
|
default {
|
||||||
|
if ($validVersions.Count -gt 0) { $result.DefaultVersion = $validVersions[0] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$result.Versions = @($result.DefaultVersion) # Only the default is available/relevant
|
||||||
|
$result.IsEnabled = $false # Combo should be disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get available SKUs for a given Windows Release value and display name
|
||||||
|
function Get-AvailableSkusForRelease {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[int]$SelectedReleaseValue,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$SelectedReleaseDisplayName,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
WriteLog "Get-AvailableSkusForRelease: Getting SKUs for Release Value '$SelectedReleaseValue', Display Name '$SelectedReleaseDisplayName'."
|
||||||
|
|
||||||
|
# Handle LTSC 2016 specifically
|
||||||
|
if ($SelectedReleaseValue -eq 2016 -and $SelectedReleaseDisplayName -like '*LTSB*') {
|
||||||
|
WriteLog "Get-AvailableSkusForRelease: Matched LTSB 2016. Returning LTSC 2016 SKUs."
|
||||||
|
return $State.Defaults.WindowsSettingsDefaults.Ltsc2016SKUs
|
||||||
|
}
|
||||||
|
# Handle LTSC 2019 specifically
|
||||||
|
# Ensure "Server" is not in the display name to avoid matching "Windows Server 2019"
|
||||||
|
elseif ($SelectedReleaseValue -eq 2019 -and $SelectedReleaseDisplayName -like '*LTSC*' -and $SelectedReleaseDisplayName -notlike '*Server*') {
|
||||||
|
WriteLog "Get-AvailableSkusForRelease: Matched LTSC 2019. Returning generic LTSC SKUs (including IoT)."
|
||||||
|
# Assuming LTSC 2019 uses the generic LTSC SKUs + IoT LTSC SKUs
|
||||||
|
return ($State.Defaults.WindowsSettingsDefaults.LtscGenericSKUs + $State.Defaults.WindowsSettingsDefaults.IotLtscSKUs | Select-Object -Unique)
|
||||||
|
}
|
||||||
|
# For all other cases, use the main SKU map
|
||||||
|
elseif ($State.Defaults.WindowsSettingsDefaults.WindowsReleaseSkuMap.ContainsKey($SelectedReleaseValue)) {
|
||||||
|
$availableSkus = $State.Defaults.WindowsSettingsDefaults.WindowsReleaseSkuMap[$SelectedReleaseValue]
|
||||||
|
WriteLog "Get-AvailableSkusForRelease: Found $($availableSkus.Count) SKUs for Release '$SelectedReleaseValue' using standard map."
|
||||||
|
return $availableSkus
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Get-AvailableSkusForRelease: Warning - Release Value '$SelectedReleaseValue' not found in SKU map. Returning default client SKUs."
|
||||||
|
# Fallback to a default list (e.g., client SKUs) or an empty list
|
||||||
|
return $State.Defaults.WindowsSettingsDefaults.ClientSKUs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to refresh the Windows Release ComboBox based on ISO path
|
||||||
|
function Update-WindowsReleaseCombo {
|
||||||
|
param(
|
||||||
|
[string]$isoPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not $State.Controls.cmbWindowsRelease) { return }
|
||||||
|
|
||||||
|
$oldSelectedItemValue = $null
|
||||||
|
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
|
||||||
|
$oldSelectedItemValue = $State.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the appropriate list of releases from the helper module
|
||||||
|
$availableReleases = Get-AvailableWindowsReleases -IsoPath $isoPath -State $State
|
||||||
|
|
||||||
|
# Update the ComboBox ItemsSource
|
||||||
|
$State.Controls.cmbWindowsRelease.ItemsSource = $availableReleases
|
||||||
|
$State.Controls.cmbWindowsRelease.DisplayMemberPath = 'Display'
|
||||||
|
$State.Controls.cmbWindowsRelease.SelectedValuePath = 'Value'
|
||||||
|
|
||||||
|
# Try to re-select the previously selected item, or default
|
||||||
|
$itemToSelect = $availableReleases | Where-Object { $_.Value -eq $oldSelectedItemValue } | Select-Object -First 1
|
||||||
|
if ($null -ne $itemToSelect) {
|
||||||
|
$State.Controls.cmbWindowsRelease.SelectedItem = $itemToSelect
|
||||||
|
}
|
||||||
|
elseif ($availableReleases.Count -gt 0) {
|
||||||
|
# Default to Windows 11 if available, otherwise the first item
|
||||||
|
$defaultItem = $availableReleases | Where-Object { $_.Value -eq 11 } | Select-Object -First 1
|
||||||
|
if ($null -eq $defaultItem) {
|
||||||
|
$defaultItem = $availableReleases[0]
|
||||||
|
}
|
||||||
|
$State.Controls.cmbWindowsRelease.SelectedItem = $defaultItem
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# No items available (should not happen with current logic)
|
||||||
|
$State.Controls.cmbWindowsRelease.SelectedIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to refresh the Windows Version ComboBox based on selected release and ISO path
|
||||||
|
function Update-WindowsVersionCombo {
|
||||||
|
param(
|
||||||
|
[int]$selectedRelease,
|
||||||
|
[string]$isoPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
$combo = $State.Controls.cmbWindowsVersion
|
||||||
|
if (-not $combo) { return }
|
||||||
|
|
||||||
|
# Get available versions and default from the helper module
|
||||||
|
$versionData = Get-AvailableWindowsVersions -SelectedRelease $selectedRelease -IsoPath $isoPath -State $State
|
||||||
|
|
||||||
|
# Update the ComboBox ItemsSource and IsEnabled state
|
||||||
|
$combo.ItemsSource = $versionData.Versions
|
||||||
|
$combo.IsEnabled = $versionData.IsEnabled
|
||||||
|
|
||||||
|
# Set the selected item
|
||||||
|
if ($null -ne $versionData.DefaultVersion -and $versionData.Versions -contains $versionData.DefaultVersion) {
|
||||||
|
$combo.SelectedItem = $versionData.DefaultVersion
|
||||||
|
}
|
||||||
|
elseif ($versionData.Versions.Count -gt 0) {
|
||||||
|
$combo.SelectedIndex = 0
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$combo.SelectedIndex = -1 # No items available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to refresh the Windows SKU ComboBox based on selected release
|
||||||
|
function Update-WindowsSkuCombo {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
$skuCombo = $State.Controls.cmbWindowsSKU
|
||||||
|
if (-not $skuCombo) {
|
||||||
|
WriteLog "Update-WindowsSkuCombo: SKU ComboBox not found."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseCombo = $State.Controls.cmbWindowsRelease
|
||||||
|
if (-not $releaseCombo -or $null -eq $releaseCombo.SelectedItem) {
|
||||||
|
WriteLog "Update-WindowsSkuCombo: Windows Release ComboBox not found or no item selected. Cannot update SKUs."
|
||||||
|
$skuCombo.ItemsSource = @() # Clear SKUs
|
||||||
|
$skuCombo.SelectedIndex = -1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedReleaseItem = $releaseCombo.SelectedItem
|
||||||
|
$selectedReleaseValue = $selectedReleaseItem.Value
|
||||||
|
$selectedReleaseDisplayName = $selectedReleaseItem.Display
|
||||||
|
|
||||||
|
$previousSelectedSku = $null
|
||||||
|
if ($null -ne $skuCombo.SelectedItem) {
|
||||||
|
$previousSelectedSku = $skuCombo.SelectedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog "Update-WindowsSkuCombo: Updating SKUs for Release Value '$selectedReleaseValue' (Display: '$selectedReleaseDisplayName')."
|
||||||
|
# Call Get-AvailableSkusForRelease with both Value and DisplayName
|
||||||
|
$availableSkus = Get-AvailableSkusForRelease -SelectedReleaseValue $selectedReleaseValue -SelectedReleaseDisplayName $selectedReleaseDisplayName -State $State
|
||||||
|
|
||||||
|
$skuCombo.ItemsSource = $availableSkus
|
||||||
|
WriteLog "Update-WindowsSkuCombo: Set ItemsSource with $($availableSkus.Count) SKUs."
|
||||||
|
|
||||||
|
# Attempt to re-select the previous SKU, or "Pro", or the first available
|
||||||
|
if ($null -ne $previousSelectedSku -and $availableSkus -contains $previousSelectedSku) {
|
||||||
|
$skuCombo.SelectedItem = $previousSelectedSku
|
||||||
|
WriteLog "Update-WindowsSkuCombo: Re-selected previous SKU '$previousSelectedSku'."
|
||||||
|
}
|
||||||
|
elseif ($availableSkus -contains "Pro") {
|
||||||
|
$skuCombo.SelectedItem = "Pro"
|
||||||
|
WriteLog "Update-WindowsSkuCombo: Selected default SKU 'Pro'."
|
||||||
|
}
|
||||||
|
elseif ($availableSkus.Count -gt 0) {
|
||||||
|
$skuCombo.SelectedIndex = 0
|
||||||
|
WriteLog "Update-WindowsSkuCombo: Selected first available SKU '$($skuCombo.SelectedItem)'."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$skuCombo.SelectedIndex = -1 # No SKUs available
|
||||||
|
WriteLog "Update-WindowsSkuCombo: No SKUs available for Release '$selectedReleaseValue' (Display: '$selectedReleaseDisplayName')."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to refresh the Windows Architecture ComboBox based on selected release, version, and SKU
|
||||||
|
function Update-WindowsArchCombo {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
$archCombo = $State.Controls.cmbWindowsArch
|
||||||
|
if (-not $archCombo) {
|
||||||
|
WriteLog "Update-WindowsArchCombo: Architecture ComboBox not found."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousSelectedArch = $archCombo.SelectedItem
|
||||||
|
|
||||||
|
# Start with a safe, common default
|
||||||
|
$availableArchitectures = @('x64')
|
||||||
|
|
||||||
|
$releaseItem = $State.Controls.cmbWindowsRelease.SelectedItem
|
||||||
|
$versionItem = $State.Controls.cmbWindowsVersion.SelectedItem
|
||||||
|
$skuItem = $State.Controls.cmbWindowsSKU.SelectedItem
|
||||||
|
|
||||||
|
if ($null -eq $releaseItem) {
|
||||||
|
WriteLog "Update-WindowsArchCombo: No release selected. Defaulting to x64."
|
||||||
|
$archCombo.ItemsSource = $availableArchitectures
|
||||||
|
$archCombo.SelectedItem = 'x64'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseDisplay = $releaseItem.Display
|
||||||
|
$versionValue = if ($null -ne $versionItem) { $versionItem } else { "" }
|
||||||
|
$skuValue = if ($null -ne $skuItem) { $skuItem } else { "" }
|
||||||
|
|
||||||
|
if ($releaseDisplay -like 'Windows Server*') {
|
||||||
|
# All servers are x64 only
|
||||||
|
$availableArchitectures = @('x64')
|
||||||
|
}
|
||||||
|
elseif ($releaseDisplay -like 'Windows 11*') {
|
||||||
|
if ($releaseDisplay -like '*LTSC*') {
|
||||||
|
# Windows 11 LTSC 2024
|
||||||
|
if ($skuValue -like 'IoT*') {
|
||||||
|
$availableArchitectures = @('x64', 'arm64')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$availableArchitectures = @('x64')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Standard Windows 11
|
||||||
|
if ($versionValue -eq '24H2') {
|
||||||
|
$availableArchitectures = @('x64', 'arm64')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# 22H2, 23H2
|
||||||
|
$availableArchitectures = @('x64')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($releaseDisplay -like 'Windows 10*') {
|
||||||
|
if ($releaseDisplay -like '*LTSB*' -or $releaseDisplay -like '*LTSC*') {
|
||||||
|
# Windows 10 LTSB 2016, LTSC 2019, LTSC 2021
|
||||||
|
if ($releaseDisplay -like '*2021*' -and $skuValue -like 'IoT*') {
|
||||||
|
$availableArchitectures = @('x64', 'arm64')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# LTSB 2016, LTSC 2019, LTSC 2021 (non-IoT)
|
||||||
|
$availableArchitectures = @('x86', 'x64')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Standard Windows 10 (22H2)
|
||||||
|
$availableArchitectures = @('x86', 'x64')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$archCombo.ItemsSource = $availableArchitectures
|
||||||
|
|
||||||
|
if ($availableArchitectures -contains $previousSelectedArch) {
|
||||||
|
$archCombo.SelectedItem = $previousSelectedArch
|
||||||
|
}
|
||||||
|
elseif ($availableArchitectures -contains 'x64') {
|
||||||
|
$archCombo.SelectedItem = 'x64'
|
||||||
|
}
|
||||||
|
elseif ($availableArchitectures.Count -gt 0) {
|
||||||
|
$archCombo.SelectedIndex = 0
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$archCombo.SelectedIndex = -1
|
||||||
|
}
|
||||||
|
WriteLog "Update-WindowsArchCombo: Updated available architectures to ($($availableArchitectures -join ', ')). Selected: $($archCombo.SelectedItem)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Combined function to initialize the Release, Version, and SKU combos
|
||||||
|
function Get-WindowsSettingsCombos {
|
||||||
|
param(
|
||||||
|
[string]$isoPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine visibility for download-specific controls based on ISO path
|
||||||
|
$visibility = if (-not [string]::IsNullOrEmpty($isoPath) -and $isoPath.EndsWith('.iso', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
'Collapsed'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
'Visible'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set visibility for Language and Media Type controls
|
||||||
|
$State.Controls.WindowsLangStackPanel.Visibility = $visibility
|
||||||
|
$State.Controls.cmbWindowsLang.Visibility = $visibility
|
||||||
|
$State.Controls.MediaTypeStackPanel.Visibility = $visibility
|
||||||
|
$State.Controls.cmbMediaType.Visibility = $visibility
|
||||||
|
|
||||||
|
# Update Release combo first
|
||||||
|
Update-WindowsReleaseCombo -isoPath $isoPath -State $State
|
||||||
|
|
||||||
|
# Get the newly selected release value
|
||||||
|
$selectedReleaseValue = 11 # Default to 11 if selection is null
|
||||||
|
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
|
||||||
|
$selectedReleaseValue = $State.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update Version combo based on the selected release
|
||||||
|
Update-WindowsVersionCombo -selectedRelease $selectedReleaseValue -isoPath $isoPath -State $State
|
||||||
|
|
||||||
|
# Update SKU combo based on the selected release (now derives values internally)
|
||||||
|
Update-WindowsSkuCombo -State $State
|
||||||
|
|
||||||
|
# Finally, update the Architecture combo to match the initial state
|
||||||
|
Update-WindowsArchCombo -State $State
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dynamic checkboxes for optional features in Windows Settings tab
|
||||||
|
function UpdateOptionalFeaturesString {
|
||||||
|
param(
|
||||||
|
[psobject]$State
|
||||||
|
)
|
||||||
|
$checkedFeatures = @()
|
||||||
|
foreach ($entry in $State.Controls.featureCheckBoxes.GetEnumerator()) {
|
||||||
|
if ($entry.Value.IsChecked) { $checkedFeatures += $entry.Key }
|
||||||
|
}
|
||||||
|
$State.Controls.txtOptionalFeatures.Text = $checkedFeatures -join ";"
|
||||||
|
}
|
||||||
|
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,804 @@
|
|||||||
|
<#
|
||||||
|
.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 = $_.Name
|
||||||
|
id = $_.Id
|
||||||
|
source = $_.Source.ToLower()
|
||||||
|
architecture = $_.Architecture
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$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
|
||||||
|
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
|
||||||
|
DownloadStatus = [string]::Empty
|
||||||
|
}
|
||||||
|
} -ThrottleLimit 20
|
||||||
|
WriteLog "Winget search completed. Created $($output.Count) output objects."
|
||||||
|
return $output
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error during Winget search: $($_.Exception.Message)"
|
||||||
|
# Return an empty array or throw, depending on desired UI policy
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-WingetCLI {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
$minVersion = [version]"1.8.1911"
|
||||||
|
|
||||||
|
# Check Winget CLI
|
||||||
|
$wingetCmd = Get-Command -Name winget -ErrorAction SilentlyContinue
|
||||||
|
if (-not $wingetCmd) {
|
||||||
|
return @{
|
||||||
|
Version = "Not installed"
|
||||||
|
Status = "Not installed - Install from Microsoft Store"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get and check version
|
||||||
|
$wingetVersion = & winget.exe --version
|
||||||
|
if ($wingetVersion -match 'v?(\d+\.\d+.\d+)') {
|
||||||
|
$version = [version]$matches[1]
|
||||||
|
if ($version -lt $minVersion) {
|
||||||
|
return @{
|
||||||
|
Version = $version.ToString()
|
||||||
|
Status = "Update required - Install from Microsoft Store"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return @{
|
||||||
|
Version = $version.ToString()
|
||||||
|
Status = $version.ToString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{
|
||||||
|
Version = "Unknown"
|
||||||
|
Status = "Version check failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Install-WingetComponents {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
# Add parameter to accept a script block for UI updates
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[scriptblock]$UiUpdateCallback
|
||||||
|
)
|
||||||
|
|
||||||
|
$minVersion = [version]"1.8.1911"
|
||||||
|
$module = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Check and update PowerShell Module
|
||||||
|
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction SilentlyContinue
|
||||||
|
if (-not $module -or $module.Version -lt $minVersion) {
|
||||||
|
WriteLog "Winget module needs install/update. Attempting..."
|
||||||
|
# Invoke the callback provided by the UI script to update status
|
||||||
|
# Note: We don't have the CLI version readily available here, pass a placeholder or adjust if needed.
|
||||||
|
& $UiUpdateCallback "Checking..." "Installing..."
|
||||||
|
|
||||||
|
# Store and modify PSGallery trust setting temporarily if needed
|
||||||
|
$PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy
|
||||||
|
if ($PSGalleryTrust -eq 'Untrusted') {
|
||||||
|
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install/Update the module
|
||||||
|
Install-Module -Name Microsoft.WinGet.Client -Force -Repository 'PSGallery' -Scope AllUsers
|
||||||
|
|
||||||
|
# Restore original PSGallery trust setting
|
||||||
|
if ($PSGalleryTrust -eq 'Untrusted') {
|
||||||
|
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted
|
||||||
|
}
|
||||||
|
|
||||||
|
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction Stop
|
||||||
|
}
|
||||||
|
|
||||||
|
return $module
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "Failed to install/update Winget PowerShell module: $_"
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Winget Module Check Function (UI Version)
|
||||||
|
# Performs checks, triggers install if needed, and reports status back to the UI.
|
||||||
|
function Confirm-WingetInstallationUI {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
# Callback for intermediate UI updates (e.g., "Installing...")
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[scriptblock]$UiUpdateCallback
|
||||||
|
)
|
||||||
|
|
||||||
|
$minVersion = [version]"1.8.1911"
|
||||||
|
$result = [PSCustomObject]@{
|
||||||
|
Success = $false
|
||||||
|
Message = ""
|
||||||
|
CliVersion = "Unknown"
|
||||||
|
ModuleVersion = "Unknown"
|
||||||
|
NeedsUpdate = $false
|
||||||
|
UpdateAttempted = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Initial Check
|
||||||
|
WriteLog "Confirm-WingetInstallationUI: Starting checks..."
|
||||||
|
$cliStatus = Test-WingetCLI
|
||||||
|
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
$result.CliVersion = $cliStatus.Version
|
||||||
|
$result.ModuleVersion = if ($null -ne $module) { $module.Version.ToString() } else { "Not installed" }
|
||||||
|
|
||||||
|
# Use callback for initial status display
|
||||||
|
& $UiUpdateCallback $result.CliVersion $result.ModuleVersion
|
||||||
|
|
||||||
|
# Determine if install/update is needed
|
||||||
|
$needsCliUpdate = $cliStatus.Status -notmatch '^\d+\.\d+\.\d+$' -or ([version]$cliStatus.Version -lt $minVersion)
|
||||||
|
$needsModuleUpdate = ($null -eq $module) -or ([version]$module.Version -lt $minVersion)
|
||||||
|
$result.NeedsUpdate = $needsCliUpdate -or $needsModuleUpdate
|
||||||
|
|
||||||
|
if ($result.NeedsUpdate) {
|
||||||
|
WriteLog "Confirm-WingetInstallationUI: Update needed. CLI Needs Update: $needsCliUpdate, Module Needs Update: $needsModuleUpdate"
|
||||||
|
$result.UpdateAttempted = $true
|
||||||
|
|
||||||
|
# Use callback to indicate installation attempt
|
||||||
|
& $UiUpdateCallback $result.CliVersion "Installing/Updating..."
|
||||||
|
|
||||||
|
# Attempt to install/update Winget CLI and module
|
||||||
|
$installedModule = Install-WingetComponents -UiUpdateCallback $UiUpdateCallback
|
||||||
|
|
||||||
|
# Re-check status after attempt
|
||||||
|
WriteLog "Confirm-WingetInstallationUI: Re-checking status after update attempt..."
|
||||||
|
$cliStatus = Test-WingetCLI
|
||||||
|
$result.CliVersion = $cliStatus.Version
|
||||||
|
$result.ModuleVersion = if ($null -ne $installedModule) { $installedModule.Version } else { "Install Failed" }
|
||||||
|
# Use callback for final status display after update attempt
|
||||||
|
& $UiUpdateCallback $result.CliVersion $result.ModuleVersion
|
||||||
|
|
||||||
|
# Check if update was successful
|
||||||
|
$cliOk = $cliStatus.Status -match '^\d+\.\d+\.\d+$' -and ([version]$cliStatus.Version -ge $minVersion)
|
||||||
|
$moduleOk = ($null -ne $installedModule) -and ([version]$installedModule.Version -ge $minVersion)
|
||||||
|
$result.Success = $cliOk -and $moduleOk
|
||||||
|
$result.Message = if ($result.Success) { "Winget components installed/updated successfully." } else { "Winget component installation/update failed or is incomplete." }
|
||||||
|
WriteLog "Confirm-WingetInstallationUI: Update attempt finished. Success: $($result.Success). Message: $($result.Message)"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Already up-to-date
|
||||||
|
$result.Success = $true
|
||||||
|
$result.Message = "Winget components are up-to-date."
|
||||||
|
WriteLog "Confirm-WingetInstallationUI: Components already up-to-date."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$result.Success = $false
|
||||||
|
$result.Message = "Error during Winget check/install: $($_.Exception.Message)"
|
||||||
|
WriteLog "Confirm-WingetInstallationUI: Error - $($result.Message)"
|
||||||
|
# Use callback to show error state
|
||||||
|
& $UiUpdateCallback $result.CliVersion "Error"
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result
|
||||||
|
}
|
||||||
|
# Function to handle downloading a winget application (Modified for ForEach-Object -Parallel)
|
||||||
|
function Start-WingetAppDownloadTask {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[PSCustomObject]$ApplicationItemData, # Pass data, not the UI object
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$AppListJsonPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$AppsPath, # Pass necessary paths
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$OrchestrationPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue # Add queue parameter
|
||||||
|
)
|
||||||
|
|
||||||
|
$appName = $ApplicationItemData.Name
|
||||||
|
$appId = $ApplicationItemData.Id
|
||||||
|
$source = $ApplicationItemData.Source
|
||||||
|
$status = "Checking..." # Initial local status
|
||||||
|
$resultCode = -1 # Default to error/unknown
|
||||||
|
|
||||||
|
# Initial status update
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
|
||||||
|
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
|
||||||
|
# WriteLog "Apps Path: $($AppsPath)"
|
||||||
|
# WriteLog "AppList JSON Path: $($AppListJsonPath)"
|
||||||
|
# WriteLog "Windows Architecture: $($ApplicationItemData.Architecture)"
|
||||||
|
# WriteLog "Orchestration Path: $($OrchestrationPath)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Define paths
|
||||||
|
$userAppListPath = Join-Path -Path $AppsPath -ChildPath "UserAppList.json"
|
||||||
|
$appFound = $false # Flag to track if the app is found locally
|
||||||
|
# WriteLog "UserAppList Path: $($userAppListPath)"
|
||||||
|
# WriteLog "Checking for existing app in UserAppList.json and content folder."
|
||||||
|
|
||||||
|
# 1. Check UserAppList.json and content
|
||||||
|
if (Test-Path -Path $userAppListPath) {
|
||||||
|
# WriteLog "UserAppList.json found at $($userAppListPath). Checking for app entry."
|
||||||
|
try {
|
||||||
|
$userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json
|
||||||
|
$userAppEntry = $userAppListContent | Where-Object { $_.Name -eq $appName }
|
||||||
|
|
||||||
|
if ($userAppEntry) {
|
||||||
|
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $appName
|
||||||
|
if (Test-Path -Path $appFolder -PathType Container) {
|
||||||
|
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||||
|
if ($folderSize -gt 1MB) {
|
||||||
|
$appFound = $true
|
||||||
|
$status = "Not Downloaded: App in $userAppListPath and found in $appFolder"
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
WriteLog "Found '$appName' in $userAppListPath and content exists in '$appFolder'."
|
||||||
|
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$appFound = $true
|
||||||
|
$status = "App in '$userAppListPath' but content missing/small in '$appFolder'. Copy content or remove from UserAppList.json."
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
WriteLog $status
|
||||||
|
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$appFound = $true
|
||||||
|
$status = "App in '$userAppListPath' but content folder '$appFolder' not found. Copy content or remove from UserAppList.json."
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
WriteLog $status
|
||||||
|
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Warning: Could not read or parse '$userAppListPath'. Error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Check previous Winget download
|
||||||
|
if (-not $appFound) {
|
||||||
|
if (-not $appFound) {
|
||||||
|
$wingetWin32jsonFile = Join-Path -Path $OrchestrationPath -ChildPath "WinGetWin32Apps.json"
|
||||||
|
if (Test-Path -Path $wingetWin32jsonFile) {
|
||||||
|
try {
|
||||||
|
$wingetAppsJson = Get-Content -Path $wingetWin32jsonFile -Raw | ConvertFrom-Json
|
||||||
|
# Check if app already exists in WinGetWin32Apps.json
|
||||||
|
# For multi-arch apps, there might be entries like "AppName (x86)" and "AppName (x64)"
|
||||||
|
$existingWin32Entries = @($wingetAppsJson | Where-Object {
|
||||||
|
$_.Name -eq $appName -or
|
||||||
|
$_.Name -eq "$appName (x86)" -or
|
||||||
|
$_.Name -eq "$appName (x64)"
|
||||||
|
})
|
||||||
|
|
||||||
|
if ($existingWin32Entries.Count -gt 0) {
|
||||||
|
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $appName
|
||||||
|
$appContentFound = $false
|
||||||
|
|
||||||
|
# Check if it's a multi-arch app with subfolders
|
||||||
|
if ($ApplicationItemData.Architecture -eq 'x86 x64') {
|
||||||
|
$x86Folder = Join-Path -Path $appFolder -ChildPath "x86"
|
||||||
|
$x64Folder = Join-Path -Path $appFolder -ChildPath "x64"
|
||||||
|
|
||||||
|
if ((Test-Path -Path $x86Folder -PathType Container) -and (Test-Path -Path $x64Folder -PathType Container)) {
|
||||||
|
$x86Size = (Get-ChildItem -Path $x86Folder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||||
|
$x64Size = (Get-ChildItem -Path $x64Folder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||||
|
|
||||||
|
if ($x86Size -gt 1MB -and $x64Size -gt 1MB) {
|
||||||
|
$appContentFound = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Single architecture app
|
||||||
|
if (Test-Path -Path $appFolder -PathType Container) {
|
||||||
|
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||||
|
if ($folderSize -gt 1MB) {
|
||||||
|
$appContentFound = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($appContentFound) {
|
||||||
|
$appFound = $true
|
||||||
|
$status = "Not Downloaded: App already in $wingetWin32jsonFile and found in $appFolder"
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
WriteLog "Found '$appName' in WinGetWin32Apps.json and content exists in '$appFolder'. Skipping download to prevent duplicate entry."
|
||||||
|
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# App entry exists in WinGetWin32Apps.json but folder is missing or incomplete
|
||||||
|
$appFound = $true
|
||||||
|
$status = "App in '$wingetWin32jsonFile' but content folder '$appFolder' not found or incomplete. Remove entry from WinGetWin32Apps.json or restore content."
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
WriteLog $status
|
||||||
|
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Warning: Could not read or parse '$wingetWin32jsonFile'. Error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check MSStore folder
|
||||||
|
if (-not $appFound -and (Test-Path -Path "$AppsPath\MSStore" -PathType Container)) {
|
||||||
|
$appFolder = Join-Path -Path "$AppsPath\MSStore" -ChildPath $appName
|
||||||
|
if (Test-Path -Path $appFolder -PathType Container) {
|
||||||
|
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||||
|
if ($folderSize -gt 1MB) {
|
||||||
|
$appFound = $true
|
||||||
|
$status = "Already downloaded (MSStore)"
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
WriteLog "Found '$appName' content in '$appFolder'."
|
||||||
|
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. If not found locally, add to AppList.json and download
|
||||||
|
if (-not $appFound) {
|
||||||
|
# Add to AppList.json
|
||||||
|
$appListContent = $null
|
||||||
|
$appListDir = Split-Path -Path $AppListJsonPath -Parent
|
||||||
|
if (-not (Test-Path -Path $appListDir -PathType Container)) {
|
||||||
|
New-Item -Path $appListDir -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
if (Test-Path -Path $AppListJsonPath) {
|
||||||
|
try {
|
||||||
|
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||||
|
if (-not $appListContent.PSObject.Properties['apps']) {
|
||||||
|
$appListContent = @{ apps = @() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Warning: Could not read or parse '$AppListJsonPath'. Creating new structure. Error: $($_.Exception.Message)"
|
||||||
|
$appListContent = @{ apps = @() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$appListContent = @{ apps = @() }
|
||||||
|
}
|
||||||
|
|
||||||
|
$appExistsInAppList = $false
|
||||||
|
if ($appListContent.apps) {
|
||||||
|
foreach ($app in $appListContent.apps) {
|
||||||
|
if ($app.id -eq $appId) {
|
||||||
|
$appExistsInAppList = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $appExistsInAppList) {
|
||||||
|
$newApp = @{ name = $appName; id = $appId; source = $source }
|
||||||
|
if (-not ($appListContent.apps -is [array])) { $appListContent.apps = @() }
|
||||||
|
$appListContent.apps += $newApp
|
||||||
|
try {
|
||||||
|
# Use a lock to prevent race conditions when writing to the same file
|
||||||
|
$lockName = "AppListJsonLock"
|
||||||
|
$lock = New-Object System.Threading.Mutex($false, $lockName)
|
||||||
|
try {
|
||||||
|
$lock.WaitOne() | Out-Null
|
||||||
|
# Re-read content inside lock to ensure latest version
|
||||||
|
if (Test-Path -Path $AppListJsonPath) {
|
||||||
|
$currentAppListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||||
|
if (-not ($currentAppListContent.apps | Where-Object { $_.id -eq $appId })) {
|
||||||
|
$currentAppListContent.apps += $newApp
|
||||||
|
$currentAppListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||||
|
WriteLog "Added '$appName' to '$AppListJsonPath'."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "'$appName' already exists in '$AppListJsonPath' (checked inside lock)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# File doesn't exist, write the initial content
|
||||||
|
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||||
|
WriteLog "Created '$AppListJsonPath' and added '$appName'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$lock.ReleaseMutex()
|
||||||
|
$lock.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Error saving '$AppListJsonPath'. Error: $($_.Exception.Message)"
|
||||||
|
$status = "Failed to save AppList.json: $($_.Exception.Message)"
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "'$appName' already exists in '$AppListJsonPath'."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proceed with download
|
||||||
|
$status = "Downloading..."
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
|
||||||
|
# Ensure variables needed by Get-Application are accessible
|
||||||
|
# (Assuming they are available via $using: scope or global scope from main script)
|
||||||
|
# $global:AppsPath = $AppsPath # Potentially redundant
|
||||||
|
# $global:WindowsArch = $ApplicationItemData.Architecture # Potentially redundant
|
||||||
|
# $global:orchestrationPath = $OrchestrationPath # Potentially redundant"
|
||||||
|
WriteLog "Orchestration Path: $($OrchestrationPath)"
|
||||||
|
if (-not (Test-Path -Path $OrchestrationPath -PathType Container)) {
|
||||||
|
New-Item -Path $OrchestrationPath -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
$win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32"
|
||||||
|
if ($source -eq "winget" -and -not (Test-Path -Path $win32Folder -PathType Container)) {
|
||||||
|
New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
$storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore"
|
||||||
|
if ($source -eq "msstore" -and -not (Test-Path -Path $storeAppsFolder -PathType Container)) {
|
||||||
|
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Call Get-Application
|
||||||
|
$resultCode = Get-Application -AppName $appName -AppId $appId -Source $source -AppsPath $AppsPath -WindowsArch $ApplicationItemData.Architecture -OrchestrationPath $OrchestrationPath -ErrorAction Stop
|
||||||
|
|
||||||
|
# Determine status based on result code
|
||||||
|
switch ($resultCode) {
|
||||||
|
0 { $status = "Downloaded successfully" }
|
||||||
|
1 { $status = "Error: No app installers were found" }
|
||||||
|
2 { $status = "Silent install switch could not be found. Did not download." }
|
||||||
|
3 { $status = "Error: Publisher does not support download" }
|
||||||
|
4 { $status = "Skipped: Use 'msstore' source instead." }
|
||||||
|
default { $status = "Downloaded with status: $resultCode" } # Should not happen with current Get-Application
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove app from AppList.json if silent install switch could not be found (resultCode 2)
|
||||||
|
if ($resultCode -eq 2) {
|
||||||
|
try {
|
||||||
|
if (Test-Path -Path $AppListJsonPath) {
|
||||||
|
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||||
|
if ($appListContent.apps) {
|
||||||
|
$filteredApps = @($appListContent.apps | Where-Object { $_.id -ne $appId })
|
||||||
|
$appListContent.apps = $filteredApps
|
||||||
|
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||||
|
WriteLog "Removed '$appName' ($appId) from '$AppListJsonPath' due to missing silent install switch."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Failed to remove '$appName' from '$AppListJsonPath': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$status = $_.Exception.Message
|
||||||
|
WriteLog "Download error for $($appName): $($_.Exception.Message)"
|
||||||
|
$resultCode = 1 # Indicate error
|
||||||
|
# Enqueue error status
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
|
||||||
|
# Remove app from AppList.json if publisher does not support download
|
||||||
|
if ($_.Exception.Message -match "does not support downloads by the publisher") {
|
||||||
|
try {
|
||||||
|
if (Test-Path -Path $AppListJsonPath) {
|
||||||
|
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||||
|
if ($appListContent.apps) {
|
||||||
|
$filteredApps = @($appListContent.apps | Where-Object { $_.id -ne $appId })
|
||||||
|
$appListContent.apps = $filteredApps
|
||||||
|
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||||
|
WriteLog "Removed '$appName' ($appId) from '$AppListJsonPath' due to publisher download restriction."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "Failed to remove '$appName' from '$AppListJsonPath': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
} # End if (-not $appFound)
|
||||||
|
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$status = $_.Exception.Message
|
||||||
|
WriteLog "Unexpected error in Start-WingetAppDownloadTask for $($appName): $($_.Exception.Message)"
|
||||||
|
$resultCode = 1 # Indicate error
|
||||||
|
# Enqueue error status
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# Ensure status is not empty before returning
|
||||||
|
if ([string]::IsNullOrEmpty($status)) {
|
||||||
|
$status = "Unknown failure" # Provide a default error status
|
||||||
|
WriteLog "Status was empty for $appName ($appId), setting to default error."
|
||||||
|
if ($resultCode -ne 0 -and $resultCode -ne 1 -and $resultCode -ne 2) {
|
||||||
|
$resultCode = -1 # Ensure resultCode reflects an error if it was empty
|
||||||
|
}
|
||||||
|
# Enqueue the final (error) status if it was previously empty
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
}
|
||||||
|
elseif ($resultCode -ne 0) {
|
||||||
|
# Enqueue the final status if it's an error (already set in try/catch)
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Enqueue the final success status
|
||||||
|
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prepare the return object as a Hashtable
|
||||||
|
$returnObject = @{ Id = $appId; Status = $status; ResultCode = $resultCode }
|
||||||
|
|
||||||
|
# Return the final status and result code as a Hashtable
|
||||||
|
return $returnObject
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-WingetDownload {
|
||||||
|
param(
|
||||||
|
[psobject]$State,
|
||||||
|
[object]$Button
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
$selectedApps = $State.Controls.lstWingetResults.Items | Where-Object { $_.IsSelected }
|
||||||
|
if (-not $selectedApps) {
|
||||||
|
[System.Windows.MessageBox]::Show("No applications selected to download.", "Download Winget Apps", "OK", "Information")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$Button.IsEnabled = $false
|
||||||
|
$State.Controls.pbOverallProgress.Visibility = 'Visible'
|
||||||
|
$State.Controls.pbOverallProgress.Value = 0
|
||||||
|
$State.Controls.txtStatus.Text = "Starting Winget app downloads..."
|
||||||
|
|
||||||
|
# Define necessary task-specific variables locally
|
||||||
|
$localAppsPath = $State.Controls.txtApplicationPath.Text
|
||||||
|
$localAppListJsonPath = $State.Controls.txtAppListJsonPath.Text
|
||||||
|
$localWindowsArch = $State.Controls.cmbWindowsArch.SelectedItem
|
||||||
|
$localOrchestrationPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath "Orchestration"
|
||||||
|
|
||||||
|
# Create hashtable for task-specific arguments to pass to Invoke-ParallelProcessing
|
||||||
|
$taskArguments = @{
|
||||||
|
AppsPath = $localAppsPath
|
||||||
|
AppListJsonPath = $localAppListJsonPath
|
||||||
|
OrchestrationPath = $localOrchestrationPath
|
||||||
|
}
|
||||||
|
|
||||||
|
# Select only necessary properties before passing to Invoke-ParallelProcessing
|
||||||
|
$itemsToProcess = $selectedApps | Select-Object Name, Id, Source, Version, Architecture # Include Version and Architecture if needed
|
||||||
|
# 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,349 @@
|
|||||||
|
<#
|
||||||
|
.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
|
||||||
|
BuildUSBDriveEnable = $false
|
||||||
|
CompactOS = $true
|
||||||
|
Optimize = $true
|
||||||
|
AllowVHDXCaching = $false
|
||||||
|
CreateCaptureMedia = $true
|
||||||
|
CreateDeploymentMedia = $true
|
||||||
|
Verbose = $false
|
||||||
|
AllowExternalHardDiskMedia = $false
|
||||||
|
PromptExternalHardDiskMedia = $true
|
||||||
|
SelectSpecificUSBDrives = $false
|
||||||
|
CopyAutopilot = $false
|
||||||
|
CopyUnattend = $false
|
||||||
|
CopyPPKG = $false
|
||||||
|
CleanupAppsISO = $true
|
||||||
|
CleanupCaptureISO = $true
|
||||||
|
CleanupDeployISO = $true
|
||||||
|
CleanupDrivers = $false
|
||||||
|
RemoveFFU = $false
|
||||||
|
RemoveApps = $false
|
||||||
|
RemoveUpdates = $false
|
||||||
|
# Hyper-V Settings Defaults
|
||||||
|
VMHostIPAddress = ""
|
||||||
|
DiskSizeGB = 30
|
||||||
|
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
|
||||||
|
UpdateADK = $true
|
||||||
|
CompressDownloadedDriversToWim = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get USB Drives (Moved from BuildFFUVM_UI.ps1)
|
||||||
|
function Get-USBDrives {
|
||||||
|
Get-WmiObject Win32_DiskDrive | Where-Object {
|
||||||
|
($_.MediaType -eq 'Removable Media' -or $_.MediaType -eq 'External hard disk media')
|
||||||
|
} | ForEach-Object {
|
||||||
|
$size = [math]::Round($_.Size / 1GB, 2)
|
||||||
|
$serialNumber = if ($_.SerialNumber) { $_.SerialNumber.Trim() } else { "N/A" }
|
||||||
|
@{
|
||||||
|
IsSelected = $false
|
||||||
|
Model = $_.Model.Trim()
|
||||||
|
SerialNumber = $serialNumber
|
||||||
|
Size = $size
|
||||||
|
DriveIndex = $_.Index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
$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
|
||||||
|
}
|
||||||
|
$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
|
||||||
|
|
||||||
|
# Default to enabled, then apply disabling rules
|
||||||
|
$installDriversChk.IsEnabled = $true
|
||||||
|
$copyDriversChk.IsEnabled = $true
|
||||||
|
$compressWimChk.IsEnabled = $true
|
||||||
|
|
||||||
|
if ($installDriversChk.IsChecked) {
|
||||||
|
$copyDriversChk.IsEnabled = $false
|
||||||
|
$compressWimChk.IsEnabled = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($copyDriversChk.IsChecked) {
|
||||||
|
$installDriversChk.IsEnabled = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($compressWimChk.IsChecked) {
|
||||||
|
$installDriversChk.IsEnabled = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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 *
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $True, Position = 0)]
|
||||||
|
$DeployISOPath,
|
||||||
|
[Switch]$DisableAutoPlay
|
||||||
|
)
|
||||||
|
$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 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"}
|
||||||
|
If($USBDrives -and ($null -eq $USBDrives.count)) {
|
||||||
|
$USBDrivesCount = 1
|
||||||
|
} else {
|
||||||
|
$USBDrivesCount = $USBDrives.Count
|
||||||
|
}
|
||||||
|
WriteLog "Found $USBDrivesCount USB drives"
|
||||||
|
|
||||||
|
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 "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
|
||||||
|
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
|
||||||
|
$Disk = Get-Disk -Number $DriveNumber
|
||||||
|
$PartitionStyle = $Disk.PartitionStyle
|
||||||
|
if($PartitionStyle -ne 'MBR'){
|
||||||
|
$Disk | Set-Disk -PartitionStyle MBR
|
||||||
|
}
|
||||||
|
$BootPartition = New-Partition -DiskNumber $DriveNumber -Size 2GB -IsActive -AssignDriveLetter
|
||||||
|
$DeployPartition = New-Partition -DiskNumber $DriveNumber -UseMaximumSize -AssignDriveLetter
|
||||||
|
Format-Volume -Partition $BootPartition -FileSystem FAT32 -NewFileSystemLabel "Boot" -Confirm:$false
|
||||||
|
Format-Volume -Partition $DeployPartition -FileSystem NTFS -NewFileSystemLabel "Deploy" -Confirm:$false
|
||||||
|
}
|
||||||
|
WriteLog "Start job to create BOOT and Deploy partitions on drive number $DriveNumber"
|
||||||
|
Start-Job -ScriptBlock $ScriptBlock -ArgumentList $DriveNumber | Out-Null
|
||||||
|
}
|
||||||
|
writelog "Wait for partitioning jobs to complete"
|
||||||
|
Get-Job | Wait-Job | Out-Null
|
||||||
|
if($DrivesCount -gt 1){
|
||||||
|
writelog "Get file system information for all drives"
|
||||||
|
$Partitions = Get-Partition | Get-Volume
|
||||||
|
} else {
|
||||||
|
writelog "Get file system information for drive number $DiskNumber"
|
||||||
|
$Partitions = Get-Partition -DiskNumber $DriveNumber | Get-Volume
|
||||||
|
}
|
||||||
|
writelog "Get drive letter for all volumes labeled:BOOT"
|
||||||
|
$BootDrives = ($Partitions | Where-Object { $_.FileSystemLabel -eq "BOOT"}).DriveLetter
|
||||||
|
writelog "Get drive letter for all volumes labeled:Deploy"
|
||||||
|
$DeployDrives = ($Partitions | Where-Object { $_.FileSystemLabel -eq "Deploy"}).DriveLetter
|
||||||
|
writelog "Mount Deployment .iso image"
|
||||||
|
$ISOMountPoint = (Mount-DiskImage -ImagePath "$DeployISOPath" -PassThru | Get-Volume).DriveLetter + ":\"
|
||||||
|
writelog "Copying boot files to all drives labeled BOOT concurrently"
|
||||||
|
foreach ($Drive in $BootDrives) {
|
||||||
|
$Destination = $Drive + ":\"
|
||||||
|
$jobScriptBlock = {
|
||||||
|
param (
|
||||||
|
[string]$SFolder,
|
||||||
|
[string]$DFolder
|
||||||
|
)
|
||||||
|
Robocopy $SFolder $DFolder /E /COPYALL /R:5 /W:5 /J
|
||||||
|
}
|
||||||
|
WriteLog "Start job to copy all boot files to $Destination"
|
||||||
|
Start-Job -ScriptBlock $jobScriptBlock -ArgumentList $ISOMountPoint, $Destination | Out-Null
|
||||||
|
}
|
||||||
|
if($Images){
|
||||||
|
writelog "Copying FFU image files to all drives labeled deploy concurrently"
|
||||||
|
foreach ($Drive in $DeployDrives) {
|
||||||
|
$Destination = $Drive + ":\"
|
||||||
|
$jobScriptBlock = {
|
||||||
|
param (
|
||||||
|
[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 $ImagesPath, $Destination | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!($Images)){
|
||||||
|
foreach ($Drive in $DeployDrives) {
|
||||||
|
WriteLog "Create images directory"
|
||||||
|
$drivepath = $Drive + ":\"
|
||||||
|
New-Item -Path "$drivepath" -Name Images -ItemType Directory -Force -Confirm: $false | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if($Drivers){
|
||||||
|
writelog "Copying driver files to all drives labeled deploy concurrently"
|
||||||
|
foreach ($Drive in $DeployDrives) {
|
||||||
|
$Destination = $Drive + ":\Drivers"
|
||||||
|
$jobScriptBlock = {
|
||||||
|
param (
|
||||||
|
[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 drivers to $Destination"
|
||||||
|
Start-Job -ScriptBlock $jobScriptBlock -ArgumentList $DriversPath, $Destination | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!($Drivers)){
|
||||||
|
foreach ($Drive in $DeployDrives) {
|
||||||
|
WriteLog "Create drivers directory"
|
||||||
|
$drivepath = $Drive + ":\"
|
||||||
|
New-Item -Path "$drivepath" -Name Drivers -ItemType Directory -Force -Confirm: $false | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if($DrivesCount -gt 1){
|
||||||
|
Write-ProgressLog "Create Imaging Tool" "Building $DrivesCount drives concurrently...Please be patient..."
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
Write-ProgressLog "Create Imaging Tool" "Drive creation jobs completed..."
|
||||||
|
}
|
||||||
|
|
||||||
|
Function New-DeploymentUSB {
|
||||||
|
param(
|
||||||
|
[Array]$Drives,
|
||||||
|
[int]$Count,
|
||||||
|
[String]$FFUPath = "$DevelopmentPath\FFU",
|
||||||
|
[String]$DriversPath = "$DevelopmentPath\Drivers"
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
$Drivelist = @()
|
||||||
|
writelog "Creating a USB drive selection list"
|
||||||
|
for($i=0;$i -le $Count -1;$i++){
|
||||||
|
$DriveModel = $Drives[$i].Model
|
||||||
|
$DriveSize = [math]::round($Drives[$i].size/1GB, 2)
|
||||||
|
$DiskNumber = $Drives[$i].DeviceID.Replace("\\.\PHYSICALDRIVE", "")
|
||||||
|
$Properties = [ordered]@{Number = $i + 1 ; DriveNumber = $DiskNumber ; DriveModel = $driveModel ; 'Size (GB)' = $DriveSize}
|
||||||
|
|
||||||
|
$Drivelist += New-Object PSObject -Property $Properties
|
||||||
|
}
|
||||||
|
if($Count -gt 1){
|
||||||
|
$Last = $Count+1
|
||||||
|
$Drivelist += New-Object -TypeName PSObject -Property @{ Number = "$last"; DriveModel = "Select this option to use all ($count) inserted USB Drives" }
|
||||||
|
}
|
||||||
|
$Drivelist | Format-Table -AutoSize -Property Number, DriveModel , 'Size (GB)'
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
$var = $true
|
||||||
|
$DriveSelected = Read-Host 'Enter the drive number to apply the .iso to'
|
||||||
|
$DriveSelected = ($DriveSelected -as [int]) -1
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
WriteLog "Closing all MMC windows to prevent drive lock errors"
|
||||||
|
Stop-Process -Name mmc -ErrorAction SilentlyContinue
|
||||||
|
WriteLog "Closing all Diskpart windows to prevent drive lock errors"
|
||||||
|
Stop-Process -Name diskpart -ErrorAction SilentlyContinue
|
||||||
|
$Selection = $Drivelist[$DriveSelected].Number
|
||||||
|
$totalSteps = 5
|
||||||
|
if($Selection -eq $last){
|
||||||
|
Read-Host -Prompt "ALL DRIVES SELECTED! WILL ERASE ALL CURRENTLY CONNECTED USB DRIVES!! Press ENTER to continue"
|
||||||
|
Build-DeploymentUSB -Drives $Drives
|
||||||
|
} else {
|
||||||
|
Read-Host -Prompt "Drive number $Selection was selected. Press ENTER to continue"
|
||||||
|
Build-DeploymentUSB -Drives $Drives[$DriveSelected]
|
||||||
|
}
|
||||||
|
WriteLog "Setting the registry key to re-enable autoplay for all drives"
|
||||||
|
if($DisableAutoPlay){
|
||||||
|
Write-ProgressLog "Create Imaging Tool" "Enabling Autoplay"
|
||||||
|
Set-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\AutoplayHandlers" -Name "DisableAutoplay" -Value 0 -Type DWORD
|
||||||
|
}
|
||||||
|
Write-ProgressLog "Create Imaging Tool" "Completed!"
|
||||||
|
}
|
||||||
|
#Get USB Drive and create log file
|
||||||
|
if(Test-Path "$DevelopmentPath\Script.log"){
|
||||||
|
Remove-Item -Path "$DevelopmentPath\Script.log" -Force -Confirm:$false
|
||||||
|
New-item -Path $DevelopmentPath -Name 'Script.log' -ItemType "file" -Force | Out-Null
|
||||||
|
}
|
||||||
|
WriteLog 'Begin Logging'
|
||||||
|
WriteLog 'Getting USB drive information and usb drive count'
|
||||||
|
$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
|
||||||
|
} else {
|
||||||
|
Write-Host "No .ISO file selected..."
|
||||||
|
read-host "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
|
$VMHostIPAddress = '192.168.1.158'
|
||||||
net use W: \\192.168.1.158\FFUCaptureShare /user:ffu_user ddb1f077-3eed-433c-b4d9-7b8cd54ce727
|
$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'
|
$AssignDriveLetter = 'x:\AssignDriveLetter.txt'
|
||||||
|
try {
|
||||||
|
Write-Host 'Assigning M: as Windows drive letter'
|
||||||
Start-Process -FilePath diskpart.exe -ArgumentList "/S $AssignDriveLetter" -Wait -ErrorAction Stop | Out-Null
|
Start-Process -FilePath diskpart.exe -ArgumentList "/S $AssignDriveLetter" -Wait -ErrorAction Stop | Out-Null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "Failed to assign drive letter using diskpart: $_"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
#Load Registry Hive
|
#Load Registry Hive
|
||||||
$Software = 'M:\Windows\System32\config\software'
|
$Software = 'M:\Windows\System32\config\software'
|
||||||
reg load "HKLM\FFU" $Software
|
try {
|
||||||
|
Write-Host "Loading software registry hive to $Software"
|
||||||
|
if (-not (Test-Path -Path $Software)) {
|
||||||
|
throw "Software registry hive not found at $Software"
|
||||||
|
}
|
||||||
|
$regResult = reg load "HKLM\FFU" $Software 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Registry load failed with exit code $($LASTEXITCODE): $regResult"
|
||||||
|
}
|
||||||
|
Write-Host "Successfully loaded software registry hive."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "Failed to load registry hive: $_"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
#Find Windows version values
|
#Find Windows version values
|
||||||
|
Write-Host "Retrieving Windows information from the registry..."
|
||||||
$SKU = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'EditionID'
|
$SKU = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'EditionID'
|
||||||
|
Write-Host "SKU: $SKU"
|
||||||
[int]$CurrentBuild = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'CurrentBuild'
|
[int]$CurrentBuild = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'CurrentBuild'
|
||||||
$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
|
$BuildDate = Get-Date -uformat %b%Y
|
||||||
|
Write-Host "BuildDate: $BuildDate"
|
||||||
|
|
||||||
$SKU = switch ($SKU) {
|
$SKU = switch ($SKU) {
|
||||||
Core { 'Home' }
|
Core { 'Home' }
|
||||||
CoreN { 'HomeN'}
|
CoreN { 'Home_N' }
|
||||||
CoreSingleLanguage { 'HomeSL'}
|
CoreSingleLanguage { 'Home_SL' }
|
||||||
Professional { 'Pro' }
|
Professional { 'Pro' }
|
||||||
ProfessionalN { 'ProN'}
|
ProfessionalN { 'Pro_N' }
|
||||||
ProfessionalEducation { 'Pro_Edu' }
|
ProfessionalEducation { 'Pro_Edu' }
|
||||||
ProfessionalEducationN { 'Pro_EduN' }
|
ProfessionalEducationN { 'Pro_Edu_N' }
|
||||||
Enterprise { 'Ent' }
|
Enterprise { 'Ent' }
|
||||||
EnterpriseN { 'EntN'}
|
EnterpriseN { 'Ent_N' }
|
||||||
|
EnterpriseS { 'Ent_LTSC' }
|
||||||
|
EnterpriseSN { 'Ent_N_LTSC' }
|
||||||
|
IoTEnterpriseS { 'IoT_Ent_LTSC' }
|
||||||
Education { 'Edu' }
|
Education { 'Edu' }
|
||||||
EducationN { 'EduN'}
|
EducationN { 'Edu_N' }
|
||||||
ProfessionalWorkstation { 'Pro_Wks' }
|
ProfessionalWorkstation { 'Pro_Wks' }
|
||||||
ProfessionalWorkstationN { 'Pro_WksN' }
|
ProfessionalWorkstationN { 'Pro_Wks_N' }
|
||||||
|
ServerStandard { 'Srv_Std' }
|
||||||
|
ServerDatacenter { 'Srv_Dtc' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($InstallationType -eq "Client") {
|
||||||
if ($CurrentBuild -ge 22000) {
|
if ($CurrentBuild -ge 22000) {
|
||||||
$Name = 'Win11'
|
$WindowsRelease = 'Win11'
|
||||||
|
Write-Host "WindowsRelease: $WindowsRelease"
|
||||||
}
|
}
|
||||||
else {
|
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
|
#If Office is installed, modify the file name of the FFU
|
||||||
#$Office = Get-childitem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue | Out-Null
|
$Office = Get-ChildItem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue
|
||||||
$Office = Get-childitem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue
|
|
||||||
if ($Office) {
|
if ($Office) {
|
||||||
$ffuFilePath = "W:\$Name`_$DisplayVersion`_$SKU`_Office`_$BuildDate.ffu"
|
$ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Office`_$BuildDate.ffu"
|
||||||
$dismArgs = "/capture-ffu /imagefile=$ffuFilePath /capturedrive=\\.\PhysicalDrive0 /name:$Name$DisplayVersion$SKU /Compress:Default"
|
Write-Host "Office is installed, using modified FFU file name: $ffuFilePath"
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$ffuFilePath = "W:\$Name`_$DisplayVersion`_$SKU`_Apps`_$BuildDate.ffu"
|
$ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Apps`_$BuildDate.ffu"
|
||||||
$dismArgs = "/capture-ffu /imagefile=$ffuFilePath /capturedrive=\\.\PhysicalDrive0 /name:$Name$DisplayVersion$SKU /Compress:Default"
|
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
|
#Unload Registry
|
||||||
Set-Location X:\
|
Set-Location X:\
|
||||||
Remove-Variable SKU
|
Remove-Variable SKU
|
||||||
Remove-Variable CurrentBuild
|
Remove-Variable CurrentBuild
|
||||||
Remove-Variable DisplayVersion
|
if ($CurrentBuild -notin 14393, 17763) {
|
||||||
|
Remove-Variable WindowsVersion
|
||||||
|
}
|
||||||
|
if ($Office) {
|
||||||
Remove-Variable Office
|
Remove-Variable Office
|
||||||
reg unload "HKLM\FFU"
|
}
|
||||||
#This prevents Critical Process Died errors you can have during deployment of the FFU - may not happen during capture from WinPE, but adding here to be consistent with VHDX capture
|
|
||||||
|
try {
|
||||||
|
Write-Host "Unloading registry hive HKLM\FFU..."
|
||||||
|
$regUnloadResult = reg unload "HKLM\FFU" 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Registry unload failed with exit code $($LASTEXITCODE): $regUnloadResult"
|
||||||
|
}
|
||||||
|
Write-Host "Successfully unloaded registry hive."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "Failed to unload registry hive: $_"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "Sleeping for 60 seconds to allow registry to unload prior to capture"
|
Write-Host "Sleeping for 60 seconds to allow registry to unload prior to capture"
|
||||||
Start-sleep 60
|
Start-sleep 60
|
||||||
Start-Process -FilePath dism.exe -ArgumentList $dismArgs -Wait -PassThru -ErrorAction Stop | Out-Null
|
|
||||||
#Copy DISM log to Host
|
|
||||||
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
|
wpeutil Shutdown
|
||||||
|
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "An unexpected error occurred: $_"
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
function Get-USBDrive(){
|
function Get-USBDrive() {
|
||||||
$USBDriveLetter = (Get-Volume | Where-Object { $_.DriveType -eq 'Removable' -and $_.FileSystemType -eq 'NTFS' }).DriveLetter
|
$USBDriveLetter = (Get-Volume | Where-Object { $_.DriveType -eq 'Removable' -and $_.FileSystemType -eq 'NTFS' }).DriveLetter
|
||||||
if ($null -eq $USBDriveLetter) {
|
if ($null -eq $USBDriveLetter) {
|
||||||
#Must be using a fixed USB drive - difficult to grab drive letter from win32_diskdrive. Assume user followed instructions and used Deploy as the friendly name for partition
|
#Must be using a fixed USB drive - difficult to grab drive letter from win32_diskdrive. Assume user followed instructions and used Deploy as the friendly name for partition
|
||||||
$USBDriveLetter = (Get-Volume | Where-Object { $_.DriveType -eq 'Fixed' -and $_.FileSystemType -eq 'NTFS' -and $_.FileSystemLabel -eq 'Deploy' }).DriveLetter
|
$USBDriveLetter = (Get-Volume | Where-Object { $_.DriveType -eq 'Fixed' -and $_.FileSystemType -eq 'NTFS' -and $_.FileSystemLabel -eq 'Deploy' }).DriveLetter
|
||||||
#If we didn't get the drive letter, stop the script.
|
#If we didn't get the drive letter, stop the script.
|
||||||
if ($null -eq $USBDriveLetter) {
|
if ($null -eq $USBDriveLetter) {
|
||||||
WriteLog 'Cannot find USB drive letter - most likely using a fixed USB drive. Name the 2nd partition with the FFU files as Deploy so the script can grab the drive letter. Exiting'
|
$errorMessage = 'Cannot find USB drive letter. If using a fixed USB drive, name the deployment partition "Deploy".'
|
||||||
Exit
|
WriteLog ($errorMessage + ' Exiting.')
|
||||||
|
Stop-Script -Message $errorMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -15,8 +16,34 @@ function Get-USBDrive(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Get-HardDrive() {
|
function Get-HardDrive() {
|
||||||
$DeviceID = (Get-WmiObject -Class 'Win32_DiskDrive' | Where-Object {$_.MediaType -eq 'Fixed hard disk media' -and $_.Model -ne 'Microsoft Virtual Disk'}).DeviceID
|
$systemInfo = Get-CimInstance -Class 'Win32_ComputerSystem'
|
||||||
return $DeviceID
|
$manufacturer = $systemInfo.Manufacturer
|
||||||
|
$model = $systemInfo.Model
|
||||||
|
WriteLog 'Getting Hard Drive info'
|
||||||
|
if ($manufacturer -eq 'Microsoft Corporation' -and $model -eq 'Virtual Machine') {
|
||||||
|
WriteLog 'Running in a Hyper-V VM. Getting virtual disk on Index 0 and SCSILogicalUnit 0'
|
||||||
|
$diskDrive = Get-CimInstance -Class 'Win32_DiskDrive' | Where-Object { $_.MediaType -eq 'Fixed hard disk media' `
|
||||||
|
-and $_.Model -eq 'Microsoft Virtual Disk' `
|
||||||
|
-and $_.Index -eq 0 `
|
||||||
|
-and $_.SCSILogicalUnit -eq 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog 'Not running in a VM. Getting physical disk drive'
|
||||||
|
$diskDrive = Get-CimInstance -Class 'Win32_DiskDrive' | Where-Object { $_.MediaType -eq 'Fixed hard disk media' -and $_.Model -ne 'Microsoft Virtual Disk' }
|
||||||
|
}
|
||||||
|
$deviceID = $diskDrive.DeviceID
|
||||||
|
$bytesPerSector = $diskDrive.BytesPerSector
|
||||||
|
$diskSize = $diskDrive.Size
|
||||||
|
|
||||||
|
# Create a custom object to return values
|
||||||
|
$result = [PSCustomObject]@{
|
||||||
|
DeviceID = $deviceID
|
||||||
|
BytesPerSector = $bytesPerSector
|
||||||
|
DiskSize = $diskSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result
|
||||||
}
|
}
|
||||||
|
|
||||||
function WriteLog($LogText) {
|
function WriteLog($LogText) {
|
||||||
@@ -29,11 +56,18 @@ function Set-DiskpartAnswerFiles($DiskpartFile,$DiskID){
|
|||||||
|
|
||||||
function Set-Computername($computername) {
|
function Set-Computername($computername) {
|
||||||
[xml]$xml = Get-Content $UnattendFile
|
[xml]$xml = Get-Content $UnattendFile
|
||||||
if($xml.unattend.settings.component.Count -ge 2){
|
$components = $xml.unattend.settings.component
|
||||||
#Assumes that Computername is the first component element
|
$found = $false
|
||||||
$xml.unattend.settings.component[0].ComputerName = $computername
|
foreach ($component in $components) {
|
||||||
}else{
|
if ($component.ComputerName) {
|
||||||
$xml.unattend.settings.component.ComputerName = $computername
|
$component.ComputerName = $computername
|
||||||
|
$found = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $found) {
|
||||||
|
WriteLog 'ComputerName element not found in unattend.xml.'
|
||||||
|
throw 'ComputerName element not found in unattend.xml.'
|
||||||
}
|
}
|
||||||
$xml.Save($UnattendFile)
|
$xml.Save($UnattendFile)
|
||||||
return $computername
|
return $computername
|
||||||
@@ -65,7 +99,7 @@ function Invoke-Process {
|
|||||||
RedirectStandardOutput = $stdOutTempFile
|
RedirectStandardOutput = $stdOutTempFile
|
||||||
Wait = $true;
|
Wait = $true;
|
||||||
PassThru = $true;
|
PassThru = $true;
|
||||||
NoNewWindow = $false;
|
NoNewWindow = $true;
|
||||||
}
|
}
|
||||||
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
|
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
|
||||||
$cmd = Start-Process @startProcessParams
|
$cmd = Start-Process @startProcessParams
|
||||||
@@ -78,86 +112,149 @@ function Invoke-Process {
|
|||||||
if ($cmdOutput) {
|
if ($cmdOutput) {
|
||||||
throw $cmdOutput.Trim()
|
throw $cmdOutput.Trim()
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
|
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
|
||||||
WriteLog $cmdOutput
|
WriteLog $cmdOutput
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
}
|
||||||
|
catch {
|
||||||
#$PSCmdlet.ThrowTerminatingError($_)
|
#$PSCmdlet.ThrowTerminatingError($_)
|
||||||
WriteLog $_
|
WriteLog $_
|
||||||
Write-Host 'Script failed - check scriptlog.txt on the USB drive for more info'
|
Write-Host 'Script failed - check scriptlog.txt on the USB drive for more info'
|
||||||
throw $_
|
throw $_
|
||||||
|
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
|
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# This function can be used in instances where battery level might matter (e.g. installing firmware for Surface). The problem is that WinPE doesn't have
|
function Write-SectionHeader($Title) {
|
||||||
# a driver for the battery installed, so you'll need to inject drivers, which can be tricky because just injecting the battery driver might not be enough,
|
$width = 51
|
||||||
# you might also need other drivers that the battery driver is dependent on.
|
$leftPad = [math]::Floor(($width - $Title.Length) / 2)
|
||||||
# function Get-Battery(){
|
$rightPad = $width - $Title.Length - $leftPad
|
||||||
# while (($BattLev = (Get-CimInstance win32_battery).EstimatedChargeRemaining) -lt "35")
|
$centeredTitle = (' ' * $leftPad) + $Title + (' ' * $rightPad)
|
||||||
# {
|
|
||||||
# WriteLog "Battery is currently at $BattLev`%. Waiting for 35`% to proceed..."
|
|
||||||
# Write-Host "Battery is currently at $BattLev`%. Waiting for 35`% to proceed..."
|
|
||||||
# Start-Sleep 60
|
|
||||||
# }
|
|
||||||
|
|
||||||
# WriteLog "Battery level is $BattLev `%, which is greater than 35'% applying FFU"
|
Write-Host "`n" # Add a newline for spacing
|
||||||
# Write-Host "Battery level is $BattLev `%, which is greater than 35'% applying FFU"
|
Write-Host ('-' * $width) -ForegroundColor Yellow
|
||||||
# }
|
Write-Host $centeredTitle -ForegroundColor Yellow
|
||||||
|
Write-Host ('-' * $width) -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-SystemInformation($hardDrive) {
|
||||||
|
# Gather all information first
|
||||||
|
$systemManufacturer = (Get-CimInstance -Class Win32_ComputerSystem).Manufacturer
|
||||||
|
$systemModel = if ($systemManufacturer -like '*LENOVO*') {
|
||||||
|
(Get-CimInstance -Class Win32_ComputerSystemProduct).Version
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
(Get-CimInstance -Class Win32_ComputerSystem).Model
|
||||||
|
}
|
||||||
|
$biosInfo = Get-CimInstance -Class Win32_Bios
|
||||||
|
$processor = (Get-CimInstance -Class Win32_Processor).Name
|
||||||
|
$totalMemory = (Get-CimInstance -Class Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum
|
||||||
|
$totalMemoryGB = [math]::Round($totalMemory / 1GB, 2)
|
||||||
|
$diskSizeGB = [math]::Round($hardDrive.DiskSize / 1GB, 2)
|
||||||
|
|
||||||
|
# Create a custom object for structured data
|
||||||
|
$sysInfoObject = [PSCustomObject]@{
|
||||||
|
"Manufacturer" = $systemManufacturer
|
||||||
|
"Model" = $systemModel
|
||||||
|
"BIOS Version" = $biosInfo.Version
|
||||||
|
"Serial Number" = $biosInfo.SerialNumber
|
||||||
|
"Processor" = $processor
|
||||||
|
"Memory" = "$($totalMemoryGB) GB"
|
||||||
|
"Disk Size" = "$($diskSizeGB) GB"
|
||||||
|
"Logical Sector Size" = "$($hardDrive.BytesPerSector) Bytes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Log information line-by-line
|
||||||
|
WriteLog "--- System Information ---"
|
||||||
|
$sysInfoObject.psobject.Properties | ForEach-Object {
|
||||||
|
WriteLog "$($_.Name): $($_.Value)"
|
||||||
|
}
|
||||||
|
WriteLog "--- End System Information ---"
|
||||||
|
|
||||||
|
# Console output
|
||||||
|
Write-SectionHeader -Title 'System Information'
|
||||||
|
|
||||||
|
# Format for console using Format-List for better readability
|
||||||
|
$consoleOutput = $sysInfoObject | Format-List | Out-String
|
||||||
|
Write-Host $consoleOutput.Trim()
|
||||||
|
Write-Host # Adds a blank line for spacing after the block
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-Script {
|
||||||
|
param(
|
||||||
|
[string]$Message
|
||||||
|
)
|
||||||
|
Write-Host "`n"
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($Message)) {
|
||||||
|
Write-Error -Message $Message
|
||||||
|
}
|
||||||
|
WriteLog "Copying dism log to $USBDrive"
|
||||||
|
Invoke-Process xcopy "X:\Windows\logs\dism\dism.log $USBDrive /Y"
|
||||||
|
WriteLog "Copying dism log to $USBDrive succeeded"
|
||||||
|
Read-Host "Press Enter to exit"
|
||||||
|
Exit
|
||||||
|
}
|
||||||
#Get USB Drive and create log file
|
#Get USB Drive and create log file
|
||||||
$LogFileName = 'ScriptLog.txt'
|
$LogFileName = 'ScriptLog.txt'
|
||||||
$USBDrive = Get-USBDrive
|
$USBDrive = Get-USBDrive
|
||||||
New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null
|
New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null
|
||||||
$LogFile = $USBDrive + $LogFilename
|
$LogFile = $USBDrive + $LogFilename
|
||||||
$version = '2404.1'
|
$version = '2507.1'
|
||||||
WriteLog 'Begin Logging'
|
WriteLog 'Begin Logging'
|
||||||
WriteLog "Script version: $version"
|
WriteLog "Script version: $version"
|
||||||
|
|
||||||
|
# Display banner and version
|
||||||
|
$banner = @"
|
||||||
|
|
||||||
|
███████╗███████╗██╗ ██╗ ██████╗ ██╗ ██╗██╗██╗ ██████╗ ███████╗██████╗
|
||||||
|
██╔════╝██╔════╝██║ ██║ ██╔══██╗██║ ██║██║██║ ██╔══██╗██╔════╝██╔══██╗
|
||||||
|
█████╗ █████╗ ██║ ██║ ██████╔╝██║ ██║██║██║ ██║ ██║█████╗ ██████╔╝
|
||||||
|
██╔══╝ ██╔══╝ ██║ ██║ ██╔══██╗██║ ██║██║██║ ██║ ██║██╔══╝ ██╔══██╗
|
||||||
|
██║ ██║ ╚██████╔╝ ██████╔╝╚██████╔╝██║███████╗██████╔╝███████╗██║ ██║
|
||||||
|
╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚══════╝╚═╝ ╚═╝
|
||||||
|
|
||||||
|
"@
|
||||||
|
Write-Host $banner -ForegroundColor Cyan
|
||||||
|
Write-Host "Version $version" -ForegroundColor Cyan
|
||||||
|
|
||||||
#Find PhysicalDrive
|
#Find PhysicalDrive
|
||||||
$PhysicalDeviceID = Get-HardDrive
|
# $PhysicalDeviceID = Get-HardDrive
|
||||||
|
$hardDrive = Get-HardDrive
|
||||||
|
if ($null -eq $hardDrive) {
|
||||||
|
$errorMessage = 'No hard drive found. You may need to add storage drivers to the WinPE image.'
|
||||||
|
WriteLog ($errorMessage + ' Exiting.')
|
||||||
|
WriteLog 'To add drivers, place them in the PEDrivers folder and re-run the creation script with -CopyPEDrivers $true, or add them manually via DISM.'
|
||||||
|
Stop-Script -Message $errorMessage
|
||||||
|
}
|
||||||
|
$PhysicalDeviceID = $hardDrive.DeviceID
|
||||||
|
$BytesPerSector = $hardDrive.BytesPerSector
|
||||||
WriteLog "Physical DeviceID is $PhysicalDeviceID"
|
WriteLog "Physical DeviceID is $PhysicalDeviceID"
|
||||||
|
|
||||||
#Parse DiskID Number
|
#Parse DiskID Number
|
||||||
$DiskID = $PhysicalDeviceID.substring($PhysicalDeviceID.length - 1, 1)
|
$DiskID = $PhysicalDeviceID.substring($PhysicalDeviceID.length - 1, 1)
|
||||||
WriteLog "DiskID is $DiskID"
|
WriteLog "DiskID is $DiskID"
|
||||||
|
|
||||||
#COMMENT THIS WHOLE BLOCK OUT ONCE FFUPROVIDER FIX IS IN
|
# Write System Information to console and log
|
||||||
# #Modify diskpart answer files if DiskID not 0
|
Write-SystemInformation -hardDrive $hardDrive
|
||||||
# # $UEFIFFUPartitions = 'x:\CreateUEFI-FFU-Partitions.txt'
|
|
||||||
# $ExtendPartition = 'x:\ExtendPartition-UEFI.txt'
|
|
||||||
|
|
||||||
# If ($DiskID -ne '0'){
|
|
||||||
# WriteLog 'DiskID is not 0. Need to modify diskpart answer files'
|
|
||||||
# # try {
|
|
||||||
# # Set-DiskpartAnswerFiles $UEFIFFUPartitions $DiskID
|
|
||||||
# # }
|
|
||||||
# # catch {
|
|
||||||
# # WriteLog "Modifying $UEFIFFUPartitions failed with error: $_"
|
|
||||||
# # }
|
|
||||||
|
|
||||||
# try {
|
|
||||||
# Set-DiskpartAnswerFiles $ExtendPartition $DiskID
|
|
||||||
# }
|
|
||||||
# catch {
|
|
||||||
# WriteLog "Modifying $ExtendPartition failed with error: $_"
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
|
|
||||||
#Find FFU Files
|
#Find FFU Files
|
||||||
|
Write-SectionHeader 'FFU File Selection'
|
||||||
[array]$FFUFiles = @(Get-ChildItem -Path $USBDrive*.ffu)
|
[array]$FFUFiles = @(Get-ChildItem -Path $USBDrive*.ffu)
|
||||||
$FFUCount = $FFUFiles.Count
|
$FFUCount = $FFUFiles.Count
|
||||||
|
|
||||||
#If multiple FFUs found, ask which to install
|
#If multiple FFUs found, ask which to install
|
||||||
If ($FFUCount -gt 1) {
|
If ($FFUCount -gt 1) {
|
||||||
WriteLog "Found $FFUCount FFU Files"
|
WriteLog "Found $FFUCount FFU Files"
|
||||||
|
Write-Host "Found $FFUCount FFU Files"
|
||||||
$array = @()
|
$array = @()
|
||||||
|
|
||||||
for ($i = 0; $i -le $FFUCount - 1; $i++) {
|
for ($i = 0; $i -le $FFUCount - 1; $i++) {
|
||||||
@@ -183,13 +280,15 @@ If ($FFUCount -gt 1) {
|
|||||||
}
|
}
|
||||||
elseif ($FFUCount -eq 1) {
|
elseif ($FFUCount -eq 1) {
|
||||||
WriteLog "Found $FFUCount FFU File"
|
WriteLog "Found $FFUCount FFU File"
|
||||||
|
Write-Host "Found $FFUCount FFU File"
|
||||||
$FFUFileToInstall = $FFUFiles[0].FullName
|
$FFUFileToInstall = $FFUFiles[0].FullName
|
||||||
WriteLog "$FFUFileToInstall will be installed"
|
WriteLog "$FFUFileToInstall will be installed"
|
||||||
|
Write-Host "$FFUFileToInstall will be installed"
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Writelog 'No FFU files found'
|
$errorMessage = 'No FFU files found.'
|
||||||
Write-Host 'No FFU files found'
|
Writelog $errorMessage
|
||||||
Exit
|
Stop-Script -Message $errorMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
#FindAP
|
#FindAP
|
||||||
@@ -217,6 +316,7 @@ if (Test-Path -Path $PPKGFolder){
|
|||||||
$UnattendFolder = $USBDrive + "unattend\"
|
$UnattendFolder = $USBDrive + "unattend\"
|
||||||
$UnattendFilePath = $UnattendFolder + "unattend.xml"
|
$UnattendFilePath = $UnattendFolder + "unattend.xml"
|
||||||
$UnattendPrefixPath = $UnattendFolder + "prefixes.txt"
|
$UnattendPrefixPath = $UnattendFolder + "prefixes.txt"
|
||||||
|
$UnattendComputerNamePath = $UnattendFolder + "SerialComputerNames.csv"
|
||||||
If (Test-Path -Path $UnattendFilePath) {
|
If (Test-Path -Path $UnattendFilePath) {
|
||||||
$UnattendFile = Get-ChildItem -Path $UnattendFilePath
|
$UnattendFile = Get-ChildItem -Path $UnattendFilePath
|
||||||
If ($UnattendFile) {
|
If ($UnattendFile) {
|
||||||
@@ -229,9 +329,16 @@ If (Test-Path -Path $UnattendPrefixPath){
|
|||||||
$UnattendPrefix = $true
|
$UnattendPrefix = $true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
If (Test-Path -Path $UnattendComputerNamePath) {
|
||||||
|
$UnattendComputerNameFile = Get-ChildItem -Path $UnattendComputerNamePath
|
||||||
|
If ($UnattendComputerNameFile) {
|
||||||
|
$UnattendComputerName = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Ask for device name if unattend exists
|
#Ask for device name if unattend exists
|
||||||
if ($Unattend -and $UnattendPrefix) {
|
if ($Unattend -and $UnattendPrefix) {
|
||||||
|
Write-SectionHeader 'Device Name Selection'
|
||||||
Writelog 'Unattend file found with prefixes.txt. Getting prefixes.'
|
Writelog 'Unattend file found with prefixes.txt. Getting prefixes.'
|
||||||
$UnattendPrefixes = @(Get-content $UnattendPrefixFile)
|
$UnattendPrefixes = @(Get-content $UnattendPrefixFile)
|
||||||
$UnattendPrefixCount = $UnattendPrefixes.Count
|
$UnattendPrefixCount = $UnattendPrefixes.Count
|
||||||
@@ -256,11 +363,14 @@ if ($Unattend -and $UnattendPrefix){
|
|||||||
} until (($PrefixSelected -le $UnattendPrefixCount - 1) -and $var)
|
} until (($PrefixSelected -le $UnattendPrefixCount - 1) -and $var)
|
||||||
$PrefixToUse = $array[$PrefixSelected].DeviceNamePrefix
|
$PrefixToUse = $array[$PrefixSelected].DeviceNamePrefix
|
||||||
WriteLog "$PrefixToUse was selected"
|
WriteLog "$PrefixToUse was selected"
|
||||||
|
Write-Host "`n$PrefixToUse was selected as device name prefix"
|
||||||
}
|
}
|
||||||
elseif ($UnattendPrefixCount -eq 1) {
|
elseif ($UnattendPrefixCount -eq 1) {
|
||||||
WriteLog "Found $UnattendPrefixCount Prefix"
|
WriteLog "Found $UnattendPrefixCount Prefix"
|
||||||
|
Write-Host "Found $UnattendPrefixCount Prefix"
|
||||||
$PrefixToUse = $UnattendPrefixes[0]
|
$PrefixToUse = $UnattendPrefixes[0]
|
||||||
WriteLog "Will use $PrefixToUse as device name prefix"
|
WriteLog "Will use $PrefixToUse as device name prefix"
|
||||||
|
Write-Host "Will use $PrefixToUse as device name prefix"
|
||||||
}
|
}
|
||||||
#Get serial number to append. This can make names longer than 15 characters. Trim any leading or trailing whitespace
|
#Get serial number to append. This can make names longer than 15 characters. Trim any leading or trailing whitespace
|
||||||
$serial = (Get-CimInstance -ClassName win32_bios).SerialNumber.Trim()
|
$serial = (Get-CimInstance -ClassName win32_bios).SerialNumber.Trim()
|
||||||
@@ -272,12 +382,38 @@ if ($Unattend -and $UnattendPrefix){
|
|||||||
}
|
}
|
||||||
$computername = Set-Computername($computername)
|
$computername = Set-Computername($computername)
|
||||||
Writelog "Computer name set to $computername"
|
Writelog "Computer name set to $computername"
|
||||||
|
Write-Host "Computer name set to $computername"
|
||||||
|
}
|
||||||
|
elseif ($Unattend -and $UnattendComputerName) {
|
||||||
|
Write-SectionHeader 'Device Name Selection'
|
||||||
|
Writelog 'Unattend file found with SerialComputerNames.csv. Getting name for current computer.'
|
||||||
|
$SerialComputerNames = Import-Csv -Path $UnattendComputerNameFile.FullName -Delimiter ","
|
||||||
|
|
||||||
|
$SerialNumber = (Get-CimInstance -Class Win32_Bios).SerialNumber
|
||||||
|
$SCName = $SerialComputerNames | Where-Object { $_.SerialNumber -eq $SerialNumber }
|
||||||
|
|
||||||
|
If ($SCName) {
|
||||||
|
[string]$computername = $SCName.ComputerName
|
||||||
|
$computername = Set-Computername($computername)
|
||||||
|
Writelog "Computer name set to $computername"
|
||||||
|
Write-Host "Computer name set to $computername"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Writelog 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
|
||||||
|
Write-Host 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
|
||||||
|
[string]$computername = ("FFU-" + ( -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 11 | ForEach-Object { [char]$_ })))
|
||||||
|
$computername = Set-Computername($computername)
|
||||||
|
Writelog "Computer name set to $computername"
|
||||||
|
Write-Host "Computer name set to $computername"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
elseif ($Unattend) {
|
elseif ($Unattend) {
|
||||||
Writelog 'Unattend file found with no prefixes.txt, asking for name'
|
Writelog 'Unattend file found with no prefixes.txt, asking for name'
|
||||||
|
Write-Host 'Unattend file found but no prefixes.txt. Please enter a device name.'
|
||||||
[string]$computername = Read-Host 'Enter device name'
|
[string]$computername = Read-Host 'Enter device name'
|
||||||
Set-Computername($computername)
|
Set-Computername($computername)
|
||||||
Writelog "Computer name set to $computername"
|
Writelog "Computer name set to $computername"
|
||||||
|
Write-Host "Computer name set to $computername"
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
WriteLog 'No unattend folder found. Device name will be set via PPKG, AP JSON, or default OS name.'
|
WriteLog 'No unattend folder found. Device name will be set via PPKG, AP JSON, or default OS name.'
|
||||||
@@ -345,6 +481,7 @@ else {
|
|||||||
|
|
||||||
#If multiple PPKG files found, ask which to install
|
#If multiple PPKG files found, ask which to install
|
||||||
If ($PPKGFilesCount -gt 1 -and $PPKG -eq $true) {
|
If ($PPKGFilesCount -gt 1 -and $PPKG -eq $true) {
|
||||||
|
Write-SectionHeader -Title 'Provisioning Package Selection'
|
||||||
WriteLog "Found $PPKGFilesCount PPKG Files"
|
WriteLog "Found $PPKGFilesCount PPKG Files"
|
||||||
$array = @()
|
$array = @()
|
||||||
|
|
||||||
@@ -368,151 +505,284 @@ If ($PPKGFilesCount -gt 1 -and $PPKG -eq $true) {
|
|||||||
|
|
||||||
$PPKGFileToInstall = $array[$PPKGFileSelected].PPKGFile
|
$PPKGFileToInstall = $array[$PPKGFileSelected].PPKGFile
|
||||||
WriteLog "$PPKGFileToInstall was selected"
|
WriteLog "$PPKGFileToInstall was selected"
|
||||||
|
Write-Host "`n$PPKGFileToInstall will be used"
|
||||||
}
|
}
|
||||||
elseif ($PPKGFilesCount -eq 1 -and $PPKG -eq $true) {
|
elseif ($PPKGFilesCount -eq 1 -and $PPKG -eq $true) {
|
||||||
|
Write-SectionHeader -Title 'Provisioning Package Selection'
|
||||||
WriteLog "Found $PPKGFilesCount PPKG File"
|
WriteLog "Found $PPKGFilesCount PPKG File"
|
||||||
|
Write-Host "Found $PPKGFilesCount PPKG File"
|
||||||
$PPKGFileToInstall = $PPKGFiles[0].FullName
|
$PPKGFileToInstall = $PPKGFiles[0].FullName
|
||||||
WriteLog "$PPKGFileToInstall will be used"
|
WriteLog "$PPKGFileToInstall will be used"
|
||||||
|
Write-Host "`n$PPKGFileToInstall will be used"
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Writelog 'No PPKG files found or PPKG not selected.'
|
Writelog 'No PPKG files found or PPKG not selected.'
|
||||||
}
|
}
|
||||||
|
|
||||||
#Find Drivers
|
#Find Drivers
|
||||||
$Drivers = $USBDrive + "Drivers"
|
$DriversPath = $USBDrive + "Drivers"
|
||||||
If (Test-Path -Path $Drivers)
|
$DriverSourcePath = $null
|
||||||
{
|
$DriverSourceType = $null # Will be 'WIM' or 'Folder'
|
||||||
#Check if multiple driver folders found, if so, just select one folder to save time/space
|
$driverMappingPath = Join-Path -Path $DriversPath -ChildPath "DriverMapping.json"
|
||||||
$DriverFolders = Get-ChildItem -Path $Drivers -directory
|
|
||||||
$DriverFoldersCount = $DriverFolders.count
|
|
||||||
If ($DriverFoldersCount -gt 1)
|
|
||||||
{
|
|
||||||
WriteLog "Found $DriverFoldersCount driver folders"
|
|
||||||
$array = @()
|
|
||||||
|
|
||||||
for($i=0; $i -le $DriverFoldersCount -1; $i++){
|
If (Test-Path -Path $DriversPath) {
|
||||||
$Properties = [ordered]@{Number = $i + 1; Drivers = $DriverFolders[$i].FullName}
|
Write-SectionHeader -Title 'Drivers Selection'
|
||||||
$array += New-Object PSObject -Property $Properties
|
|
||||||
}
|
}
|
||||||
$array | Format-Table -AutoSize -Property Number, Drivers
|
|
||||||
|
# --- Automatic Driver Detection using DriverMapping.json ---
|
||||||
|
if (Test-Path -Path $driverMappingPath -PathType Leaf) {
|
||||||
|
WriteLog "DriverMapping.json found at $driverMappingPath. Attempting automatic driver selection."
|
||||||
|
Write-Host "DriverMapping.json found. Attempting automatic driver selection."
|
||||||
|
try {
|
||||||
|
# Get system information
|
||||||
|
$systemManufacturer = (Get-CimInstance -Class Win32_ComputerSystem).Manufacturer
|
||||||
|
# Lenovo uses a different property for the model name
|
||||||
|
$systemModel = if ($systemManufacturer -like '*LENOVO*') {
|
||||||
|
(Get-CimInstance -Class Win32_ComputerSystemProduct).Version
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
(Get-CimInstance -Class Win32_ComputerSystem).Model
|
||||||
|
}
|
||||||
|
WriteLog "Detected System: Manufacturer='$systemManufacturer', Model='$systemModel'"
|
||||||
|
|
||||||
|
# Load and parse the mapping file, ensuring it's always an array
|
||||||
|
$driverMappings = @(Get-Content -Path $driverMappingPath -Raw | ConvertFrom-Json -ErrorAction SilentlyContinue)
|
||||||
|
|
||||||
|
# Find all matching rules and select the most specific one
|
||||||
|
$matchingRules = @()
|
||||||
|
foreach ($rule in $driverMappings) {
|
||||||
|
# Use -like for wildcard matching.
|
||||||
|
# This checks if the system model starts with the rule model, or vice-versa, for flexibility.
|
||||||
|
if ($systemManufacturer -like "$($rule.Manufacturer)*" -and ($systemModel -like "$($rule.Model)*" -or $rule.Model -like "$systemModel*")) {
|
||||||
|
$matchingRules += $rule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Select the best match
|
||||||
|
$matchedRule = $null
|
||||||
|
if ($matchingRules.Count -gt 0) {
|
||||||
|
WriteLog "Found $($matchingRules.Count) potential driver mapping rule(s)."
|
||||||
|
Write-Host "Found $($matchingRules.Count) potential driver mapping rule(s)."
|
||||||
|
foreach ($rule in $matchingRules) {
|
||||||
|
WriteLog " - Potential Match: Manufacturer='$($rule.Manufacturer)', Model='$($rule.Model)', Path='$($rule.DriverPath)'"
|
||||||
|
Write-Host " - Potential Match: Manufacturer='$($rule.Manufacturer)', Model='$($rule.Model)', Path='$($rule.DriverPath)'"
|
||||||
|
|
||||||
|
}
|
||||||
|
# Sort by model name length, descending, to find the most specific match
|
||||||
|
$matchedRule = $matchingRules | Sort-Object -Property @{Expression = { $_.Model.Length } } -Descending | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $matchedRule) {
|
||||||
|
WriteLog "Automatic match found: Manufacturer='$($matchedRule.Manufacturer)', Model='$($matchedRule.Model)'"
|
||||||
|
Write-Host "Automatic match found: Manufacturer='$($matchedRule.Manufacturer)', Model='$($matchedRule.Model)'"
|
||||||
|
$potentialDriverPath = Join-Path -Path $DriversPath -ChildPath $matchedRule.DriverPath
|
||||||
|
|
||||||
|
if (Test-Path -Path $potentialDriverPath) {
|
||||||
|
$DriverSourcePath = $potentialDriverPath
|
||||||
|
# Determine if it's a WIM or a Folder
|
||||||
|
if ($DriverSourcePath -like '*.wim') {
|
||||||
|
$DriverSourceType = 'WIM'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$DriverSourceType = 'Folder'
|
||||||
|
}
|
||||||
|
WriteLog "Automatically selected driver source. Type: $DriverSourceType, Path: $DriverSourcePath"
|
||||||
|
Write-Host "Automatically selected driver source. Type: $DriverSourceType, Path: $DriverSourcePath"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Matched driver path '$potentialDriverPath' not found. Falling back to manual selection."
|
||||||
|
Write-Host "Matched driver path '$potentialDriverPath' not found. Falling back to manual selection."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "No matching driver rule found in DriverMapping.json for this system. Falling back to manual selection."
|
||||||
|
Write-Host "No matching driver rule found in DriverMapping.json for this system. Falling back to manual selection."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "An error occurred during automatic driver detection: $($_.Exception.Message). Falling back to manual selection."
|
||||||
|
Write-Host "An error occurred during automatic driver detection: $($_.Exception.Message). Falling back to manual selection."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "DriverMapping.json not found. Proceeding with manual driver selection."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Manual Driver Selection (Fallback) ---
|
||||||
|
if ($null -eq $DriverSourcePath) {
|
||||||
|
If (Test-Path -Path $DriversPath) {
|
||||||
|
WriteLog "Searching for driver WIMs and folders in $DriversPath"
|
||||||
|
|
||||||
|
# Get all WIM files
|
||||||
|
$WimFiles = Get-ChildItem -Path $DriversPath -Filter *.wim -Recurse
|
||||||
|
|
||||||
|
# Get all top-level driver folders
|
||||||
|
$DriverFolders = Get-ChildItem -Path $DriversPath -Directory
|
||||||
|
|
||||||
|
# Create a combined list
|
||||||
|
$DriverSources = @()
|
||||||
|
$WimFiles | ForEach-Object {
|
||||||
|
$DriverSources += [PSCustomObject]@{
|
||||||
|
Type = 'WIM'
|
||||||
|
Path = $_.FullName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$DriverFolders | ForEach-Object {
|
||||||
|
$DriverSources += [PSCustomObject]@{
|
||||||
|
Type = 'Folder'
|
||||||
|
Path = $_.FullName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$DriverSourcesCount = $DriverSources.Count
|
||||||
|
|
||||||
|
if ($DriverSourcesCount -gt 0) {
|
||||||
|
WriteLog "Found $DriverSourcesCount total driver sources (WIMs and folders)."
|
||||||
|
if ($DriverSourcesCount -eq 1) {
|
||||||
|
$DriverSourcePath = $DriverSources[0].Path
|
||||||
|
$DriverSourceType = $DriverSources[0].Type
|
||||||
|
WriteLog "Single driver source found. Type: $DriverSourceType, Path: $DriverSourcePath"
|
||||||
|
Write-Host "Single driver source found. Type: $DriverSourceType, Path: $DriverSourcePath"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Multiple sources found, prompt user
|
||||||
|
WriteLog "Multiple driver sources found. Prompting for selection."
|
||||||
|
$displayArray = @()
|
||||||
|
for ($i = 0; $i -lt $DriverSourcesCount; $i++) {
|
||||||
|
$displayArray += [PSCustomObject]@{
|
||||||
|
Number = $i + 1
|
||||||
|
Type = $DriverSources[$i].Type
|
||||||
|
Path = $DriverSources[$i].Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$displayArray | Format-Table -AutoSize
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try {
|
try {
|
||||||
$var = $true
|
$var = $true
|
||||||
[int]$DriversSelected = Read-Host 'Enter the set of drivers to install'
|
[int]$DriverSelected = Read-Host 'Enter the number of the driver source to install'
|
||||||
$DriversSelected = $DriversSelected - 1
|
$DriverSelected = $DriverSelected - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
catch {
|
catch {
|
||||||
Write-Host 'Input was not in correct format. Please enter a valid driver folder number'
|
Write-Host 'Input was not in correct format. Please enter a valid number.'
|
||||||
$var = $false
|
$var = $false
|
||||||
}
|
}
|
||||||
} until (($DriversSelected -le $DriverFoldersCount -1) -and $var)
|
} until (($DriverSelected -ge 0) -and ($DriverSelected -lt $DriverSourcesCount) -and $var)
|
||||||
|
|
||||||
$Drivers = $array[$DriversSelected].Drivers
|
$DriverSourcePath = $DriverSources[$DriverSelected].Path
|
||||||
WriteLog "$Drivers was selected"
|
$DriverSourceType = $DriverSources[$DriverSelected].Type
|
||||||
|
WriteLog "User selected Type: $DriverSourceType, Path: $DriverSourcePath"
|
||||||
|
Write-Host "`nUser selected Type: $DriverSourceType, Path: $DriverSourcePath"
|
||||||
}
|
}
|
||||||
elseif ($DriverFoldersCount -eq 1) {
|
|
||||||
WriteLog "Found $DriverFoldersCount driver folder"
|
|
||||||
$Drivers = $DriverFolders.FullName
|
|
||||||
WriteLog "$Drivers will be installed"
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Writelog 'No driver folders found'
|
WriteLog "No driver WIMs or folders found in Drivers directory."
|
||||||
|
Write-Host "No driver WIMs or folders found in Drivers directory."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "Drivers folder not found at $DriversPath. Skipping driver installation."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#If you want to enable battery level checking, uncomment the line below as well as the Get-Battery function near the top of the script
|
|
||||||
#Get-Battery
|
|
||||||
|
|
||||||
#Partition drive
|
#Partition drive
|
||||||
Writelog 'Clean Disk'
|
Writelog 'Clean Disk'
|
||||||
#Start-Process -FilePath diskpart.exe -ArgumentList "/S $UEFIFFUPartitions" -Wait -ErrorAction Stop | Out-File $Logfile -Append
|
$originalProgressPreference = $ProgressPreference
|
||||||
#Invoke-Process diskpart.exe "/S $UEFIFFUPartitions"
|
|
||||||
try {
|
try {
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
$Disk = Get-Disk -Number $DiskID
|
$Disk = Get-Disk -Number $DiskID
|
||||||
|
if ($Disk.PartitionStyle -ne "RAW") {
|
||||||
$Disk | clear-disk -RemoveData -RemoveOEM -Confirm:$false
|
$Disk | clear-disk -RemoveData -RemoveOEM -Confirm:$false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch {
|
catch {
|
||||||
WriteLog 'Cleaning disk failed. Exiting'
|
WriteLog 'Cleaning disk failed. Exiting'
|
||||||
throw $_
|
throw $_
|
||||||
}
|
}
|
||||||
|
finally {
|
||||||
|
$ProgressPreference = $originalProgressPreference
|
||||||
|
}
|
||||||
|
|
||||||
Writelog 'Cleaning Disk succeeded'
|
Writelog 'Cleaning Disk succeeded'
|
||||||
|
|
||||||
#Apply FFU
|
#Apply FFU
|
||||||
|
Write-SectionHeader -Title 'Applying FFU'
|
||||||
WriteLog "Applying FFU to $PhysicalDeviceID"
|
WriteLog "Applying FFU to $PhysicalDeviceID"
|
||||||
WriteLog "Running command dism /apply-ffu /ImageFile:$FFUFileToInstall /ApplyDrive:$PhysicalDeviceID"
|
WriteLog "Running command dism /apply-ffu /ImageFile:$FFUFileToInstall /ApplyDrive:$PhysicalDeviceID"
|
||||||
#In order for Applying Image progress bar to show up, need to call dism directly. Might be a better way to handle, but must have progress bar show up on screen.
|
#In order for Applying Image progress bar to show up, need to call dism directly. Might be a better way to handle, but must have progress bar show up on screen.
|
||||||
dism /apply-ffu /ImageFile:$FFUFileToInstall /ApplyDrive:$PhysicalDeviceID
|
dism /apply-ffu /ImageFile:$FFUFileToInstall /ApplyDrive:$PhysicalDeviceID
|
||||||
if($LASTEXITCODE -eq 0){
|
$dismExitCode = $LASTEXITCODE
|
||||||
WriteLog 'Successfully applied FFU'
|
|
||||||
|
if ($dismExitCode -ne 0) {
|
||||||
|
$errorMessage = "Failed to apply FFU. LastExitCode = $dismExitCode."
|
||||||
|
if ($dismExitCode -eq 1393) {
|
||||||
|
WriteLog "Failed to apply FFU - LastExitCode = $dismExitCode"
|
||||||
|
WriteLog "This is likely due to a mismatched LogicalSectorSizeBytes"
|
||||||
|
WriteLog "BytesPerSector value from Win32_Diskdrive is $BytesPerSector"
|
||||||
|
if ($BytesPerSector -eq 4096) {
|
||||||
|
WriteLog "The FFU build process by default uses a 512 LogicalSectorSizeBytes. Rebuild the FFU by adding -LogicalSectorSizeBytes 4096 to the command line"
|
||||||
|
}
|
||||||
|
elseif ($BytesPerSector -eq 512) {
|
||||||
|
WriteLog "This FFU was likely built with a LogicalSectorSizeBytes of 4096. Rebuild the FFU by adding -LogicalSectorSizeBytes 512 to the command line"
|
||||||
|
}
|
||||||
|
$errorMessage += " This is likely due to a mismatched logical sector size. Check logs for details."
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Writelog "Failed to apply FFU - LastExitCode = $LASTEXITCODE also check dism.log on the USB drive for more info"
|
Writelog "Failed to apply FFU - LastExitCode = $dismExitCode also check dism.log on the USB drive for more info"
|
||||||
#Copy DISM log to USBDrive
|
$errorMessage += " Check dism.log on the USB drive for more info."
|
||||||
invoke-process xcopy.exe "X:\Windows\logs\dism\dism.log $USBDrive /Y"
|
}
|
||||||
exit
|
Stop-Script -Message $errorMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
#Remove recovery partition - this is needed in order to extend the Windows partition so it uses the full disk size. If dism /optimize-ffu worked, this wouldn't be needed
|
WriteLog 'Successfully applied FFU'
|
||||||
# $disk = get-disk -Number $DiskID
|
|
||||||
# $RecoveryPartition = $disk | get-partition | Where-Object {$_.type -eq 'Recovery'}
|
|
||||||
# if ($RecoveryPartition){
|
|
||||||
# $RecoveryPartitionNumber = $RecoveryPartition.PartitionNumber
|
|
||||||
# if ($RecoveryPartitionNumber -eq 4){
|
|
||||||
# try {
|
|
||||||
# WriteLog 'Removing recovery partition'
|
|
||||||
# Remove-partition -DiskNumber $DiskID -PartitionNumber $RecoveryPartitionNumber -Confirm:$false
|
|
||||||
# }
|
|
||||||
# catch {
|
|
||||||
# WriteLog 'Error removing recovery partition, exiting'
|
|
||||||
# throw $_
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
# else{
|
|
||||||
# WriteLog 'Recovery partition not partition 4. Script will exit. Please create the FFU with the recovery partition as the last partition. This is the default and recommended way.'
|
|
||||||
# exit
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
|
|
||||||
#COMMENT THIS WHOLE BLOCK OUT AFTER FFUPROVIDER FIX IS IN
|
# Verify Windows partition exists and assign drive letter
|
||||||
# # Extend Windows partition and create recovery partition
|
$windowsPartition = Get-Partition -DiskNumber $DiskID | Where-Object { $_.PartitionNumber -eq 3 }
|
||||||
# Writelog 'Extending Windows partition'
|
if ($null -eq $windowsPartition) {
|
||||||
# Invoke-Process diskpart.exe "/S $ExtendPartition"
|
$errorMessage = "Windows partition (Partition 3) not found after applying FFU, even though DISM reported success."
|
||||||
# if($LASTEXITCODE -eq 0){
|
WriteLog $errorMessage
|
||||||
# WriteLog 'Successfully extended Windows partition and created recovery partition'
|
Stop-Script -Message $errorMessage
|
||||||
# }
|
}
|
||||||
# else{
|
|
||||||
# Writelog "Failed to extend Windows partition and/or create recovery partition - LastExitCode = $LASTEXITCODE"
|
|
||||||
# }
|
|
||||||
|
|
||||||
#UNCOMMENT THIS AFTER FFUPROVIDER FIX IS IN
|
WriteLog "Assigning drive letter 'W' to Windows partition."
|
||||||
# Set W: drive letter to Windows partition
|
Set-Partition -InputObject $windowsPartition -NewDriveLetter W
|
||||||
Get-Disk | Where-Object Number -eq $DiskID | Get-Partition | Where-Object PartitionNumber -eq 3 | Set-Partition -NewDriveLetter W
|
|
||||||
|
# Verify the drive letter was set
|
||||||
|
$windowsVolume = Get-Volume -DriveLetter W -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $windowsVolume) {
|
||||||
|
$errorMessage = "Failed to assign drive letter 'W' to the Windows partition after applying FFU."
|
||||||
|
WriteLog $errorMessage
|
||||||
|
Stop-Script -Message $errorMessage
|
||||||
|
}
|
||||||
|
WriteLog "Successfully assigned drive letter 'W'."
|
||||||
|
|
||||||
|
$recoveryPartition = Get-Partition -DiskNumber $DiskID | Where-Object PartitionNumber -eq 4
|
||||||
|
if ($recoveryPartition) {
|
||||||
|
WriteLog 'Setting recovery partition attributes'
|
||||||
|
$diskpartScript = @(
|
||||||
|
"SELECT DISK $($Disk.Number)",
|
||||||
|
"SELECT PARTITION $($recoveryPartition.PartitionNumber)",
|
||||||
|
"GPT ATTRIBUTES=0x8000000000000001",
|
||||||
|
"EXIT"
|
||||||
|
)
|
||||||
|
$diskpartScript | diskpart.exe | Out-Null
|
||||||
|
WriteLog 'Setting recovery partition attributes complete'
|
||||||
|
}
|
||||||
|
|
||||||
#Copy modified WinRE if folder exists, else copy inbox WinRE
|
#Copy modified WinRE if folder exists, else copy inbox WinRE
|
||||||
$WinRE = $USBDrive + "WinRE\winre.wim"
|
$WinRE = $USBDrive + "WinRE\winre.wim"
|
||||||
If (Test-Path -Path $WinRE)
|
If (Test-Path -Path $WinRE) {
|
||||||
{
|
|
||||||
WriteLog 'Copying modified WinRE to Recovery directory'
|
WriteLog 'Copying modified WinRE to Recovery directory'
|
||||||
|
Get-Disk | Where-Object Number -eq $DiskID | Get-Partition | Where-Object Type -eq Recovery | Set-Partition -NewDriveLetter R
|
||||||
Invoke-Process xcopy.exe "/h $WinRE R:\Recovery\WindowsRE\ /Y"
|
Invoke-Process xcopy.exe "/h $WinRE R:\Recovery\WindowsRE\ /Y"
|
||||||
WriteLog 'Copying WinRE to Recovery directory succeeded'
|
WriteLog 'Copying WinRE to Recovery directory succeeded'
|
||||||
WriteLog 'Registering location of recovery tools'
|
WriteLog 'Registering location of recovery tools'
|
||||||
Invoke-Process W:\Windows\System32\Reagentc.exe "/Setreimage /Path R:\Recovery\WindowsRE /Target W:\Windows"
|
Invoke-Process W:\Windows\System32\Reagentc.exe "/Setreimage /Path R:\Recovery\WindowsRE /Target W:\Windows"
|
||||||
|
Get-Disk | Where-Object Number -eq $DiskID | Get-Partition | Where-Object Type -eq Recovery | Remove-PartitionAccessPath -AccessPath R:
|
||||||
WriteLog 'Registering location of recovery tools succeeded'
|
WriteLog 'Registering location of recovery tools succeeded'
|
||||||
}
|
}
|
||||||
# else
|
|
||||||
# {
|
|
||||||
# WriteLog 'Copying default WinRE to Recovery directory'
|
|
||||||
# Invoke-Process xcopy.exe "/h W:\Windows\System32\Recovery\Winre.wim R:\Recovery\WindowsRE\ /Y"
|
|
||||||
# WriteLog 'Copying WinRE to Recovery directory succeeded'
|
|
||||||
# WriteLog 'Registering location of recovery tools'
|
|
||||||
# Invoke-process W:\Windows\System32\Reagentc.exe "/Setreimage /Path R:\Recovery\WindowsRE /Target W:\Windows"
|
|
||||||
# WriteLog 'Registering location of recovery tools succeeded'
|
|
||||||
# }
|
|
||||||
|
|
||||||
#Autopilot JSON
|
#Autopilot JSON
|
||||||
If ($APFileToInstall) {
|
If ($APFileToInstall) {
|
||||||
|
Write-SectionHeader -Title 'Applying Autopilot Configuration'
|
||||||
WriteLog "Copying $APFileToInstall to W:\windows\provisioning\autopilot"
|
WriteLog "Copying $APFileToInstall to W:\windows\provisioning\autopilot"
|
||||||
Invoke-process xcopy.exe "$APFileToInstall W:\Windows\provisioning\autopilot\"
|
Invoke-process xcopy.exe "$APFileToInstall W:\Windows\provisioning\autopilot\"
|
||||||
WriteLog "Copying $APFileToInstall to W:\windows\provisioning\autopilot succeeded"
|
WriteLog "Copying $APFileToInstall to W:\windows\provisioning\autopilot succeeded"
|
||||||
@@ -529,54 +799,115 @@ If ($APFileToInstall){
|
|||||||
}
|
}
|
||||||
#Apply PPKG
|
#Apply PPKG
|
||||||
If ($PPKGFileToInstall) {
|
If ($PPKGFileToInstall) {
|
||||||
|
Write-SectionHeader -Title 'Applying Provisioning Package'
|
||||||
try {
|
try {
|
||||||
#Make sure to delete any existing PPKG on the USB drive
|
#Make sure to delete any existing PPKG on the USB drive
|
||||||
Get-Childitem -Path $USBDrive\*.ppkg | ForEach-Object {
|
Get-Childitem -Path $USBDrive\*.ppkg | ForEach-Object {
|
||||||
Remove-item -Path $_.FullName
|
Remove-item -Path $_.FullName
|
||||||
}
|
}
|
||||||
WriteLog "Copying $PPKGFileToInstall to $USBDrive"
|
WriteLog "Copying $PPKGFileToInstall to $USBDrive"
|
||||||
|
Write-Host "Copying $PPKGFileToInstall to $USBDrive"
|
||||||
Invoke-process xcopy.exe "$PPKGFileToInstall $USBDrive"
|
Invoke-process xcopy.exe "$PPKGFileToInstall $USBDrive"
|
||||||
WriteLog "Copying $PPKGFileToInstall to $USBDrive succeeded"
|
WriteLog "Copying $PPKGFileToInstall to $USBDrive succeeded"
|
||||||
|
Write-Host "Copying $PPKGFileToInstall to $USBDrive succeeded"
|
||||||
}
|
}
|
||||||
|
|
||||||
catch {
|
catch {
|
||||||
Writelog "Copying $PPKGFileToInstall to $USBDrive failed with error: $_"
|
Writelog "Copying $PPKGFileToInstall to $USBDrive failed with error: $_"
|
||||||
|
Write-Host "Copying $PPKGFileToInstall to $USBDrive failed with error: $_"
|
||||||
throw $_
|
throw $_
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#Set DeviceName
|
#Set DeviceName
|
||||||
If ($computername) {
|
If ($computername) {
|
||||||
|
Write-SectionHeader -Title 'Applying Computer Name and Unattend Configuration'
|
||||||
try {
|
try {
|
||||||
$PantherDir = 'w:\windows\panther'
|
$PantherDir = 'w:\windows\panther'
|
||||||
If (Test-Path -Path $PantherDir) {
|
If (Test-Path -Path $PantherDir) {
|
||||||
Writelog "Copying $UnattendFile to $PantherDir"
|
Writelog "Copying $UnattendFile to $PantherDir"
|
||||||
|
Write-Host "Copying $UnattendFile to $PantherDir"
|
||||||
Invoke-process xcopy "$UnattendFile $PantherDir /Y"
|
Invoke-process xcopy "$UnattendFile $PantherDir /Y"
|
||||||
WriteLog "Copying $UnattendFile to $PantherDir succeeded"
|
WriteLog "Copying $UnattendFile to $PantherDir succeeded"
|
||||||
|
Write-Host "Copying $UnattendFile to $PantherDir succeeded"
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Writelog "$PantherDir doesn't exist, creating it"
|
Writelog "$PantherDir doesn't exist, creating it"
|
||||||
New-Item -Path $PantherDir -ItemType Directory -Force
|
New-Item -Path $PantherDir -ItemType Directory -Force
|
||||||
Writelog "Copying $UnattendFile to $PantherDir"
|
Writelog "Copying $UnattendFile to $PantherDir"
|
||||||
|
Write-Host "Copying $UnattendFile to $PantherDir"
|
||||||
Invoke-Process xcopy.exe "$UnattendFile $PantherDir"
|
Invoke-Process xcopy.exe "$UnattendFile $PantherDir"
|
||||||
WriteLog "Copying $UnattendFile to $PantherDir succeeded"
|
WriteLog "Copying $UnattendFile to $PantherDir succeeded"
|
||||||
|
Write-Host "Copying $UnattendFile to $PantherDir succeeded"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
WriteLog "Copying Unattend.xml to name device failed"
|
WriteLog "Copying Unattend.xml to name device failed"
|
||||||
throw $_
|
Stop-Script -Message "Copying Unattend.xml to name device failed with error: $_"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Add Drivers
|
#Add Drivers
|
||||||
#Some drivers can sometimes fail to copy and dism ends up with a non-zero error code. Invoke-process will throw and terminate in these instances.
|
if ($null -ne $DriverSourcePath) {
|
||||||
If (Test-Path -Path $Drivers)
|
Write-SectionHeader -Title 'Installing Drivers'
|
||||||
{
|
if ($DriverSourceType -eq 'WIM') {
|
||||||
WriteLog 'Copying drivers'
|
WriteLog "Installing drivers from WIM: $DriverSourcePath"
|
||||||
Write-Warning 'Copying Drivers - dism will pop a window with no progress. This can take a few minutes to complete. This is done so drivers are logged to the scriptlog.txt file. Please be patient.'
|
Write-Host "Installing drivers from WIM: $DriverSourcePath"
|
||||||
Invoke-process dism.exe "/image:W:\ /Add-Driver /Driver:""$Drivers"" /Recurse"
|
$TempDriverDir = "W:\TempDrivers"
|
||||||
WriteLog 'Copying drivers succeeded'
|
try {
|
||||||
}
|
WriteLog "Creating temporary directory for drivers at $TempDriverDir"
|
||||||
|
New-Item -Path $TempDriverDir -ItemType Directory -Force | Out-Null
|
||||||
|
|
||||||
|
WriteLog "Mounting WIM contents to $TempDriverDir"
|
||||||
|
Write-Host "Mounting WIM contents to $TempDriverDir"
|
||||||
|
# For some reason can't use /mount-image with invoke-process, so using dism.exe directly
|
||||||
|
dism.exe /Mount-Image /ImageFile:$DriverSourcePath /Index:1 /MountDir:$TempDriverDir /ReadOnly /optimize
|
||||||
|
WriteLog "WIM mount successful."
|
||||||
|
|
||||||
|
WriteLog "Injecting drivers from $TempDriverDir"
|
||||||
|
Write-Host "Injecting drivers from $TempDriverDir"
|
||||||
|
Write-Host "This may take a while, please be patient."
|
||||||
|
Invoke-Process dism.exe "/image:W:\ /Add-Driver /Driver:""$TempDriverDir"" /Recurse"
|
||||||
|
WriteLog "Driver injection from WIM succeeded."
|
||||||
|
Write-Host "Driver injection from WIM succeeded."
|
||||||
|
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
WriteLog "An error occurred during WIM driver installation: $_"
|
||||||
|
# Copy DISM log to USBDrive for debugging
|
||||||
|
invoke-process xcopy.exe "X:\Windows\logs\dism\dism.log $USBDrive /Y"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Test-Path -Path $TempDriverDir) {
|
||||||
|
WriteLog "Unmounting WIM from $TempDriverDir"
|
||||||
|
Write-Host "Unmounting WIM from $TempDriverDir"
|
||||||
|
Invoke-Process dism.exe "/Unmount-Image /MountDir:""$TempDriverDir"" /Discard"
|
||||||
|
WriteLog "Unmount successful."
|
||||||
|
Write-Host "Unmount successful."
|
||||||
|
WriteLog "Cleaning up temporary driver directory: $TempDriverDir"
|
||||||
|
Write-Host "Cleaning up temporary driver directory: $TempDriverDir"
|
||||||
|
Remove-Item -Path $TempDriverDir -Recurse -Force
|
||||||
|
WriteLog "Cleanup successful."
|
||||||
|
Write-Host "Cleanup successful."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($DriverSourceType -eq 'Folder') {
|
||||||
|
WriteLog "Injecting drivers from folder: $DriverSourcePath"
|
||||||
|
Invoke-Process dism.exe "/image:W:\ /Add-Driver /Driver:""$DriverSourcePath"" /Recurse"
|
||||||
|
WriteLog "Driver injection from folder succeeded."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
WriteLog "No drivers to install."
|
||||||
|
}
|
||||||
|
Write-SectionHeader -Title 'Setting Boot Configuration'
|
||||||
|
WriteLog "Setting Windows Boot Manager to be first in the firmware display order."
|
||||||
|
Write-Host "Setting Windows Boot Manager to be first in the firmware display order."
|
||||||
|
Invoke-Process bcdedit.exe "/set {fwbootmgr} displayorder {bootmgr} /addfirst"
|
||||||
|
WriteLog "Setting Windows Boot Manager to be first in the default display order."
|
||||||
|
Write-Host "Setting Windows Boot Manager to be first in the default display order."
|
||||||
|
Invoke-Process bcdedit.exe "/set {bootmgr} displayorder {default} /addfirst"
|
||||||
#Copy DISM log to USBDrive
|
#Copy DISM log to USBDrive
|
||||||
WriteLog "Copying dism log to $USBDrive"
|
WriteLog "Copying dism log to $USBDrive"
|
||||||
invoke-process xcopy "X:\Windows\logs\dism\dism.log $USBDrive /Y"
|
invoke-process xcopy "X:\Windows\logs\dism\dism.log $USBDrive /Y"
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
select disk 0
|
|
||||||
select partition 3
|
|
||||||
Assign letter="W"
|
|
||||||
shrink minimum=1000
|
|
||||||
create partition primary
|
|
||||||
format quick fs=ntfs label="Recovery"
|
|
||||||
assign letter="R"
|
|
||||||
set id="de94bba4-06d1-4d40-a16a-bfd50179d6ac"
|
|
||||||
gpt attributes=0x8000000000000001
|
|
||||||
exit
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
wpeinit
|
@echo off
|
||||||
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
|
wpeinit > NUL
|
||||||
|
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c > NUL
|
||||||
powershell -Noprofile -ExecutionPolicy Bypass -File x:\ApplyFFU.ps1
|
powershell -Noprofile -ExecutionPolicy Bypass -File x:\ApplyFFU.ps1
|
||||||
exit
|
exit
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?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">
|
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||||
<settings pass="specialize">
|
<settings pass="specialize">
|
||||||
|
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
|
||||||
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||||
<ComputerName>MyComputer</ComputerName>
|
<ComputerName>MyComputer</ComputerName>
|
||||||
</component>
|
</component>
|
||||||
|
<!--Place addtional Components Elements and settings below here. -->
|
||||||
</settings>
|
</settings>
|
||||||
</unattend>
|
</unattend>
|
||||||
@@ -1,120 +1,72 @@
|
|||||||
# Using Full Flash Update (FFU) files to speed up Windows deployment
|
# 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
|
# Updates
|
||||||
|
|
||||||
**2404.1**
|
2507.1 has been released to preview! This is a major update that brings a new user interface to preview.
|
||||||
|
|
||||||
There's a big change with this release related to the ADK. The ADK will now be automatically updated to the latest ADK release. This is required in order to fix an issue with optimized FFUs not applying due to an issue with DISM/FFUProvider.dll. The FFUProvider.dll fix was added to the Sept 2023 ADK. Since we now have the ability to auto upgrade the ADK, I'm more confident in having the BuildFFUVM script creating a complete FFU now (prior it was only creating 3 partitions instead of 4 with the recovery partition - at deployment time, the ApplyFFU.ps1 script would create an empty recovery parition). Please open an issue if this creates a problem for you. I do realize that any new ADK release can have it's own challenges and issues and I do suspect we'll see a new ADK released later this year.
|
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.
|
||||||
|
|
||||||
- Allow for ISOs with single index WIMs to work [Issue 10](https://github.com/rbalsleyMSFT/FFU/issues/10) - [Commit](https://github.com/rbalsleyMSFT/FFU/commit/9e2da741d53652e6e600ca19cfd38f507bd01fde)
|
|
||||||
- Added more robust ADK handling. Will now check for the latest ADK and download it if not installed. Thanks to [Zehadi Alam](https://github.com/zehadialam) [PR 18](https://github.com/rbalsleyMSFT/FFU/pull/18)
|
|
||||||
- Revert code back to allow optimized FFUs to be applied via ApplyFFU.ps1 now that Sept 2023 ADK release has FFUProvider.dll fix. [Commit](https://github.com/rbalsleyMSFT/FFU/commit/79364e334d6d09ff150e70dab7bfb2637d0ad8a8)
|
|
||||||
- Changed how the script searches for the latest CU. Instead of relying on the Windows release info page to grab the KB number, will just use the MU Catalog, the same as what we do for the .NET Framework. Windows release info page is updated manually and is unknown as to when it will be updated. [Commit](https://github.com/rbalsleyMSFT/FFU/commit/6fd5a4a41fd9ce2f842f43dc3a69bda264c29fa6)
|
|
||||||
- Added fix to not allow computer names with spaces. Thanks to [JoeMama54 (Rob)](https://github.com/JoeMama54) [PR 20](https://github.com/rbalsleyMSFT/FFU/pull/20)
|
|
||||||
|
|
||||||
**2403.1**
|
|
||||||
|
|
||||||
Fixed an issue with the SecurityHealthSetup.exe file giving an error when building the VM if -UpdateLatestDefender was set to $true. A new update for this came out on 3/21 which included a x64 and ARM64 binary. This file doesn't have an architecture designation to it, so it's impossible to know which file is for which architecture. Investigating to see if we can fix this in the Microsoft Update catalog. There is a web site to pull this from, but the support article is out of date.
|
|
||||||
|
|
||||||
Included ADK functions from Zehadi Alam [Introduce Automated ADK Retrieval and Installation Functions #14](https://github.com/rbalsleyMSFT/FFU/pull/14) to automate the installation of the ADK if it's not present. Thanks, Zehadi!
|
|
||||||
|
|
||||||
**2402.1**
|
|
||||||
|
|
||||||
**New functionality**
|
|
||||||
|
|
||||||
* If -BuildUSBDrve $true, script will now check for USB drive before continuing. If not present, script exits
|
|
||||||
* Added a number of new parameters.
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| -------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| CopyPEDrivers | Bool | When set to\$true, will copy the drivers from the \$FFUDevelopmentPath\PEDrivers folder to the WinPE deployment media. Default is \$false. |
|
|
||||||
| RemoveFFU | Bool | When set to\$true, will remove the FFU file from the\$FFUDevelopmentPath\FFU folder after it has been copied to the USB drive. Default is \$false. |
|
|
||||||
| UpdateLatestCU | Bool | When set to\$true, will download and install the latest cumulative update for Windows 10/11. Default is \$false. |
|
|
||||||
| UpdateLatestNet | Bool | When set to\$true, will download and install the latest .NET Framework for Windows 10/11. Default is \$false. |
|
|
||||||
| UpdateLatestDefender | Bool | When set to\$true, will download and install the latest Windows Defender definitions and Defender platform update. Default is \$false. |
|
|
||||||
| UpdateEdge | Bool | When set to\$true, will download and install the latest Microsoft Edge for Windows 10/11. Default is \$false. |
|
|
||||||
| UpdateOneDrive | Bool | 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. |
|
|
||||||
| CopyPPKG | Bool | When set to\$true, will copy the provisioning package from the \$FFUDevelopmentPath\PPKG folder to the Deployment partition of the USB drive. Default is \$false. |
|
|
||||||
| CopyUnattend | Bool | When set to\$true, will copy the \$FFUDevelopmentPath\Unattend folder to the Deployment partition of the USB drive. Default is \$false. |
|
|
||||||
| CopyAutopilot | Bool | When set to\$true, will copy the \$FFUDevelopmentPath\Autopilot folder to the Deployment partition of the USB drive. Default is \$false. |
|
|
||||||
| CompactOS | Bool | When set to\$true, will compact the OS when building the FFU. Default is \$true. |
|
|
||||||
| CleanupCaptureISO | Bool | When set to\$true, will remove the WinPE capture ISO after the FFU has been captured. Default is \$true. |
|
|
||||||
| CleanupDeployISO | Bool | When set to\$true, will remove the WinPE deployment ISO after the FFU has been captured. Default is \$true. |
|
|
||||||
| CleanupAppsISO | Bool | When set to\$true, will remove the Apps ISO after the FFU has been captured. Default is \$true. |
|
|
||||||
|
|
||||||
* Updated the docs with the new variables and made some minor modifications.
|
|
||||||
* Changed version variable to 2402.1
|
|
||||||
|
|
||||||
**2401.1**
|
|
||||||
|
|
||||||
- Added -CopyDrivers boolean parameter to control the ability to copy drivers to the USB drive in the deploy partition drivers folder.
|
|
||||||
- Changed version varaible to 2401.1
|
|
||||||
- When creating the scratch VHDX, switched it to create a dynamic VHDX instead of fixed
|
|
||||||
- Fixed an issue where adding drivers to the FFU would sometimes fail and would cause the script to exit unexpectedly
|
|
||||||
- Added -optimize boolean parameter to control whether the FFU is optimized or not. This defaults to $true and in most cases should be left this way.
|
|
||||||
- Fixed an issue where if the script failed to create the FFU and the old VM was left behind, it wouldn't clean it up if the VM was in the running state. Will now turn off any running VM with a name prefix of _FFU- and then remove any VMs with a name _FFU- if the environment is flagged as dirty.
|
|
||||||
- Fixed an issue where devices that ship with UFS drives were unable to image due to the script setting a LogicalSectorSizeBytes value of 512. If you're creating a FFU for devices that have UFS drives, you'll need to set -LogicalSectorSizeBytes 4096.
|
|
||||||
- There's a known issue where adding drivers to a FFU that has a LogicalSectorSizeBytes value of 4096. Added some code to prevent allowing this to happen. Please use -copydrivers $true as a workaround for now. We're investigating whether this is a bug or not.
|
|
||||||
- Fixed an issue where VHDX only captures (i.e. where -installapps $false) would not install Windows updates.
|
|
||||||
- Changed Office deployment to use Current channel instead of Monthly enterprise. If you want to change to Monthly Enterprise channel, it's recommended to leverage Intune.
|
|
||||||
|
|
||||||
**2309.2**
|
|
||||||
|
|
||||||
New Features
|
|
||||||
|
|
||||||
**Multiple USB Drive Support**
|
|
||||||
|
|
||||||
You can now plug in multiple USB drives (even using a USB hub) to create multiple USB drives for deployment. This is great for partners or customers who need to provide USB drives to their employees to image a large number of devices. It will copy the content to one USB drive at a time. The most USB drives we've seen created so far is 23 via a USB hub. Open an issue if you see any problems with this.
|
|
||||||
|
|
||||||
**Robocopy support**
|
|
||||||
|
|
||||||
Replaced Copy-Item with Robocopy when copying content to the USB drive(s). Copy-Item uses buffered IO, which can take a long time to copy large files. Robocopy with the /J switch allows for unbuffered IO support, which reduces the amount of time to copy.
|
|
||||||
|
|
||||||
**Better error handling**
|
|
||||||
|
|
||||||
Prior to 2309.2, if the script failed or you manually killed the script (ctrl+c, or closing the PowerShell window), the environment would end up in a bad state and you had to do a number of things to manually clean up the environment. Added a new function called Get-FFUEnvironment and a new text file called dirty.txt that gets created in the FFUDevelopment folder. When the script starts, it checks for the dirty.txt file and if it sees it, Get-FFUEnvironment runs and cleans out a number of things to help ensure the next run will complete successfully. Open an issue if you still see problems when the script fails and the next run of the script fails.
|
|
||||||
|
|
||||||
Bug Fixes
|
|
||||||
|
|
||||||
- In 2309.1, added a 15 second sleep to allow for the registry to unload to fix a Critical Process Died error on deployment. In this build, increased that to 60 seconds.
|
|
||||||
- Fixed an issue where the script was incorrectly detecting the USB drive boot and deploy drive letters which caused issues when attempting to copy the WinPE files to the boot partition.
|
|
||||||
|
|
||||||
**2309.1**
|
|
||||||
|
|
||||||
- Fixed an issue with a Critical Process Died BSOD that would happen when using -installapps $false. More detailed information in the [commit](https://github.com/rbalsleyMSFT/FFU/pull/2/commits/34efbda7ec56dc7cb43ac42b058725d56c8b8899)
|
|
||||||
|
|
||||||
**2306.1.2**
|
|
||||||
|
|
||||||
- Fixed an issue where manually entering a name wouldn't name the computer as expected
|
|
||||||
|
|
||||||
**2306.1.1**
|
|
||||||
|
|
||||||
- Included some better error handling if defining optionalfeatures that require source folders (netfx3). ESD files don't have source folders like ISO media, which means installing .net 3.5 as an optional feature would fail. Also cleaned up some formatting.
|
|
||||||
|
|
||||||
**2306.1**
|
|
||||||
|
|
||||||
- Added support to automatically download the latest Windows 10 or 11 media via the media creation tool (thanks to [Michael](https://oofhours.com/2022/09/14/want-your-own-windows-11-21h2-arm64-isos/) for the idea). This also allows for different architecture, language, and media type support. If you omit the -ISOPath, the script will download the Windows 11 x64 English (US) consumer media.
|
|
||||||
|
|
||||||
An example command to download Windows 11 Pro x64 English (US) consumer media with Office and install drivers (it won't download drivers, you'll put those in your c:\FFUDevelopment\Drivers folder)
|
|
||||||
|
|
||||||
.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
|
|
||||||
|
|
||||||
An example command to download Windows 11 Pro x64 French (CA) consumer media with Office and install drivers
|
|
||||||
|
|
||||||
.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -WindowsRelease 11 -WindowsArch 'x64' -WindowsLang 'fr-ca' -MediaType 'consumer' -verbose
|
|
||||||
- Changed default size of System/EFI partition to 260MB from 256MB to accomodate 4Kn drives. 4Kn support needs more testing. I'm not confident yet that this can be done with VMs and FFUs.
|
|
||||||
- Added versioning with a new version parameter. Using YYMM as the format followed by a point release.
|
|
||||||
|
|
||||||
# Getting Started
|
# 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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 33 KiB |