From d16642bf0ea63ae49cee1d6267069e43804ea102 Mon Sep 17 00:00:00 2001 From: Chris Titus Tech Date: Mon, 2 Mar 2026 15:57:43 -0600 Subject: [PATCH] Fix OSCDIMG output and cleanup comments --- functions/private/Invoke-WinUtilISO.ps1 | 508 +++++++----------- functions/private/Invoke-WinUtilISOScript.ps1 | 128 +---- functions/private/Invoke-WinUtilISOUSB.ps1 | 134 ++--- scripts/main.ps1 | 5 +- xaml/inputXML.xaml | 3 - 5 files changed, 260 insertions(+), 518 deletions(-) diff --git a/functions/private/Invoke-WinUtilISO.ps1 b/functions/private/Invoke-WinUtilISO.ps1 index 845bfa28..bd80daa7 100644 --- a/functions/private/Invoke-WinUtilISO.ps1 +++ b/functions/private/Invoke-WinUtilISO.ps1 @@ -1,18 +1,12 @@ function Write-Win11ISOLog { - <# - .SYNOPSIS - Appends a timestamped message to the Win11ISO status log TextBox. - .PARAMETER Message - The message to append. - #> param([string]$Message) - $timestamp = (Get-Date).ToString("HH:mm:ss") + $ts = (Get-Date).ToString("HH:mm:ss") $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ $current = $sync["WPFWin11ISOStatusLog"].Text if ($current -eq "Ready. Please select a Windows 11 ISO to begin.") { - $sync["WPFWin11ISOStatusLog"].Text = "[$timestamp] $Message" + $sync["WPFWin11ISOStatusLog"].Text = "[$ts] $Message" } else { - $sync["WPFWin11ISOStatusLog"].Text += "`n[$timestamp] $Message" + $sync["WPFWin11ISOStatusLog"].Text += "`n[$ts] $Message" } $sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length $sync["WPFWin11ISOStatusLog"].ScrollToEnd() @@ -20,52 +14,34 @@ function Write-Win11ISOLog { } function Invoke-WinUtilISOBrowse { - <# - .SYNOPSIS - Opens an OpenFileDialog so the user can choose a Windows 11 ISO file. - Populates WPFWin11ISOPath and reveals the Mount & Verify section (Step 2). - #> Add-Type -AssemblyName System.Windows.Forms $dlg = [System.Windows.Forms.OpenFileDialog]::new() - $dlg.Title = "Select Windows 11 ISO" - $dlg.Filter = "ISO files (*.iso)|*.iso|All files (*.*)|*.*" + $dlg.Title = "Select Windows 11 ISO" + $dlg.Filter = "ISO files (*.iso)|*.iso|All files (*.*)|*.*" $dlg.InitialDirectory = [System.Environment]::GetFolderPath("Desktop") if ($dlg.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { return } - $isoPath = $dlg.FileName - - # ── Basic size sanity-check (a Win11 ISO is typically > 4 GB) ── + $isoPath = $dlg.FileName $fileSizeGB = [math]::Round((Get-Item $isoPath).Length / 1GB, 2) - $sync["WPFWin11ISOPath"].Text = $isoPath - $sync["WPFWin11ISOFileInfo"].Text = "File size: $fileSizeGB GB" + $sync["WPFWin11ISOPath"].Text = $isoPath + $sync["WPFWin11ISOFileInfo"].Text = "File size: $fileSizeGB GB" $sync["WPFWin11ISOFileInfo"].Visibility = "Visible" - - # Reveal Step 2 - $sync["WPFWin11ISOMountSection"].Visibility = "Visible" - - # Collapse all later steps whenever a new ISO is chosen - $sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Collapsed" - $sync["WPFWin11ISOModifySection"].Visibility = "Collapsed" - $sync["WPFWin11ISOOutputSection"].Visibility = "Collapsed" + $sync["WPFWin11ISOMountSection"].Visibility = "Visible" + $sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Collapsed" + $sync["WPFWin11ISOModifySection"].Visibility = "Collapsed" + $sync["WPFWin11ISOOutputSection"].Visibility = "Collapsed" Write-Win11ISOLog "ISO selected: $isoPath ($fileSizeGB GB)" } function Invoke-WinUtilISOMountAndVerify { - <# - .SYNOPSIS - Mounts the selected ISO, verifies it is a valid Windows 11 image, - and populates the edition list. Reveals Step 3 on success. - #> $isoPath = $sync["WPFWin11ISOPath"].Text if ([string]::IsNullOrWhiteSpace($isoPath) -or $isoPath -eq "No ISO selected...") { - [System.Windows.MessageBox]::Show( - "Please select an ISO file first.", - "No ISO Selected", "OK", "Warning") + [System.Windows.MessageBox]::Show("Please select an ISO file first.", "No ISO Selected", "OK", "Warning") return } @@ -73,14 +49,12 @@ function Invoke-WinUtilISOMountAndVerify { Set-WinUtilProgressBar -Label "Mounting ISO..." -Percent 10 try { - # Mount the ISO - $diskImage = Mount-DiskImage -ImagePath $isoPath -PassThru -ErrorAction Stop + $diskImage = Mount-DiskImage -ImagePath $isoPath -PassThru -ErrorAction Stop $driveLetter = ($diskImage | Get-Volume).DriveLetter + ":" Write-Win11ISOLog "Mounted at drive $driveLetter" Set-WinUtilProgressBar -Label "Verifying ISO contents..." -Percent 30 - # ── Verify install.wim / install.esd presence ── $wimPath = Join-Path $driveLetter "sources\install.wim" $esdPath = Join-Path $driveLetter "sources\install.esd" @@ -96,14 +70,10 @@ function Invoke-WinUtilISOMountAndVerify { $activeWim = if (Test-Path $wimPath) { $wimPath } else { $esdPath } - # ── Read edition / architecture info ── Set-WinUtilProgressBar -Label "Reading image metadata..." -Percent 55 - $imageInfo = Get-WindowsImage -ImagePath $activeWim | Select-Object ImageIndex, ImageName - # ── Verify at least one Win11 edition is present ── - $isWin11 = $imageInfo | Where-Object { $_.ImageName -match "Windows 11" } - if (-not $isWin11) { + if (-not ($imageInfo | Where-Object { $_.ImageName -match "Windows 11" })) { Dismount-DiskImage -ImagePath $isoPath | Out-Null Write-Win11ISOLog "ERROR: No 'Windows 11' edition found in the image." [System.Windows.MessageBox]::Show( @@ -113,10 +83,8 @@ function Invoke-WinUtilISOMountAndVerify { return } - # Store edition info for later index lookup $sync["Win11ISOImageInfo"] = $imageInfo - # ── Populate UI ── $sync["WPFWin11ISOMountDriveLetter"].Text = "Mounted at: $driveLetter | Image file: $(Split-Path $activeWim -Leaf)" $sync["WPFWin11ISOEditionComboBox"].Dispatcher.Invoke([action]{ $sync["WPFWin11ISOEditionComboBox"].Items.Clear() @@ -124,12 +92,10 @@ function Invoke-WinUtilISOMountAndVerify { [void]$sync["WPFWin11ISOEditionComboBox"].Items.Add("$($img.ImageIndex): $($img.ImageName)") } if ($sync["WPFWin11ISOEditionComboBox"].Items.Count -gt 0) { - # Default to Windows 11 Pro; fall back to first item if not found $proIndex = -1 for ($i = 0; $i -lt $sync["WPFWin11ISOEditionComboBox"].Items.Count; $i++) { if ($sync["WPFWin11ISOEditionComboBox"].Items[$i] -match "Windows 11 Pro(?![\w ])") { - $proIndex = $i - break + $proIndex = $i; break } } $sync["WPFWin11ISOEditionComboBox"].SelectedIndex = if ($proIndex -ge 0) { $proIndex } else { 0 } @@ -137,43 +103,28 @@ function Invoke-WinUtilISOMountAndVerify { }) $sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Visible" - # Store for later steps $sync["Win11ISODriveLetter"] = $driveLetter $sync["Win11ISOWimPath"] = $activeWim $sync["Win11ISOImagePath"] = $isoPath - - # Reveal Step 3 $sync["WPFWin11ISOModifySection"].Visibility = "Visible" - Set-WinUtilProgressBar -Label "ISO verified ✔" -Percent 100 + Set-WinUtilProgressBar -Label "ISO verified" -Percent 100 Write-Win11ISOLog "ISO verified OK. Editions found: $($imageInfo.Count)" - } - catch { + } catch { Write-Win11ISOLog "ERROR during mount/verify: $_" [System.Windows.MessageBox]::Show( "An error occurred while mounting or verifying the ISO:`n`n$_", "Error", "OK", "Error") - } - finally { + } finally { Start-Sleep -Milliseconds 800 Set-WinUtilProgressBar -Label "" -Percent 0 } } function Invoke-WinUtilISOModify { - <# - .SYNOPSIS - Extracts ISO contents to a temp working directory, modifies install.wim, - then repackages the image. Reveals Step 4 (output options) on success. - - .NOTES - This function runs inside a PowerShell runspace so the UI stays responsive. - Placeholder modification logic is provided; extend as needed. - #> - - $isoPath = $sync["Win11ISOImagePath"] - $driveLetter= $sync["Win11ISODriveLetter"] - $wimPath = $sync["Win11ISOWimPath"] + $isoPath = $sync["Win11ISOImagePath"] + $driveLetter = $sync["Win11ISODriveLetter"] + $wimPath = $sync["Win11ISOWimPath"] if (-not $isoPath) { [System.Windows.MessageBox]::Show( @@ -182,9 +133,8 @@ function Invoke-WinUtilISOModify { return } - # ── Resolve selected edition index from the ComboBox ── - $selectedItem = $sync["WPFWin11ISOEditionComboBox"].SelectedItem - $selectedWimIndex = 1 # default fallback + $selectedItem = $sync["WPFWin11ISOEditionComboBox"].SelectedItem + $selectedWimIndex = 1 if ($selectedItem -and $selectedItem -match '^(\d+):') { $selectedWimIndex = [int]$Matches[1] } elseif ($sync["Win11ISOImageInfo"]) { @@ -193,13 +143,10 @@ function Invoke-WinUtilISOModify { $selectedEditionName = if ($selectedItem) { ($selectedItem -replace '^\d+:\s*', '') } else { "Unknown" } Write-Win11ISOLog "Selected edition: $selectedEditionName (Index $selectedWimIndex)" - # Disable the modify button to prevent double-click $sync["WPFWin11ISOModifyButton"].IsEnabled = $false $existingWorkDir = Get-Item -Path (Join-Path $env:TEMP "WinUtil_Win11ISO*") -ErrorAction SilentlyContinue | - Where-Object { $_.PSIsContainer } | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 + Where-Object { $_.PSIsContainer } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $workDir = if ($existingWorkDir) { Write-Win11ISOLog "Reusing existing temp directory: $($existingWorkDir.FullName)" @@ -208,9 +155,6 @@ function Invoke-WinUtilISOModify { Join-Path $env:TEMP "WinUtil_Win11ISO_$(Get-Date -Format 'yyyyMMdd_HHmmss')" } - # ── Resolve autounattend.xml content ────────────────────────────────────── - # Compiled winutil.ps1 sets $WinUtilAutounattendXml before main.ps1 runs. - # In dev/source mode fall back to reading tools\autounattend.xml directly. $autounattendContent = if ($WinUtilAutounattendXml) { $WinUtilAutounattendXml } else { @@ -218,7 +162,6 @@ function Invoke-WinUtilISOModify { if (Test-Path $toolsXml) { Get-Content $toolsXml -Raw } else { "" } } - # ── Run modification in a background runspace ── $runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() $runspace.ApartmentState = "STA" $runspace.ThreadOptions = "ReuseThread" @@ -235,24 +178,16 @@ function Invoke-WinUtilISOModify { $runspace.SessionStateProxy.SetVariable("autounattendContent", $autounattendContent) $runspace.SessionStateProxy.SetVariable("injectDrivers", $injectDrivers) - # Serialize functions so they are available inside the runspace - $isoScriptFuncDef = "function Invoke-WinUtilISOScript {`n" + ` - ${function:Invoke-WinUtilISOScript}.ToString() + "`n}" - $runspace.SessionStateProxy.SetVariable("isoScriptFuncDef", $isoScriptFuncDef) - - $win11ISOLogFuncDef = "function Write-Win11ISOLog {`n" + ` - ${function:Write-Win11ISOLog}.ToString() + "`n}" + $isoScriptFuncDef = "function Invoke-WinUtilISOScript {`n" + ${function:Invoke-WinUtilISOScript}.ToString() + "`n}" + $win11ISOLogFuncDef = "function Write-Win11ISOLog {`n" + ${function:Write-Win11ISOLog}.ToString() + "`n}" + $refreshUSBFuncDef = "function Invoke-WinUtilISORefreshUSBDrives {`n" + ${function:Invoke-WinUtilISORefreshUSBDrives}.ToString() + "`n}" + $runspace.SessionStateProxy.SetVariable("isoScriptFuncDef", $isoScriptFuncDef) $runspace.SessionStateProxy.SetVariable("win11ISOLogFuncDef", $win11ISOLogFuncDef) - - $refreshUSBFuncDef = "function Invoke-WinUtilISORefreshUSBDrives {`n" + ` - ${function:Invoke-WinUtilISORefreshUSBDrives}.ToString() + "`n}" - $runspace.SessionStateProxy.SetVariable("refreshUSBFuncDef", $refreshUSBFuncDef) + $runspace.SessionStateProxy.SetVariable("refreshUSBFuncDef", $refreshUSBFuncDef) $script = [Management.Automation.PowerShell]::Create() $script.Runspace = $runspace $script.AddScript({ - - # Import helper functions into this runspace . ([scriptblock]::Create($isoScriptFuncDef)) . ([scriptblock]::Create($win11ISOLogFuncDef)) . ([scriptblock]::Create($refreshUSBFuncDef)) @@ -276,15 +211,13 @@ function Invoke-WinUtilISOModify { } try { - # ── Hide Steps 1-3 while modification is running; expand log to fill screen ── $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFWin11ISOSelectSection"].Visibility = "Collapsed" - $sync["WPFWin11ISOMountSection"].Visibility = "Collapsed" - $sync["WPFWin11ISOModifySection"].Visibility = "Collapsed" + $sync["WPFWin11ISOSelectSection"].Visibility = "Collapsed" + $sync["WPFWin11ISOMountSection"].Visibility = "Collapsed" + $sync["WPFWin11ISOModifySection"].Visibility = "Collapsed" $expandedHeight = [Math]::Max(400, $sync["Form"].ActualHeight - 100) $sync["WPFWin11ISOStatusLog"].Height = $expandedHeight $sync["Win11ISOLogExpanded"] = $true - # Register the resize handler once so the log tracks window resizes if (-not $sync["Win11ISOResizeHandlerAdded"]) { $sync["Form"].add_SizeChanged({ if ($sync["Win11ISOLogExpanded"]) { @@ -297,146 +230,101 @@ function Invoke-WinUtilISOModify { } }) - # ── 1. Create working directory structure ── Log "Creating working directory: $workDir" $isoContents = Join-Path $workDir "iso_contents" - $mountDir = Join-Path $workDir "wim_mount" + $mountDir = Join-Path $workDir "wim_mount" New-Item -ItemType Directory -Path $isoContents, $mountDir -Force | Out-Null SetProgress "Copying ISO contents..." 10 - # ── 2. Copy all ISO contents to the working directory ── Log "Copying ISO contents from $driveLetter to $isoContents..." - $robocopyArgs = @($driveLetter, $isoContents, "/E", "/NFL", "/NDL", "/NJH", "/NJS") - & robocopy @robocopyArgs | Out-Null + & robocopy $driveLetter $isoContents /E /NFL /NDL /NJH /NJS | Out-Null Log "ISO contents copied." SetProgress "Mounting install.wim..." 25 - # ── 3. Copy install.wim to working dir (it may be read-only on the DVD) ── $localWim = Join-Path $isoContents "sources\install.wim" - if (-not (Test-Path $localWim)) { - # ESD path - $localWim = Join-Path $isoContents "sources\install.esd" - } - # Ensure the file is writable + if (-not (Test-Path $localWim)) { $localWim = Join-Path $isoContents "sources\install.esd" } Set-ItemProperty -Path $localWim -Name IsReadOnly -Value $false - # ── 4. Mount the selected edition of install.wim ── Log "Mounting install.wim (Index ${selectedWimIndex}: $selectedEditionName) at $mountDir..." Mount-WindowsImage -ImagePath $localWim -Index $selectedWimIndex -Path $mountDir -ErrorAction Stop | Out-Null SetProgress "Modifying install.wim..." 45 - # ── Apply all WinUtil modifications via Invoke-WinUtilISOScript ── Log "Applying WinUtil modifications to install.wim..." Invoke-WinUtilISOScript -ScratchDir $mountDir -ISOContentsDir $isoContents -AutoUnattendXml $autounattendContent -InjectCurrentSystemDrivers $injectDrivers -Log { param($m) Log $m } - # ── 4b. DISM component store cleanup ── - # /ResetBase removes all superseded component versions from WinSxS, - # which is the single largest space saving possible (typically 300–800 MB). - # This must be done while the image is still mounted. SetProgress "Cleaning up component store (WinSxS)..." 56 Log "Running DISM component store cleanup (/ResetBase)..." & dism /English "/image:$mountDir" /Cleanup-Image /StartComponentCleanup /ResetBase | ForEach-Object { Log $_ } Log "Component store cleanup complete." - # ── 5. Save and dismount the WIM ── SetProgress "Saving modified install.wim..." 65 Log "Dismounting and saving install.wim. This will take several minutes..." Dismount-WindowsImage -Path $mountDir -Save -ErrorAction Stop | Out-Null Log "install.wim saved." - # ── 5b. Strip unused editions — export only the selected index ── - # A standard multi-edition install.wim can be 4–5 GB; exporting a - # single index typically drops it to ~3 GB, saving 1–2 GB in the ISO. SetProgress "Removing unused editions from install.wim..." 70 Log "Exporting edition '$selectedEditionName' (Index $selectedWimIndex) to a single-edition install.wim..." $exportWim = Join-Path $isoContents "sources\install_export.wim" - Export-WindowsImage ` - -SourceImagePath $localWim ` - -SourceIndex $selectedWimIndex ` - -DestinationImagePath $exportWim ` - -ErrorAction Stop | Out-Null + Export-WindowsImage -SourceImagePath $localWim -SourceIndex $selectedWimIndex -DestinationImagePath $exportWim -ErrorAction Stop | Out-Null Remove-Item -Path $localWim -Force Rename-Item -Path $exportWim -NewName "install.wim" -Force - # Update local path so later steps (e.g. ISO build) reference the new file $localWim = Join-Path $isoContents "sources\install.wim" - Log "Unused editions removed. install.wim now contains only '$selectedEditionName'." + Log "Unused editions removed. install.wim now contains only '$selectedEditionName'." SetProgress "Dismounting source ISO..." 80 - - # ── 6. Dismount the original ISO ── Log "Dismounting original ISO..." Dismount-DiskImage -ImagePath $isoPath | Out-Null - # Store work directory for output steps - $sync["Win11ISOWorkDir"] = $workDir - $sync["Win11ISOContentsDir"] = $isoContents + $sync["Win11ISOWorkDir"] = $workDir + $sync["Win11ISOContentsDir"] = $isoContents - SetProgress "Modification complete ✔" 100 - Log "install.wim modification complete. Choose an output option in Step 4." + SetProgress "Modification complete" 100 + Log "install.wim modification complete. Choose an output option in Step 4." - # ── Reveal Step 4 on the UI thread ── - # Note: USB drive enumeration (Get-Disk) is intentionally deferred to - # when the user explicitly selects the USB option, to avoid blocking - # the UI thread here. $sync["WPFWin11ISOOutputSection"].Dispatcher.Invoke([action]{ $sync["WPFWin11ISOOutputSection"].Visibility = "Visible" $sync["WPFWin11ISOStatusLog"].Height = 300 }) - } - catch { + } catch { Log "ERROR during modification: $_" - # ── Cleanup: dismount WIM if still mounted ── try { if (Test-Path $mountDir) { - $mountedImages = Get-WindowsImage -Mounted -ErrorAction SilentlyContinue | - Where-Object { $_.Path -eq $mountDir } + $mountedImages = Get-WindowsImage -Mounted -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $mountDir } if ($mountedImages) { Log "Cleaning up: dismounting install.wim (discarding changes)..." Dismount-WindowsImage -Path $mountDir -Discard -ErrorAction SilentlyContinue | Out-Null } } - } catch { - Log "Warning: could not dismount install.wim during cleanup: $_" - } + } catch { Log "Warning: could not dismount install.wim during cleanup: $_" } - # ── Cleanup: dismount the source ISO ── try { $mountedISO = Get-DiskImage -ImagePath $isoPath -ErrorAction SilentlyContinue if ($mountedISO -and $mountedISO.Attached) { Log "Cleaning up: dismounting source ISO..." Dismount-DiskImage -ImagePath $isoPath -ErrorAction SilentlyContinue | Out-Null } - } catch { - Log "Warning: could not dismount ISO during cleanup: $_" - } + } catch { Log "Warning: could not dismount ISO during cleanup: $_" } - # ── Cleanup: remove temp working directory ── try { if (Test-Path $workDir) { Log "Cleaning up: removing temp directory $workDir..." Remove-Item -Path $workDir -Recurse -Force -ErrorAction SilentlyContinue } - } catch { - Log "Warning: could not remove temp directory during cleanup: $_" - } + } catch { Log "Warning: could not remove temp directory during cleanup: $_" } $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ [System.Windows.MessageBox]::Show( "An error occurred during install.wim modification:`n`n$_", "Modification Error", "OK", "Error") }) - } - finally { + } finally { Start-Sleep -Milliseconds 800 $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync.progressBarTextBlock.Text = "" + $sync.progressBarTextBlock.Text = "" $sync.progressBarTextBlock.ToolTip = "" - $sync.ProgressBar.Value = 0 + $sync.ProgressBar.Value = 0 $sync["WPFWin11ISOModifyButton"].IsEnabled = $true - # ── Only restore steps 1-3 if Step 4 was NOT successfully shown ── - # When modification succeeds, Step 4 is visible and steps 1-3 stay - # hidden until the user clicks Clean & Reset. if ($sync["WPFWin11ISOOutputSection"].Visibility -ne "Visible") { $sync["WPFWin11ISOSelectSection"].Visibility = "Visible" $sync["WPFWin11ISOMountSection"].Visibility = "Visible" @@ -452,43 +340,25 @@ function Invoke-WinUtilISOModify { } function Invoke-WinUtilISOCheckExistingWork { - <# - .SYNOPSIS - Called when the Win11ISO tab is opened. Checks for a pre-existing - WinUtil_Win11ISO temp directory and, if found, restores the working- - directory state so the user can proceed directly to Step 4 (output - options) without repeating the modification. - #> - - # If state is already loaded (e.g. user just switched tabs mid-session) - # do nothing so we don't overwrite in-progress work. - if ($sync["Win11ISOContentsDir"] -and (Test-Path $sync["Win11ISOContentsDir"])) { - return - } + if ($sync["Win11ISOContentsDir"] -and (Test-Path $sync["Win11ISOContentsDir"])) { return } $existingWorkDir = Get-Item -Path (Join-Path $env:TEMP "WinUtil_Win11ISO*") -ErrorAction SilentlyContinue | - Where-Object { $_.PSIsContainer } | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 + Where-Object { $_.PSIsContainer } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $existingWorkDir) { return } $isoContents = Join-Path $existingWorkDir.FullName "iso_contents" if (-not (Test-Path $isoContents)) { return } - # Restore state $sync["Win11ISOWorkDir"] = $existingWorkDir.FullName $sync["Win11ISOContentsDir"] = $isoContents - # Show Step 4 and collapse steps 1-3 (modification already happened) $sync["WPFWin11ISOSelectSection"].Visibility = "Collapsed" $sync["WPFWin11ISOMountSection"].Visibility = "Collapsed" $sync["WPFWin11ISOModifySection"].Visibility = "Collapsed" $sync["WPFWin11ISOOutputSection"].Visibility = "Visible" - $sync["WPFWin11ISOStatusLog"].Height = 300 + $sync["WPFWin11ISOStatusLog"].Height = 300 - # Notify via the status log - $dirName = $existingWorkDir.Name $modified = $existingWorkDir.LastWriteTime.ToString("yyyy-MM-dd HH:mm") Write-Win11ISOLog "Existing working directory found: $($existingWorkDir.FullName)" Write-Win11ISOLog "Last modified: $modified - Skipping Steps 1-3 and resuming at Step 4." @@ -500,14 +370,6 @@ function Invoke-WinUtilISOCheckExistingWork { } function Invoke-WinUtilISOCleanAndReset { - <# - .SYNOPSIS - Deletes the temporary working directory created during ISO modification - and resets the entire ISO UI back to its initial state (Step 1 only). - Deletion runs in a background runspace so the UI stays responsive and - progress is reported to the user. - #> - $workDir = $sync["Win11ISOWorkDir"] if ($workDir -and (Test-Path $workDir)) { @@ -517,7 +379,6 @@ function Invoke-WinUtilISOCleanAndReset { if ($confirm -ne "Yes") { return } } - # Disable button so it cannot be clicked twice $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $false $runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() @@ -540,6 +401,7 @@ function Invoke-WinUtilISOCleanAndReset { }) Add-Content -Path (Join-Path $workDir "WinUtil_Win11ISO.log") -Value "[$ts] $msg" -ErrorAction SilentlyContinue } + function SetProgress($label, $pct) { $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ $sync.progressBarTextBlock.Text = $label @@ -549,7 +411,6 @@ function Invoke-WinUtilISOCleanAndReset { } try { - # ── Dismount any WIM images still mounted under workDir ────────────── if ($workDir) { $mountDir = Join-Path $workDir "wim_mount" try { @@ -562,22 +423,15 @@ function Invoke-WinUtilISOCleanAndReset { Dismount-WindowsImage -Path $img.Path -Discard -ErrorAction Stop | Out-Null Log "WIM dismounted successfully." } - } else { - # Fallback: run dism /Cleanup-Wim in case metadata is corrupt - # and Get-WindowsImage cannot enumerate the stuck mount - if (Test-Path $mountDir) { - Log "No mounted WIM reported by Get-WindowsImage, running DISM /Cleanup-Wim as a precaution..." - SetProgress "Running DISM cleanup..." 3 - & dism /English /Cleanup-Wim 2>&1 | ForEach-Object { Log $_ } - } + } elseif (Test-Path $mountDir) { + Log "No mounted WIM reported by Get-WindowsImage, running DISM /Cleanup-Wim as a precaution..." + SetProgress "Running DISM cleanup..." 3 + & dism /English /Cleanup-Wim 2>&1 | ForEach-Object { Log $_ } } } catch { Log "Warning: could not dismount WIM cleanly, attempting DISM /Cleanup-Wim fallback: $_" - try { - & dism /English /Cleanup-Wim 2>&1 | ForEach-Object { Log $_ } - } catch { - Log "Warning: DISM /Cleanup-Wim also failed: $_" - } + try { & dism /English /Cleanup-Wim 2>&1 | ForEach-Object { Log $_ } } + catch { Log "Warning: DISM /Cleanup-Wim also failed: $_" } } } @@ -585,41 +439,28 @@ function Invoke-WinUtilISOCleanAndReset { Log "Scanning files to delete in: $workDir" SetProgress "Scanning files..." 5 - $allItems = @(Get-ChildItem -Path $workDir -Recurse -Force -ErrorAction SilentlyContinue) - $total = $allItems.Count - $deleted = 0 + $allFiles = @(Get-ChildItem -Path $workDir -File -Recurse -Force -ErrorAction SilentlyContinue) + $allDirs = @(Get-ChildItem -Path $workDir -Directory -Recurse -Force -ErrorAction SilentlyContinue | + Sort-Object { $_.FullName.Length } -Descending) + $total = $allFiles.Count + $deleted = 0 - Log "Found $total items to delete." + Log "Found $total files to delete." - # Delete files first, then directories (deepest first) - $files = $allItems | Where-Object { -not $_.PSIsContainer } - $dirs = $allItems | Where-Object { $_.PSIsContainer } | - Sort-Object { $_.FullName.Length } -Descending - - foreach ($f in $files) { - Log "Deleting file: $($f.FullName)" - try { Remove-Item -Path $f.FullName -Force -ErrorAction Stop } catch { Log " WARNING: could not delete file: $_" } + foreach ($f in $allFiles) { + try { Remove-Item -Path $f.FullName -Force -ErrorAction Stop } catch { Log "WARNING: could not delete $($f.FullName): $_" } $deleted++ - if ($deleted % 100 -eq 0 -or $deleted -eq $files.Count) { - $pct = [math]::Round(($deleted / [Math]::Max($total,1)) * 85) + 5 - SetProgress "Deleting files... ($deleted / $total)" $pct - Log "Progress: $deleted of $total items processed." + if ($deleted % 100 -eq 0 -or $deleted -eq $total) { + $pct = [math]::Round(($deleted / [Math]::Max($total, 1)) * 85) + 5 + SetProgress "Deleting files in $($f.Directory.Name)... ($deleted / $total)" $pct } } - foreach ($d in $dirs) { - Log "Removing directory: $($d.FullName)" - try { Remove-Item -Path $d.FullName -Force -Recurse -ErrorAction Stop } catch { Log " WARNING: could not remove directory: $_" } - $deleted++ - if ($deleted % 50 -eq 0 -or $deleted -eq $total) { - $pct = [math]::Round(($deleted / [Math]::Max($total,1)) * 85) + 5 - SetProgress "Removing directories... ($deleted / $total)" $pct - Log "Progress: $deleted of $total items processed." - } + foreach ($d in $allDirs) { + try { Remove-Item -Path $d.FullName -Force -ErrorAction SilentlyContinue } catch {} } - # Remove the root work directory itself - try { Remove-Item -Path $workDir -Force -Recurse -ErrorAction Stop } catch {} + try { Remove-Item -Path $workDir -Recurse -Force -ErrorAction Stop } catch {} if (Test-Path $workDir) { Log "WARNING: some items could not be deleted in $workDir" @@ -633,9 +474,7 @@ function Invoke-WinUtilISOCleanAndReset { SetProgress "Resetting UI..." 95 Log "Resetting interface..." - # ── Full UI reset on the dispatcher thread ────────────────────── $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - # Clear stored state $sync["Win11ISOWorkDir"] = $null $sync["Win11ISOContentsDir"] = $null $sync["Win11ISOImagePath"] = $null @@ -644,17 +483,16 @@ function Invoke-WinUtilISOCleanAndReset { $sync["Win11ISOImageInfo"] = $null $sync["Win11ISOUSBDisks"] = $null - # Reset UI elements - $sync["WPFWin11ISOPath"].Text = "No ISO selected..." - $sync["WPFWin11ISOFileInfo"].Visibility = "Collapsed" - $sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Collapsed" - $sync["WPFWin11ISOOptionUSB"].Visibility = "Collapsed" - $sync["WPFWin11ISOOutputSection"].Visibility = "Collapsed" - $sync["WPFWin11ISOModifySection"].Visibility = "Collapsed" - $sync["WPFWin11ISOMountSection"].Visibility = "Collapsed" - $sync["WPFWin11ISOSelectSection"].Visibility = "Visible" - $sync["WPFWin11ISOModifyButton"].IsEnabled = $true - $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $true + $sync["WPFWin11ISOPath"].Text = "No ISO selected..." + $sync["WPFWin11ISOFileInfo"].Visibility = "Collapsed" + $sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Collapsed" + $sync["WPFWin11ISOOptionUSB"].Visibility = "Collapsed" + $sync["WPFWin11ISOOutputSection"].Visibility = "Collapsed" + $sync["WPFWin11ISOModifySection"].Visibility = "Collapsed" + $sync["WPFWin11ISOMountSection"].Visibility = "Collapsed" + $sync["WPFWin11ISOSelectSection"].Visibility = "Visible" + $sync["WPFWin11ISOModifyButton"].IsEnabled = $true + $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $true $sync.progressBarTextBlock.Text = "" $sync.progressBarTextBlock.ToolTip = "" @@ -663,8 +501,7 @@ function Invoke-WinUtilISOCleanAndReset { $sync["WPFWin11ISOStatusLog"].Height = 140 $sync["WPFWin11ISOStatusLog"].Text = "Ready. Please select a Windows 11 ISO to begin." }) - } - catch { + } catch { Log "ERROR during Clean & Reset: $_" $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ $sync.progressBarTextBlock.Text = "" @@ -679,17 +516,11 @@ function Invoke-WinUtilISOCleanAndReset { } function Invoke-WinUtilISOExport { - <# - .SYNOPSIS - Saves the modified ISO contents as a new bootable ISO file. - Uses oscdimg.exe (part of the Windows ADK) if present; falls back - to a reminder message if not installed. - #> $contentsDir = $sync["Win11ISOContentsDir"] if (-not $contentsDir -or -not (Test-Path $contentsDir)) { [System.Windows.MessageBox]::Show( - "No modified ISO content found. Please complete Steps 1–3 first.", + "No modified ISO content found. Please complete Steps 1-3 first.", "Not Ready", "OK", "Warning") return } @@ -705,9 +536,6 @@ function Invoke-WinUtilISOExport { if ($dlg.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { return } $outputISO = $dlg.FileName - Write-Win11ISOLog "Exporting to ISO: $outputISO" - - Set-WinUtilProgressBar -Label "Building ISO..." -Percent 10 # Locate oscdimg.exe (Windows ADK or winget per-user install) $oscdimg = Get-ChildItem "C:\Program Files (x86)\Windows Kits" -Recurse -Filter "oscdimg.exe" -ErrorAction SilentlyContinue | @@ -719,13 +547,11 @@ function Invoke-WinUtilISOExport { } if (-not $oscdimg) { - Write-Win11ISOLog "oscdimg.exe not found. Attempting to install via winget..." - Set-WinUtilProgressBar -Label "Installing oscdimg..." -Percent 5 + Write-Win11ISOLog "oscdimg.exe not found. Attempting to install via winget..." try { $winget = Get-Command winget -ErrorAction Stop $result = & $winget install -e --id Microsoft.OSCDIMG --accept-package-agreements --accept-source-agreements 2>&1 Write-Win11ISOLog "winget output: $result" - # Re-scan after install $oscdimg = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Filter "oscdimg.exe" -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match 'Microsoft\.OSCDIMG' } | Select-Object -First 1 -ExpandProperty FullName @@ -734,7 +560,6 @@ function Invoke-WinUtilISOExport { } if (-not $oscdimg) { - Set-WinUtilProgressBar -Label "" -Percent 0 Write-Win11ISOLog "oscdimg.exe still not found after install attempt." [System.Windows.MessageBox]::Show( "oscdimg.exe could not be found or installed automatically.`n`nPlease install it manually:`n winget install -e --id Microsoft.OSCDIMG`n`nOr install the Windows ADK from:`nhttps://learn.microsoft.com/windows-hardware/get-started/adk-install", @@ -744,65 +569,116 @@ function Invoke-WinUtilISOExport { Write-Win11ISOLog "oscdimg.exe installed successfully." } - # Build boot parameters (BIOS + UEFI dual-boot) - $bootData = "2#p0,e,b`"$contentsDir\boot\etfsboot.com`"#pEF,e,b`"$contentsDir\efi\microsoft\boot\efisys.bin`"" - $oscdimgArgs = @( - "-m", # ignore source path max size - "-o", # optimise storage - "-u2", # UDF 2.01 - "-udfver102", - "-bootdata:$bootData", - "-l`"CTOS_MODIFIED`"", - "`"$contentsDir`"", - "`"$outputISO`"" - ) + $sync["WPFWin11ISOChooseISOButton"].IsEnabled = $false - try { - Write-Win11ISOLog "Running oscdimg..." - $psi = [System.Diagnostics.ProcessStartInfo]::new() - $psi.FileName = $oscdimg - $psi.Arguments = $oscdimgArgs -join " " - $psi.RedirectStandardOutput = $true - $psi.RedirectStandardError = $true - $psi.UseShellExecute = $false - $psi.CreateNoWindow = $true + $runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() + $runspace.ApartmentState = "STA" + $runspace.ThreadOptions = "ReuseThread" + $runspace.Open() + $runspace.SessionStateProxy.SetVariable("sync", $sync) + $runspace.SessionStateProxy.SetVariable("contentsDir", $contentsDir) + $runspace.SessionStateProxy.SetVariable("outputISO", $outputISO) + $runspace.SessionStateProxy.SetVariable("oscdimg", $oscdimg) - $proc = [System.Diagnostics.Process]::new() - $proc.StartInfo = $psi - $proc.Start() | Out-Null + $win11ISOLogFuncDef = "function Write-Win11ISOLog {`n" + ${function:Write-Win11ISOLog}.ToString() + "`n}" + $runspace.SessionStateProxy.SetVariable("win11ISOLogFuncDef", $win11ISOLogFuncDef) - # Stream stdout and stderr line-by-line to the status log - $stdoutTask = $proc.StandardOutput.ReadToEndAsync() - $stderrTask = $proc.StandardError.ReadToEndAsync() - $proc.WaitForExit() - [System.Threading.Tasks.Task]::WaitAll($stdoutTask, $stderrTask) + $script = [Management.Automation.PowerShell]::Create() + $script.Runspace = $runspace + $script.AddScript({ + . ([scriptblock]::Create($win11ISOLogFuncDef)) - foreach ($line in ($stdoutTask.Result -split "`r?`n")) { - if ($line.Trim()) { Write-Win11ISOLog $line } - } - foreach ($line in ($stderrTask.Result -split "`r?`n")) { - if ($line.Trim()) { Write-Win11ISOLog "[stderr]$line" } + function SetProgress($label, $pct) { + $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ + $sync.progressBarTextBlock.Text = $label + $sync.progressBarTextBlock.ToolTip = $label + $sync.ProgressBar.Value = [Math]::Max($pct, 5) + }) } - if ($proc.ExitCode -eq 0) { - Set-WinUtilProgressBar -Label "ISO exported" -Percent 100 - Write-Win11ISOLog "ISO exported successfully: $outputISO" - [System.Windows.MessageBox]::Show( - "ISO exported successfully!`n`n$outputISO", - "Export Complete", "OK", "Info") - } else { - Write-Win11ISOLog "oscdimg exited with code $($proc.ExitCode)." - [System.Windows.MessageBox]::Show( - "oscdimg exited with code $($proc.ExitCode).`nCheck the status log for details.", - "Export Error", "OK", "Error") + # Expand the log to fill the screen while oscdimg runs + $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ + $expandedHeight = [Math]::Max(400, $sync["Form"].ActualHeight - 100) + $sync["WPFWin11ISOStatusLog"].Height = $expandedHeight + $sync["Win11ISOLogExpanded"] = $true + if (-not $sync["Win11ISOResizeHandlerAdded"]) { + $sync["Form"].add_SizeChanged({ + if ($sync["Win11ISOLogExpanded"]) { + $sync["WPFWin11ISOStatusLog"].Height = [Math]::Max(400, $sync["Form"].ActualHeight - 100) + $sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length + $sync["WPFWin11ISOStatusLog"].ScrollToEnd() + } + }) + $sync["Win11ISOResizeHandlerAdded"] = $true + } + }) + + try { + Write-Win11ISOLog "Exporting to ISO: $outputISO" + SetProgress "Building ISO..." 10 + + $bootData = "2#p0,e,b`"$contentsDir\boot\etfsboot.com`"#pEF,e,b`"$contentsDir\efi\microsoft\boot\efisys.bin`"" + $oscdimgArgs = @("-m", "-o", "-u2", "-udfver102", "-bootdata:$bootData", "-l`"CTOS_MODIFIED`"", "`"$contentsDir`"", "`"$outputISO`"") + + Write-Win11ISOLog "Running oscdimg..." + + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $oscdimg + $psi.Arguments = $oscdimgArgs -join " " + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $proc = [System.Diagnostics.Process]::new() + $proc.StartInfo = $psi + $proc.Start() | Out-Null + + # Stream stdout line-by-line as oscdimg runs + while (-not $proc.StandardOutput.EndOfStream) { + $line = $proc.StandardOutput.ReadLine() + if ($line.Trim()) { Write-Win11ISOLog $line } + } + + $proc.WaitForExit() + + # Flush any stderr after process exits + $stderr = $proc.StandardError.ReadToEnd() + foreach ($line in ($stderr -split "`r?`n")) { + if ($line.Trim()) { Write-Win11ISOLog "[stderr] $line" } + } + + if ($proc.ExitCode -eq 0) { + SetProgress "ISO exported" 100 + Write-Win11ISOLog "ISO exported successfully: $outputISO" + $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ + [System.Windows.MessageBox]::Show("ISO exported successfully!`n`n$outputISO", "Export Complete", "OK", "Info") + }) + } else { + Write-Win11ISOLog "oscdimg exited with code $($proc.ExitCode)." + $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ + [System.Windows.MessageBox]::Show( + "oscdimg exited with code $($proc.ExitCode).`nCheck the status log for details.", + "Export Error", "OK", "Error") + }) + } + } catch { + Write-Win11ISOLog "ERROR during ISO export: $_" + $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ + [System.Windows.MessageBox]::Show("ISO export failed:`n`n$_", "Error", "OK", "Error") + }) + } finally { + Start-Sleep -Milliseconds 800 + $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ + $sync.progressBarTextBlock.Text = "" + $sync.progressBarTextBlock.ToolTip = "" + $sync.ProgressBar.Value = 0 + $sync["Win11ISOLogExpanded"] = $false + $sync["WPFWin11ISOStatusLog"].Height = 300 + $sync["WPFWin11ISOChooseISOButton"].IsEnabled = $true + }) } - } - catch { - Write-Win11ISOLog "ERROR during ISO export: $_" - [System.Windows.MessageBox]::Show("ISO export failed:`n`n$_","Error","OK","Error") - } - finally { - Start-Sleep -Milliseconds 800 - Set-WinUtilProgressBar -Label "" -Percent 0 - } + }) | Out-Null + + $script.BeginInvoke() | Out-Null } diff --git a/functions/private/Invoke-WinUtilISOScript.ps1 b/functions/private/Invoke-WinUtilISOScript.ps1 index b7610da9..c6749d6f 100644 --- a/functions/private/Invoke-WinUtilISOScript.ps1 +++ b/functions/private/Invoke-WinUtilISOScript.ps1 @@ -11,41 +11,31 @@ function Invoke-WinUtilISOScript { scheduled-task definition files, and optionally writes autounattend.xml to the ISO root and removes the support\ folder from the ISO contents directory. - All setup scripts embedded in the autounattend.xml nodes - (Specialize.ps1, DefaultUser.ps1, FirstLogon.ps1, UserOnce.ps1, etc.) are written - directly into the WIM at their target paths under C:\Windows\Setup\Scripts\. This - pre-staging is necessary because Windows Setup strips unrecognised-namespace XML - elements — including the Schneegans block — when copying the answer - file to %WINDIR%\Panther\unattend.xml. Without pre-staging the [scriptblock] that - tries to extract scripts from the Panther copy receives $null, no scripts reach - disk, and both the specialize-pass actions and FirstLogonCommands silently fail. + All setup scripts embedded in the autounattend.xml nodes are + written directly into the WIM at their target paths under C:\Windows\Setup\Scripts\ + to ensure they survive Windows Setup stripping unrecognised-namespace XML elements + from the Panther copy of the answer file. Mounting/dismounting the WIM is the caller's responsibility (e.g. Invoke-WinUtilISO). .PARAMETER ScratchDir Mandatory. Full path to the directory where the Windows image is currently mounted. - Example: C:\Users\USERNAME\AppData\Local\Temp\WinUtil_Win11ISO_20260222\wim_mount .PARAMETER ISOContentsDir - Optional. Root directory of the extracted ISO contents. - When supplied, autounattend.xml is also written here so Windows Setup picks it - up automatically at boot, and the support\ folder is deleted from that location. + Optional. Root directory of the extracted ISO contents. When supplied, + autounattend.xml is written here and the support\ folder is removed. .PARAMETER AutoUnattendXml - Optional. Full XML content for autounattend.xml. - In compiled winutil.ps1 this is the embedded $WinUtilAutounattendXml here-string; - in dev mode it is read from tools\autounattend.xml. - If empty, the OOBE bypass file is skipped and a warning is logged. + Optional. Full XML content for autounattend.xml. If empty, the OOBE bypass + file is skipped and a warning is logged. .PARAMETER InjectCurrentSystemDrivers Optional. When $true, exports all drivers from the running system and injects them into install.wim and boot.wim index 2 (Windows Setup PE). - Defaults to $false — no drivers are injected. + Defaults to $false. .PARAMETER Log - Optional ScriptBlock used for progress/status logging. - Receives a single [string] message argument. - Defaults to { param($m) Write-Output $m } when not supplied. + Optional ScriptBlock for progress/status logging. Receives a single [string] argument. .EXAMPLE Invoke-WinUtilISOScript -ScratchDir "C:\Temp\wim_mount" @@ -70,11 +60,9 @@ function Invoke-WinUtilISOScript { [scriptblock]$Log = { param($m) Write-Output $m } ) - # ── Resolve admin group name (for takeown / icacls) ────────────────────── $adminSID = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544') $adminGroup = $adminSID.Translate([System.Security.Principal.NTAccount]) - # ── Local helpers ───────────────────────────────────────────────────────── function Set-ISOScriptReg { param ([string]$path, [string]$name, [string]$type, [string]$value) try { @@ -95,30 +83,19 @@ function Invoke-WinUtilISOScript { } } - # Injects all drivers from $DriverDir into a DISM-mounted image in one call. function Add-DriversToImage { - param ( - [string]$MountPath, - [string]$DriverDir, - [string]$Label = "image", - [scriptblock]$Logger - ) + param ([string]$MountPath, [string]$DriverDir, [string]$Label = "image", [scriptblock]$Logger) & dism /English "/image:$MountPath" /Add-Driver "/Driver:$DriverDir" /Recurse 2>&1 | ForEach-Object { & $Logger " dism[$Label]: $_" } } - # Mounts boot.wim index 2, injects all drivers from $DriverDir, saves, dismounts. function Invoke-BootWimInject { - param ( - [string]$BootWimPath, - [string]$DriverDir, - [scriptblock]$Logger - ) + param ([string]$BootWimPath, [string]$DriverDir, [scriptblock]$Logger) Set-ItemProperty -Path $BootWimPath -Name IsReadOnly -Value $false -ErrorAction SilentlyContinue $mountDir = Join-Path $env:TEMP "WinUtil_BootMount_$(Get-Random)" New-Item -Path $mountDir -ItemType Directory -Force | Out-Null try { - & $Logger "Mounting boot.wim (index 2 — Windows Setup) for driver injection..." + & $Logger "Mounting boot.wim (index 2) for driver injection..." Mount-WindowsImage -ImagePath $BootWimPath -Index 2 -Path $mountDir -ErrorAction Stop | Out-Null Add-DriversToImage -MountPath $mountDir -DriverDir $DriverDir -Label "boot" -Logger $Logger & $Logger "Saving boot.wim..." @@ -132,15 +109,11 @@ function Invoke-WinUtilISOScript { } } - # ═════════════════════════════════════════════════════════════════════════ - # 1. Remove provisioned AppX packages - # ═════════════════════════════════════════════════════════════════════════ + # ── 1. Remove provisioned AppX packages ────────────────────────────────── & $Log "Removing provisioned AppX packages..." $packages = & dism /English "/image:$ScratchDir" /Get-ProvisionedAppxPackages | - ForEach-Object { - if ($_ -match 'PackageName : (.*)') { $matches[1] } - } + ForEach-Object { if ($_ -match 'PackageName : (.*)') { $matches[1] } } $packagePrefixes = @( 'AppUp.IntelManagementandSecurityStatus', @@ -187,22 +160,10 @@ function Invoke-WinUtilISOScript { 'MicrosoftTeams' ) - $packagesToRemove = $packages | Where-Object { - $pkg = $_ - $packagePrefixes | Where-Object { $pkg -like "*$_*" } - } - foreach ($package in $packagesToRemove) { - & dism /English "/image:$ScratchDir" /Remove-ProvisionedAppxPackage "/PackageName:$package" - } + $packages | Where-Object { $pkg = $_; $packagePrefixes | Where-Object { $pkg -like "*$_*" } } | + ForEach-Object { & dism /English "/image:$ScratchDir" /Remove-ProvisionedAppxPackage "/PackageName:$_" } - # ═════════════════════════════════════════════════════════════════════════ - # 2. Inject current system drivers (optional) - # Enabled by the "Inject current system drivers" checkbox at the - # Mount & Verify step. Exports ALL drivers from the running system - # and injects them into install.wim AND boot.wim index 2 so Windows - # Setup can see the target disk on systems with unsupported NVMe or - # SATA controllers. - # ═════════════════════════════════════════════════════════════════════════ + # ── 2. Inject current system drivers (optional) ─────────────────────────── if ($InjectCurrentSystemDrivers) { & $Log "Exporting all drivers from running system..." $driverExportRoot = Join-Path $env:TEMP "WinUtil_DriverExport_$(Get-Random)" @@ -214,7 +175,6 @@ function Invoke-WinUtilISOScript { Add-DriversToImage -MountPath $ScratchDir -DriverDir $driverExportRoot -Label "install" -Logger $Log & $Log "install.wim driver injection complete." - # Also inject into boot.wim so Windows Setup can see the target disk. if ($ISOContentsDir -and (Test-Path $ISOContentsDir)) { $bootWim = Join-Path $ISOContentsDir "sources\boot.wim" if (Test-Path $bootWim) { @@ -233,17 +193,13 @@ function Invoke-WinUtilISOScript { & $Log "Driver injection skipped." } - # ═════════════════════════════════════════════════════════════════════════ - # 3. Remove OneDrive - # ═════════════════════════════════════════════════════════════════════════ + # ── 3. Remove OneDrive ──────────────────────────────────────────────────── & $Log "Removing OneDrive..." & takeown /f "$ScratchDir\Windows\System32\OneDriveSetup.exe" | Out-Null & icacls "$ScratchDir\Windows\System32\OneDriveSetup.exe" /grant "$($adminGroup.Value):(F)" /T /C | Out-Null Remove-Item -Path "$ScratchDir\Windows\System32\OneDriveSetup.exe" -Force -ErrorAction SilentlyContinue - # ═════════════════════════════════════════════════════════════════════════ - # 4. Registry tweaks - # ═════════════════════════════════════════════════════════════════════════ + # ── 4. Registry tweaks ──────────────────────────────────────────────────── & $Log "Loading offline registry hives..." reg load HKLM\zCOMPONENTS "$ScratchDir\Windows\System32\config\COMPONENTS" reg load HKLM\zDEFAULT "$ScratchDir\Windows\System32\config\default" @@ -292,19 +248,6 @@ function Invoke-WinUtilISOScript { Set-ISOScriptReg 'HKLM\zSOFTWARE\Microsoft\Windows\CurrentVersion\OOBE' 'BypassNRO' 'REG_DWORD' '1' if ($AutoUnattendXml) { - # ── Pre-stage embedded setup scripts directly into the WIM ──────────── - # The autounattend.xml (Schneegans generator format) embeds all setup - # scripts as nodes. The specialize-pass command that - # is supposed to extract them reads C:\Windows\Panther\unattend.xml, but - # Windows Setup strips unrecognised-namespace elements (including the - # entire block) when it copies the answer file to that path. - # As a result [scriptblock]::Create($null) throws, no scripts are written - # to C:\Windows\Setup\Scripts\, Specialize.ps1 and DefaultUser.ps1 never - # run, and FirstLogon.ps1 is absent so FirstLogonCommands silently fails. - # - # Writing the scripts directly into the WIM guarantees they are present - # on the target drive after Windows Setup applies the image, regardless - # of whether the Panther extraction step succeeds. try { $xmlDoc = [xml]::new() $xmlDoc.LoadXml($AutoUnattendXml) @@ -315,24 +258,18 @@ function Invoke-WinUtilISOScript { $fileNodes = $xmlDoc.SelectNodes("//sg:File", $nsMgr) if ($fileNodes -and $fileNodes.Count -gt 0) { foreach ($fileNode in $fileNodes) { - # Paths in the XML are absolute Windows paths (e.g. C:\Windows\Setup\Scripts\…). - # Strip the drive-letter prefix so we can root them under $ScratchDir. $absPath = $fileNode.GetAttribute("path") $relPath = $absPath -replace '^[A-Za-z]:[/\\]', '' $destPath = Join-Path $ScratchDir $relPath - $destDir = Split-Path $destPath -Parent + New-Item -Path (Split-Path $destPath -Parent) -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null - New-Item -Path $destDir -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null - - # Match the encoding logic used by the original ExtractScript. $ext = [IO.Path]::GetExtension($destPath).ToLower() $encoding = switch ($ext) { - { $_ -in '.ps1', '.xml' } { [System.Text.Encoding]::UTF8 } + { $_ -in '.ps1', '.xml' } { [System.Text.Encoding]::UTF8 } { $_ -in '.reg', '.vbs', '.js' } { [System.Text.UnicodeEncoding]::new($false, $true) } - default { [System.Text.Encoding]::Default } + default { [System.Text.Encoding]::Default } } - $bytes = $encoding.GetPreamble() + $encoding.GetBytes($fileNode.InnerText.Trim()) - [System.IO.File]::WriteAllBytes($destPath, $bytes) + [System.IO.File]::WriteAllBytes($destPath, ($encoding.GetPreamble() + $encoding.GetBytes($fileNode.InnerText.Trim()))) & $Log "Pre-staged setup script: $relPath" } } else { @@ -342,9 +279,6 @@ function Invoke-WinUtilISOScript { & $Log "Warning: could not pre-stage setup scripts from autounattend.xml: $_" } - # ── Place autounattend.xml at the ISO / USB root ────────────────────── - # Windows Setup reads this file first (before booting into the OS), - # which is what drives the local-account / OOBE bypass at install time. if ($ISOContentsDir -and (Test-Path $ISOContentsDir)) { $isoDest = Join-Path $ISOContentsDir "autounattend.xml" Set-Content -Path $isoDest -Value $AutoUnattendXml -Encoding UTF8 -Force @@ -387,8 +321,8 @@ function Invoke-WinUtilISOScript { Remove-ISOScriptReg 'HKLM\zSOFTWARE\Microsoft\WindowsUpdate\Orchestrator\UScheduler_Oobe\DevHomeUpdate' & $Log "Disabling Copilot..." - Set-ISOScriptReg 'HKLM\zSOFTWARE\Policies\Microsoft\Windows\WindowsCopilot' 'TurnOffWindowsCopilot' 'REG_DWORD' '1' - Set-ISOScriptReg 'HKLM\zSOFTWARE\Policies\Microsoft\Edge' 'HubsSidebarEnabled' 'REG_DWORD' '0' + Set-ISOScriptReg 'HKLM\zSOFTWARE\Policies\Microsoft\Windows\WindowsCopilot' 'TurnOffWindowsCopilot' 'REG_DWORD' '1' + Set-ISOScriptReg 'HKLM\zSOFTWARE\Policies\Microsoft\Edge' 'HubsSidebarEnabled' 'REG_DWORD' '0' Set-ISOScriptReg 'HKLM\zSOFTWARE\Policies\Microsoft\Windows\Explorer' 'DisableSearchBoxSuggestions' 'REG_DWORD' '1' & $Log "Disabling Windows Update during OOBE (re-enabled on first logon via FirstLogon.ps1)..." @@ -419,12 +353,9 @@ function Invoke-WinUtilISOScript { reg unload HKLM\zSOFTWARE reg unload HKLM\zSYSTEM - # ═════════════════════════════════════════════════════════════════════════ - # 5. Delete scheduled task definition files - # ═════════════════════════════════════════════════════════════════════════ + # ── 5. Delete scheduled task definition files ───────────────────────────── & $Log "Deleting scheduled task definition files..." $tasksPath = "$ScratchDir\Windows\System32\Tasks" - Remove-Item "$tasksPath\Microsoft\Windows\Application Experience\Microsoft Compatibility Appraiser" -Force -ErrorAction SilentlyContinue Remove-Item "$tasksPath\Microsoft\Windows\Customer Experience Improvement Program" -Recurse -Force -ErrorAction SilentlyContinue Remove-Item "$tasksPath\Microsoft\Windows\Application Experience\ProgramDataUpdater" -Force -ErrorAction SilentlyContinue @@ -436,12 +367,9 @@ function Invoke-WinUtilISOScript { Remove-Item "$tasksPath\Microsoft\Windows\WaaSMedic" -Recurse -Force -ErrorAction SilentlyContinue Remove-Item "$tasksPath\Microsoft\Windows\WindowsUpdate" -Recurse -Force -ErrorAction SilentlyContinue Remove-Item "$tasksPath\Microsoft\WindowsUpdate" -Recurse -Force -ErrorAction SilentlyContinue - & $Log "Scheduled task files deleted." - # ═════════════════════════════════════════════════════════════════════════ - # 6. Remove ISO support folder (fresh-install only; not needed) - # ═════════════════════════════════════════════════════════════════════════ + # ── 6. Remove ISO support folder ───────────────────────────────────────── if ($ISOContentsDir -and (Test-Path $ISOContentsDir)) { & $Log "Removing ISO support\ folder..." Remove-Item -Path (Join-Path $ISOContentsDir "support") -Recurse -Force -ErrorAction SilentlyContinue diff --git a/functions/private/Invoke-WinUtilISOUSB.ps1 b/functions/private/Invoke-WinUtilISOUSB.ps1 index 063694f8..81642b60 100644 --- a/functions/private/Invoke-WinUtilISOUSB.ps1 +++ b/functions/private/Invoke-WinUtilISOUSB.ps1 @@ -1,13 +1,9 @@ function Invoke-WinUtilISORefreshUSBDrives { - <# - .SYNOPSIS - Populates the USB drive ComboBox with all currently attached removable drives. - #> - $combo = $sync["WPFWin11ISOUSBDriveComboBox"] - $combo.Items.Clear() - + $combo = $sync["WPFWin11ISOUSBDriveComboBox"] $removable = Get-Disk | Where-Object { $_.BusType -eq "USB" } | Sort-Object Number + $combo.Items.Clear() + if ($removable.Count -eq 0) { $combo.Items.Add("No USB drives detected") $combo.SelectedIndex = 0 @@ -16,38 +12,26 @@ function Invoke-WinUtilISORefreshUSBDrives { } foreach ($disk in $removable) { - $sizeGB = [math]::Round($disk.Size / 1GB, 1) - $label = "Disk $($disk.Number): $($disk.FriendlyName) [$sizeGB GB] - $($disk.PartitionStyle)" - $combo.Items.Add($label) + $sizeGB = [math]::Round($disk.Size / 1GB, 1) + $combo.Items.Add("Disk $($disk.Number): $($disk.FriendlyName) [$sizeGB GB] - $($disk.PartitionStyle)") } $combo.SelectedIndex = 0 Write-Win11ISOLog "Found $($removable.Count) USB drive(s)." - - # Store disk objects for later use $sync["Win11ISOUSBDisks"] = $removable } function Invoke-WinUtilISOWriteUSB { - <# - .SYNOPSIS - Erases the selected USB drive and writes the modified Windows 11 ISO - content as a bootable installation drive (using DISM / robocopy approach). - #> $contentsDir = $sync["Win11ISOContentsDir"] $usbDisks = $sync["Win11ISOUSBDisks"] if (-not $contentsDir -or -not (Test-Path $contentsDir)) { - [System.Windows.MessageBox]::Show( - "No modified ISO content found. Please complete Steps 1-3 first.", - "Not Ready", "OK", "Warning") + [System.Windows.MessageBox]::Show("No modified ISO content found. Please complete Steps 1-3 first.", "Not Ready", "OK", "Warning") return } $selectedIndex = $sync["WPFWin11ISOUSBDriveComboBox"].SelectedIndex if ($selectedIndex -lt 0 -or -not $usbDisks -or $selectedIndex -ge $usbDisks.Count) { - [System.Windows.MessageBox]::Show( - "Please select a USB drive from the dropdown.", - "No Drive Selected", "OK", "Warning") + [System.Windows.MessageBox]::Show("Please select a USB drive from the dropdown.", "No Drive Selected", "OK", "Warning") return } @@ -71,9 +55,9 @@ function Invoke-WinUtilISOWriteUSB { $runspace.ApartmentState = "STA" $runspace.ThreadOptions = "ReuseThread" $runspace.Open() - $runspace.SessionStateProxy.SetVariable("sync", $sync) - $runspace.SessionStateProxy.SetVariable("diskNum", $diskNum) - $runspace.SessionStateProxy.SetVariable("contentsDir", $contentsDir) + $runspace.SessionStateProxy.SetVariable("sync", $sync) + $runspace.SessionStateProxy.SetVariable("diskNum", $diskNum) + $runspace.SessionStateProxy.SetVariable("contentsDir", $contentsDir) $script = [Management.Automation.PowerShell]::Create() $script.Runspace = $runspace @@ -87,6 +71,7 @@ function Invoke-WinUtilISOWriteUSB { $sync["WPFWin11ISOStatusLog"].ScrollToEnd() }) } + function SetProgress($label, $pct) { $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ $sync.progressBarTextBlock.Text = $label @@ -95,38 +80,25 @@ function Invoke-WinUtilISOWriteUSB { }) } + function Get-FreeDriveLetter { + $used = (Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue).Name + foreach ($c in [char[]](68..90)) { + if ($used -notcontains [string]$c) { return $c } + } + return $null + } + try { SetProgress "Formatting USB drive..." 10 - # ── Helper: find a free drive letter (D-Z) ────────────────────────── - function Get-FreeDriveLetter { - $used = (Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue).Name - foreach ($c in [char[]](68..90)) { # D..Z - if ($used -notcontains [string]$c) { return $c } - } - return $null - } - - # ── Phase 1: Clean the disk via diskpart ──────────────────────────── - # Only run "clean" here. "convert gpt" in diskpart requires the disk to - # already be MBR; after a clean the disk is RAW, so convert gpt fails on - # many systems. We use Initialize-Disk (which accepts RAW disks) instead. - $dpScript1 = @" -select disk $diskNum -clean -exit -"@ + # Phase 1: Clean disk via diskpart $dpFile1 = Join-Path $env:TEMP "winutil_diskpart_$(Get-Random).txt" - $dpScript1 | Set-Content -Path $dpFile1 -Encoding ASCII + "select disk $diskNum`nclean`nexit" | Set-Content -Path $dpFile1 -Encoding ASCII Log "Running diskpart clean on Disk $diskNum..." - $dpOut1 = diskpart /s $dpFile1 2>&1 + diskpart /s $dpFile1 2>&1 | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" } Remove-Item $dpFile1 -Force -ErrorAction SilentlyContinue - $dpOut1 | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" } - # ── Phase 2: Initialize as GPT via PowerShell ──────────────────────── - # After "clean", Windows may still see the disk as initialized (stale - # metadata). Initialize-Disk only accepts RAW disks; Set-Disk handles - # already-initialized (MBR/GPT) disks with no partitions. Try both. + # Phase 2: Initialize as GPT Start-Sleep -Seconds 2 Update-Disk -Number $diskNum -ErrorAction SilentlyContinue $diskObj = Get-Disk -Number $diskNum -ErrorAction Stop @@ -138,32 +110,19 @@ exit Log "Disk $diskNum converted to GPT (was $($diskObj.PartitionStyle))." } - # ── Phase 3: Create partitions via diskpart ────────────────────────── - # "create partition efi" is not supported on removable media. - # A single FAT32 primary partition is all that is needed for a UEFI- - # bootable Windows install USB - the firmware locates \EFI\Boot\bootx64.efi - # on any FAT32 volume regardless of GPT partition type. - # FAT32 label limit is 11 chars; "W11-yyMMdd" = 10, fits without trimming. + # Phase 3: Create FAT32 partition via diskpart $volLabel = "W11-" + (Get-Date).ToString('yyMMdd') - $dpScript2 = @" -select disk $diskNum -create partition primary -format quick fs=fat32 label="$volLabel" -exit -"@ - $dpFile2 = Join-Path $env:TEMP "winutil_diskpart2_$(Get-Random).txt" - $dpScript2 | Set-Content -Path $dpFile2 -Encoding ASCII + $dpFile2 = Join-Path $env:TEMP "winutil_diskpart2_$(Get-Random).txt" + "select disk $diskNum`ncreate partition primary`nformat quick fs=fat32 label=`"$volLabel`"`nexit" | + Set-Content -Path $dpFile2 -Encoding ASCII Log "Creating partitions on Disk $diskNum..." - $dpOut2 = diskpart /s $dpFile2 2>&1 + diskpart /s $dpFile2 2>&1 | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" } Remove-Item $dpFile2 -Force -ErrorAction SilentlyContinue - $dpOut2 | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" } SetProgress "Assigning drive letters..." 30 - Start-Sleep -Seconds 3 # allow Windows to settle after partition creation + Start-Sleep -Seconds 3 Update-Disk -Number $diskNum -ErrorAction SilentlyContinue - # ── Explicitly assign drive letters via PowerShell ─────────────────── - # This is reliable regardless of registry state, unlike diskpart assign. $partitions = Get-Partition -DiskNumber $diskNum -ErrorAction Stop Log "Partitions on Disk $diskNum after format: $($partitions.Count)" foreach ($p in $partitions) { @@ -171,12 +130,10 @@ exit } $winpePart = $partitions | Where-Object { $_.Type -eq "Basic" } | Select-Object -Last 1 - if (-not $winpePart) { throw "Could not find the WINPE (Basic) partition on Disk $diskNum after format." } - # Remove stale letter first (noops if none), then assign a fresh one try { Remove-PartitionAccessPath -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber -AccessPath "$($winpePart.DriveLetter):" -ErrorAction SilentlyContinue } catch {} $usbLetter = Get-FreeDriveLetter if (-not $usbLetter) { throw "No free drive letters (D-Z) available to assign to the USB data partition." } @@ -185,30 +142,22 @@ exit Start-Sleep -Seconds 2 $usbDrive = "${usbLetter}:" - if (-not (Test-Path $usbDrive)) { - throw "Drive $usbDrive is not accessible after letter assignment." - } + if (-not (Test-Path $usbDrive)) { throw "Drive $usbDrive is not accessible after letter assignment." } Log "USB data partition: $usbDrive" SetProgress "Copying Windows 11 files to USB..." 45 - # ── Copy files (split large install.wim if > 4 GB for FAT32) ── + # Copy files; split install.wim if > 4 GB (FAT32 limit) $installWim = Join-Path $contentsDir "sources\install.wim" if (Test-Path $installWim) { $wimSizeMB = [math]::Round((Get-Item $installWim).Length / 1MB) if ($wimSizeMB -gt 3800) { - # FAT32 limit – split with DISM - Log "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility...This will take several minutes." + Log "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility... This will take several minutes." $splitDest = Join-Path $usbDrive "sources\install.swm" New-Item -ItemType Directory -Path (Split-Path $splitDest) -Force | Out-Null - Split-WindowsImage -ImagePath $installWim ` - -SplitImagePath $splitDest ` - -FileSize 3800 -CheckIntegrity + Split-WindowsImage -ImagePath $installWim -SplitImagePath $splitDest -FileSize 3800 -CheckIntegrity Log "install.wim split complete." - - # Copy everything else (exclude install.wim) Log "Copying remaining files to USB..." - $robocopyArgs = @($contentsDir, $usbDrive, "/E", "/XF", "install.wim", "/NFL", "/NDL", "/NJH", "/NJS") - & robocopy @robocopyArgs + & robocopy $contentsDir $usbDrive /E /XF install.wim /NFL /NDL /NJH /NJS } else { & robocopy $contentsDir $usbDrive /E /NFL /NDL /NJH /NJS } @@ -218,7 +167,6 @@ exit SetProgress "Finalising USB drive..." 90 Log "Files copied to USB." - SetProgress "USB write complete" 100 Log "USB drive is ready for use." @@ -227,21 +175,17 @@ exit "USB drive created successfully!`n`nYou can now boot from this drive to install Windows 11.", "USB Ready", "OK", "Info") }) - } - catch { + } catch { Log "ERROR during USB write: $_" $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show( - "USB write failed:`n`n$_", - "USB Write Error", "OK", "Error") + [System.Windows.MessageBox]::Show("USB write failed:`n`n$_", "USB Write Error", "OK", "Error") }) - } - finally { + } finally { Start-Sleep -Milliseconds 800 $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync.progressBarTextBlock.Text = "" + $sync.progressBarTextBlock.Text = "" $sync.progressBarTextBlock.ToolTip = "" - $sync.ProgressBar.Value = 0 + $sync.ProgressBar.Value = 0 $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $true }) } diff --git a/scripts/main.ps1 b/scripts/main.ps1 index 1dcbb4bb..ced7ecb3 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -535,10 +535,7 @@ $sync["FontScalingApplyButton"].Add_Click({ # ── Win11ISO Tab button handlers ────────────────────────────────────────────── $sync["WPFTab5BT"].Add_Click({ - $sync["Form"].Dispatcher.BeginInvoke( - [System.Windows.Threading.DispatcherPriority]::Background, - [action]{ Invoke-WinUtilISOCheckExistingWork } - ) | Out-Null + $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null }) $sync["WPFWin11ISOBrowseButton"].Add_Click({ diff --git a/xaml/inputXML.xaml b/xaml/inputXML.xaml index 08e7c529..2d72472e 100644 --- a/xaml/inputXML.xaml +++ b/xaml/inputXML.xaml @@ -278,7 +278,6 @@ - - @@ -301,7 +299,6 @@ Background="Transparent" HorizontalAlignment="Left" VerticalAlignment="Center" Margin="6,3,2,3"/> -