VMPilot.GUI.ps1

# VMPilot - WPF GUI (dark, minimal)
# Spins up a fresh Hyper-V VM from a cached parent VHDX, collects the AutoPilot
# hardware hash, and optionally imports it to Intune via Microsoft Graph.
# Auto-elevates; hides host console; runs the workflow in a background runspace.

# --- Auto-elevate ---------------------------------------------------------
# Spawn the elevated child via Shell.Application.ShellExecute with show=0
# (SW_HIDE). Start-Process -WindowStyle Hidden hides the console AFTER it
# paints — produces a console flash before UAC, then another after acceptance.
# ShellExecute(verb='runas', show=0) creates the window in SW_HIDE from the
# start, so only the UAC prompt itself is visible.
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
    $psExe   = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh.exe' } else { 'powershell.exe' }
    $argLine = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f $PSCommandPath
    $shell   = New-Object -ComObject Shell.Application
    try {
        $shell.ShellExecute($psExe, $argLine, '', 'runas', 0)
    } finally {
        [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell)
    }
    [Environment]::Exit(0)
}

# --- Hide host console + DWM dark title bar -------------------------------
$nativeTypes = @'
using System;
using System.Runtime.InteropServices;
public static class NativeUtil {
    [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
    [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
    [DllImport("dwmapi.dll")] public static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize);
}
'@

try {
    Add-Type -TypeDefinition $nativeTypes -ErrorAction Stop
    $h = [NativeUtil]::GetConsoleWindow()
    if ($h -ne [IntPtr]::Zero) { [NativeUtil]::ShowWindow($h, 0) | Out-Null }
} catch { }

# --- WPF assemblies -------------------------------------------------------
Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase, System.Xaml

# --- Hyper-V startup check ------------------------------------------------
# Module ships via PSGallery; on a fresh install we can't assume Hyper-V is
# present. Detect early and offer to enable + reboot before any code path
# tries to call Get-VM.

function Show-VMPilotDialog {
    param(
        [Parameter(Mandatory)] [string]$Title,
        [Parameter(Mandatory)] [string]$Message,
        [string]$PrimaryText   = 'OK',
        [string]$SecondaryText,
        [string]$PrimaryColor  = '#0078D4'
    )
    $hasSecondary = -not [string]::IsNullOrWhiteSpace($SecondaryText)
    $secondaryXaml = if ($hasSecondary) {
@"
      <Button Grid.Column="0" x:Name="BtnSecondary" Content="$SecondaryText"
              Width="140" Height="36" Margin="0,0,8,0"
              Background="#2A2A2A" Foreground="#FFFFFF" BorderThickness="0"
              FontWeight="SemiBold" Cursor="Hand"/>
"@

    } else { '' }

    [xml]$x = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="$Title" Width="480" SizeToContent="Height"
        WindowStartupLocation="CenterScreen"
        Background="#161616" Foreground="#FFFFFF"
        FontFamily="Segoe UI Variable, Segoe UI" ResizeMode="NoResize">
  <Grid Margin="24">
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <TextBlock Grid.Row="0" Text="$Title" FontSize="20" FontWeight="SemiBold" Margin="0,0,0,12"/>
    <TextBlock Grid.Row="1" x:Name="Body" Foreground="#C0C0C0" FontSize="13"
               TextWrapping="Wrap" Margin="0,0,0,24"/>
    <Grid Grid.Row="2">
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition Width="Auto"/>
      </Grid.ColumnDefinitions>
$secondaryXaml
      <Button Grid.Column="2" x:Name="BtnPrimary" Content="$PrimaryText"
              Width="160" Height="36"
              Background="$PrimaryColor" Foreground="#FFFFFF" BorderThickness="0"
              FontWeight="SemiBold" Cursor="Hand"/>
    </Grid>
  </Grid>
</Window>
"@

    $dlg = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $x))
    $dlg.FindName('Body').Text = $Message
    $script:__vmpDlgChoice = 'Closed'
    $dlg.FindName('BtnPrimary').Add_Click({ $script:__vmpDlgChoice = 'Primary';   $dlg.Close() })
    if ($hasSecondary) {
        $dlg.FindName('BtnSecondary').Add_Click({ $script:__vmpDlgChoice = 'Secondary'; $dlg.Close() })
    }
    [void]$dlg.ShowDialog()
    return $script:__vmpDlgChoice
}

function Test-HyperVState {
    # Fast path #1: cmdlet present → feature is live
    if (Get-Command Get-VM -ErrorAction SilentlyContinue) { return 'Ready' }

    # Fast path #2: SKU check via WMI is sub-second. Pro / Enterprise /
    # Education / Workstations / Server all support Hyper-V; Home does not.
    # We prefer this over Get-WindowsOptionalFeature (which calls DISM and
    # routinely takes 15-45 seconds per feature lookup) and treat any
    # supported SKU as 'Disabled' if the cmdlet check above came back empty.
    # The enable step will surface any real DISM error if our assumption is
    # wrong — that's a 1-second cost vs. 45 seconds of unconditional probing.
    try {
        $caption = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).Caption
        if ($caption -notmatch 'Home' -and
            $caption -match 'Pro|Enterprise|Education|Workstation|Server') {
            return 'Disabled'
        }
    } catch { }

    return 'NotAvailable'
}

function Invoke-EnableHyperVWithProgress {
    # Shows a progress dialog while dism.exe enables Microsoft-Hyper-V-All in
    # the background. A DispatcherTimer polls the child process so the
    # indeterminate progress bar keeps animating. Returns
    # @{ Success = bool; Error = string }.
    [xml]$x = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Enabling Hyper-V" Width="440" SizeToContent="Height"
        WindowStartupLocation="CenterScreen"
        Background="#161616" Foreground="#FFFFFF"
        FontFamily="Segoe UI Variable, Segoe UI" ResizeMode="NoResize">
  <Grid Margin="24">
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <TextBlock Grid.Row="0" Text="Enabling Hyper-V" FontSize="20" FontWeight="SemiBold" Margin="0,0,0,12"/>
    <TextBlock Grid.Row="1" Foreground="#C0C0C0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,16"
               Text="This takes about a minute. Please don't close this window."/>
    <ProgressBar Grid.Row="2" IsIndeterminate="True" Height="6" Background="#1F1F1F" Foreground="#0078D4" BorderThickness="0"/>
  </Grid>
</Window>
"@

    $dlg = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $x))

    # Call dism.exe directly. The PowerShell Enable-WindowsOptionalFeature
    # cmdlet relies on DISM COM components that are sometimes misregistered
    # on IT-managed Enterprise machines (HRESULT 0x80040154 "Class not
    # registered"). dism.exe is a native binary that does the same work
    # without that dependency. Exit codes: 0 = success, 3010 = success +
    # reboot required (which is exactly what we expect for Hyper-V enable).
    $dismExe = Join-Path $env:WINDIR 'System32\dism.exe'
    $psi = New-Object System.Diagnostics.ProcessStartInfo
    $psi.FileName               = $dismExe
    $psi.Arguments              = '/Online /Enable-Feature /FeatureName:Microsoft-Hyper-V-All /All /NoRestart /Quiet'
    $psi.UseShellExecute        = $false
    $psi.RedirectStandardError  = $true
    $psi.RedirectStandardOutput = $true
    $psi.CreateNoWindow         = $true
    $proc = [System.Diagnostics.Process]::Start($psi)

    $script:__enableResult = $null
    $timer = New-Object System.Windows.Threading.DispatcherTimer
    $timer.Interval = [TimeSpan]::FromMilliseconds(500)
    $timer.Add_Tick({
        if ($proc.HasExited) {
            $timer.Stop()
            $stdoutText = $proc.StandardOutput.ReadToEnd().Trim()
            $stderrText = $proc.StandardError.ReadToEnd().Trim()
            if ($proc.ExitCode -in 0, 3010) {
                $script:__enableResult = @{ Success = $true; Error = $null }
            } else {
                $combined = (($stderrText, $stdoutText) -join "`n").Trim()
                $errMsg = if ($combined) { $combined } else { "dism.exe exit code $($proc.ExitCode)" }
                $script:__enableResult = @{ Success = $false; Error = $errMsg }
            }
            $dlg.Close()
        }
    })
    $timer.Start()

    [void]$dlg.ShowDialog()
    return $script:__enableResult
}

switch (Test-HyperVState) {
    'Ready' { } # continue to main GUI
    'NotAvailable' {
        [void](Show-VMPilotDialog -Title 'Hyper-V Not Available' `
            -Message ("Hyper-V isn't available on this edition of Windows. " +
                      "VM-Pilot requires Windows 10/11 Pro, Enterprise, or Education.`r`n`r`n" +
                      "Windows Home does not include Hyper-V.") `
            -PrimaryText 'OK')
        [Environment]::Exit(1)
    }
    'EnablePending' {
        $r = Show-VMPilotDialog -Title 'Reboot Required' `
            -Message "Hyper-V is enabled but a reboot is required before VM-Pilot can use it. Reboot now?" `
            -PrimaryText 'REBOOT NOW' -SecondaryText 'REBOOT LATER'
        if ($r -eq 'Primary') { Restart-Computer -Force }
        [Environment]::Exit(0)
    }
    'Disabled' {
        $r = Show-VMPilotDialog -Title 'Hyper-V Required' `
            -Message ("VM-Pilot needs Hyper-V to create virtual machines, but it's not enabled on this machine.`r`n`r`n" +
                      "Enable it now? A reboot is required after enable.") `
            -PrimaryText 'ENABLE HYPER-V' -SecondaryText 'CANCEL'
        if ($r -ne 'Primary') { [Environment]::Exit(0) }

        $result = Invoke-EnableHyperVWithProgress
        if (-not $result -or -not $result.Success) {
            $msg = if ($result) { $result.Error } else { 'Unknown error.' }
            [void](Show-VMPilotDialog -Title 'Enable Failed' `
                -Message "Failed to enable Hyper-V:`r`n`r`n$msg" -PrimaryText 'OK')
            [Environment]::Exit(1)
        }

        $r = Show-VMPilotDialog -Title 'Hyper-V Enabled' `
            -Message ("Hyper-V has been enabled successfully. A reboot is required to complete the installation.`r`n`r`n" +
                      "After reboot, run Start-VMPilot again to launch the GUI.`r`n`r`nReboot now?") `
            -PrimaryText 'REBOOT NOW' -SecondaryText 'REBOOT LATER'
        if ($r -eq 'Primary') { Restart-Computer -Force }
        [Environment]::Exit(0)
    }
}

# --- Constants ------------------------------------------------------------
$script:BootSource         = 'C:\VMs\Win11-25H2.vhdx'
# Prefer the builder vendored in the module folder; fall back to the legacy
# C:\Tools\WinVHDX\ location for users who installed the script there before
# the module wrapper existed.
$script:BuilderScript      = $(
    $localBuilder  = Join-Path $PSScriptRoot 'Get-Win11VHDX.ps1'
    $legacyBuilder = 'C:\Tools\WinVHDX\Get-Win11VHDX.ps1'
    if     (Test-Path $localBuilder)  { $localBuilder }
    elseif (Test-Path $legacyBuilder) { $legacyBuilder }
    else                              { $localBuilder }
)
$script:VMPath             = 'C:\VMs'
$script:FilesToCopy        = @('C:\Autopilot HWID Collection\AutoPilotHWID-Collection.bat')
$script:SearchPattern      = 'AutoPilotHWID*'
$script:SourceFolder       = 'HWID'
$script:DestinationPath    = 'C:\Autopilot HWID Collection'
# Community AutoPilot script — pre-cached on the host, injected onto each VM's VHDX
# at C:\ so the user can run it from OOBE Shift+F10 with a single short command.
$script:CommunityScriptUrl   = 'https://raw.githubusercontent.com/andrew-s-taylor/WindowsAutopilotInfo/main/Community%20Version/get-windowsautopilotinfocommunity.ps1'
$script:CommunityScriptCache = 'C:\Tools\VMPilot\Get-WindowsAutopilotInfoCommunity.ps1'
$script:CommunityScriptInVM  = 'Get-WindowsAutopilotInfoCommunity.ps1'   # lands at C:\<this>
$script:EnrollGuiInVM        = 'AutopilotEnroll.GUI.ps1'                 # lands at C:\<this>
$script:EnrollBatInVM        = 'import.bat'                              # lands at C:\import.bat — what the user runs at Shift+F10
$script:CollectScriptInVM    = 'VMPilotCollect.ps1'                      # Offline: lands at C:\<this>, called by SetupComplete.cmd
$script:IntuneAutopilotUrl   = 'https://intune.microsoft.com/#view/Microsoft_Intune_Enrollment/AutopilotDevices.ReactView/filterOnManualRemediationRequired~/false'

# --- XAML -----------------------------------------------------------------
[xml]$xaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="VM-Pilot"
        Width="600" Height="940"
        WindowStartupLocation="CenterScreen"
        Background="#161616"
        Foreground="#FFFFFF"
        FontFamily="Segoe UI Variable, Segoe UI"
        ResizeMode="CanMinimize"
        UseLayoutRounding="True"
        TextOptions.TextFormattingMode="Display"
        TextOptions.TextRenderingMode="ClearType">
 
  <Window.Resources>
    <Style TargetType="TextBox">
      <Setter Property="Background" Value="#1F1F1F"/>
      <Setter Property="Foreground" Value="#FFFFFF"/>
      <Setter Property="BorderBrush" Value="#3A3A3A"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="14,11"/>
      <Setter Property="FontSize" Value="14"/>
      <Setter Property="CaretBrush" Value="#FFFFFF"/>
      <Setter Property="SelectionBrush" Value="#0078D4"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="TextBox">
            <Border x:Name="Bd"
                    Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    CornerRadius="6">
              <ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter TargetName="Bd" Property="BorderBrush" Value="#5A5A5A"/>
              </Trigger>
              <Trigger Property="IsFocused" Value="True">
                <Setter TargetName="Bd" Property="BorderBrush" Value="#0078D4"/>
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
 
    <Style x:Key="Segment" TargetType="RadioButton">
      <Setter Property="Background" Value="#1F1F1F"/>
      <Setter Property="Foreground" Value="#A8A8A8"/>
      <Setter Property="BorderBrush" Value="#3A3A3A"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="FontSize" Value="14"/>
      <Setter Property="FontWeight" Value="SemiBold"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Focusable" Value="False"/>
      <Setter Property="HorizontalContentAlignment" Value="Center"/>
      <Setter Property="VerticalContentAlignment" Value="Center"/>
      <Setter Property="Height" Value="40"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="RadioButton">
            <Border x:Name="Bd"
                    Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    CornerRadius="6">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter TargetName="Bd" Property="Background" Value="#2A2A2A"/>
                <Setter TargetName="Bd" Property="BorderBrush" Value="#4A4A4A"/>
              </Trigger>
              <Trigger Property="IsChecked" Value="True">
                <Setter TargetName="Bd" Property="Background" Value="#0078D4"/>
                <Setter TargetName="Bd" Property="BorderBrush" Value="#0078D4"/>
                <Setter Property="Foreground" Value="#FFFFFF"/>
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
 
    <Style x:Key="PrimaryButton" TargetType="Button">
      <Setter Property="Background" Value="#0078D4"/>
      <Setter Property="Foreground" Value="#FFFFFF"/>
      <Setter Property="BorderThickness" Value="0"/>
      <Setter Property="FontSize" Value="15"/>
      <Setter Property="FontWeight" Value="SemiBold"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Height" Value="52"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="Button">
            <Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="8">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter TargetName="Bd" Property="Background" Value="#1F8AE0"/>
              </Trigger>
              <Trigger Property="IsPressed" Value="True">
                <Setter TargetName="Bd" Property="Background" Value="#0061B0"/>
              </Trigger>
              <Trigger Property="IsEnabled" Value="False">
                <Setter TargetName="Bd" Property="Background" Value="#2A2A2A"/>
                <Setter Property="Foreground" Value="#707070"/>
                <Setter Property="Cursor" Value="Arrow"/>
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
 
    <Style x:Key="FieldLabel" TargetType="TextBlock">
      <Setter Property="Foreground" Value="#909090"/>
      <Setter Property="FontSize" Value="11"/>
      <Setter Property="FontWeight" Value="SemiBold"/>
      <Setter Property="Margin" Value="2,0,0,8"/>
    </Style>
  </Window.Resources>
 
  <Grid Margin="32,28,32,28">
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/> <!-- Title -->
      <RowDefinition Height="Auto"/> <!-- Mode -->
      <RowDefinition Height="Auto"/> <!-- VM name -->
      <RowDefinition Height="Auto"/> <!-- CPU/RAM -->
      <RowDefinition Height="Auto"/> <!-- Online-only fields -->
      <RowDefinition Height="Auto"/> <!-- Button -->
      <RowDefinition Height="Auto"/> <!-- Divider -->
      <RowDefinition Height="*"/> <!-- Status + result -->
    </Grid.RowDefinitions>
 
    <!-- Title -->
    <StackPanel Grid.Row="0" Margin="0,0,0,22">
      <TextBlock Text="VM-Pilot" FontSize="26" FontWeight="SemiBold"/>
      <TextBlock Text="Spin up a fresh Hyper-V VM and collect the AutoPilot hardware hash." Foreground="#909090" FontSize="13" Margin="0,6,0,0"/>
    </StackPanel>
 
    <!-- Mode (left) + Win Release (right) -->
    <Grid Grid.Row="1" Margin="0,0,0,18">
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="20"/>
        <ColumnDefinition Width="*"/>
      </Grid.ColumnDefinitions>
 
      <StackPanel Grid.Column="0">
        <TextBlock Text="MODE" Style="{StaticResource FieldLabel}"/>
        <Grid>
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="6"/>
            <ColumnDefinition Width="*"/>
          </Grid.ColumnDefinitions>
          <RadioButton Grid.Column="0" x:Name="ModeOffline" GroupName="Mode" Content="Offline" IsChecked="True" Style="{StaticResource Segment}"/>
          <RadioButton Grid.Column="2" x:Name="ModeOnline" GroupName="Mode" Content="Online" Style="{StaticResource Segment}"/>
        </Grid>
      </StackPanel>
 
      <StackPanel Grid.Column="2">
        <TextBlock Text="WIN RELEASE" Style="{StaticResource FieldLabel}"/>
        <Grid>
          <RadioButton x:Name="Rel25H2" GroupName="Release" Content="25H2" IsChecked="True" IsEnabled="False" Style="{StaticResource Segment}"/>
        </Grid>
      </StackPanel>
    </Grid>
 
    <!-- VM name -->
    <StackPanel Grid.Row="2" Margin="0,0,0,18">
      <TextBlock Text="VM NAME" Style="{StaticResource FieldLabel}"/>
      <TextBox x:Name="VMNameBox" Text="ME1"/>
    </StackPanel>
 
    <!-- CPU/RAM -->
    <Grid Grid.Row="3" Margin="0,0,0,18">
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="20"/>
        <ColumnDefinition Width="*"/>
      </Grid.ColumnDefinitions>
 
      <StackPanel Grid.Column="0">
        <TextBlock Text="CPU CORES" Style="{StaticResource FieldLabel}"/>
        <Grid>
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="6"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="6"/>
            <ColumnDefinition Width="*"/>
          </Grid.ColumnDefinitions>
          <RadioButton Grid.Column="0" x:Name="Cpu1" GroupName="Cpu" Content="1" Style="{StaticResource Segment}"/>
          <RadioButton Grid.Column="2" x:Name="Cpu2" GroupName="Cpu" Content="2" IsChecked="True" Style="{StaticResource Segment}"/>
          <RadioButton Grid.Column="4" x:Name="Cpu4" GroupName="Cpu" Content="4" Style="{StaticResource Segment}"/>
        </Grid>
      </StackPanel>
 
      <StackPanel Grid.Column="2">
        <TextBlock Text="RAM (GB)" Style="{StaticResource FieldLabel}"/>
        <Grid>
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="6"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="6"/>
            <ColumnDefinition Width="*"/>
          </Grid.ColumnDefinitions>
          <RadioButton Grid.Column="0" x:Name="Ram4" GroupName="Ram" Content="4" IsChecked="True" Style="{StaticResource Segment}"/>
          <RadioButton Grid.Column="2" x:Name="Ram8" GroupName="Ram" Content="8" Style="{StaticResource Segment}"/>
          <RadioButton Grid.Column="4" x:Name="Ram16" GroupName="Ram" Content="16" Style="{StaticResource Segment}"/>
        </Grid>
      </StackPanel>
    </Grid>
 
    <!-- Group Tag (Offline only; toggled by Update-ModeUI). Online mode captures its
         own group tag inside the VM via AutopilotEnroll.GUI.ps1. -->
    <StackPanel Grid.Row="4" x:Name="GroupTagPanel" Margin="0,0,0,18">
      <TextBlock Text="GROUP TAG (OPTIONAL)" Style="{StaticResource FieldLabel}"/>
      <TextBox x:Name="GroupTagBox" Text=""/>
    </StackPanel>
 
    <!-- Primary button -->
    <Button Grid.Row="5" x:Name="RunButton" Content="COLLECT HWID" Style="{StaticResource PrimaryButton}" Margin="0,8,0,22"/>
 
    <!-- Divider -->
    <Border Grid.Row="6" Height="1" Background="#2A2A2A" Margin="0,0,0,22"/>
 
    <!-- Status + progress (top) + completion/serial (centered) + Cleanup link (bottom) -->
    <Grid Grid.Row="7">
      <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
        <RowDefinition Height="Auto"/>
      </Grid.RowDefinitions>
 
      <StackPanel Grid.Row="0">
        <TextBlock x:Name="StatusText"
                   Text=""
                   FontSize="14"
                   Foreground="#FFFFFF"
                   Margin="0,0,0,12"
                   TextWrapping="Wrap"/>
        <ProgressBar x:Name="ActivityBar"
                     Height="4"
                     IsIndeterminate="True"
                     Foreground="#0078D4"
                     Background="#252525"
                     BorderThickness="0"
                     Visibility="Collapsed"/>
      </StackPanel>
 
      <!-- Center stack: Complete (or red error) + the serial number block -->
      <StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center">
        <TextBlock x:Name="CompletedIcon" Text="Complete"
                   FontSize="26" FontWeight="SemiBold" Foreground="#1ACB5F"
                   HorizontalAlignment="Center" TextAlignment="Center"
                   Padding="0,2,0,4"
                   Visibility="Collapsed"/>
 
        <TextBlock x:Name="ResultText" Text=""
                   FontSize="13" Foreground="#F03A47" TextWrapping="Wrap"
                   HorizontalAlignment="Center" TextAlignment="Center"
                   Visibility="Collapsed"/>
 
        <!-- DEVICE SERIAL block: shown alongside Complete, auto-copied to clipboard -->
        <StackPanel x:Name="SerialPanel" Visibility="Collapsed" Margin="0,10,0,0">
          <TextBlock Text="DEVICE SERIAL" Style="{StaticResource FieldLabel}" HorizontalAlignment="Center"/>
          <TextBlock x:Name="SerialText" FontSize="14"
                     FontFamily="Cascadia Mono, Consolas, Courier New"
                     Foreground="#FFFFFF" HorizontalAlignment="Center" Margin="0,4,0,0"/>
          <TextBlock Text="copied to clipboard" FontSize="11"
                     Foreground="#707070" HorizontalAlignment="Center"
                     Margin="0,4,0,0" FontStyle="Italic"/>
        </StackPanel>
 
        <!-- HARDWARE HASH path: shown after an Offline collect. The .csv on the
             host holding the AutoPilot hardware hash; the link opens its folder. -->
        <StackPanel x:Name="HashPanel" Visibility="Collapsed" Margin="0,10,0,0">
          <TextBlock Text="HARDWARE HASH SAVED TO" Style="{StaticResource FieldLabel}" HorizontalAlignment="Center"/>
          <TextBlock x:Name="HashPathText" FontSize="11"
                     FontFamily="Cascadia Mono, Consolas, Courier New"
                     Foreground="#C0C0C0" HorizontalAlignment="Center" TextAlignment="Center"
                     TextWrapping="Wrap" Margin="0,4,0,0"/>
          <TextBlock HorizontalAlignment="Center" Margin="0,5,0,0">
            <Hyperlink x:Name="HashOpenLink" Foreground="#3F9BFE" TextDecorations="Underline">
              <Run Text="Open folder"/>
            </Hyperlink>
          </TextBlock>
        </StackPanel>
      </StackPanel>
 
      <!-- Bottom row: Open AutoPilot (left, blue) | Cleanup VMs (red) + Exit (gray) on right. -->
      <Grid Grid.Row="2" Margin="0,12,0,0">
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width="Auto"/>
          <ColumnDefinition Width="*"/>
          <ColumnDefinition Width="Auto"/>
          <ColumnDefinition Width="Auto"/>
        </Grid.ColumnDefinitions>
 
        <StackPanel Grid.Column="0" Orientation="Horizontal">
        <Button x:Name="IntuneButton" Content="OPEN AUTOPILOT"
                Width="148" Height="36"
                Background="#0078D4" Foreground="#FFFFFF" BorderThickness="0"
                FontSize="12" FontWeight="SemiBold" Cursor="Hand">
          <Button.Template>
            <ControlTemplate TargetType="Button">
              <Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="6">
                <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
              </Border>
              <ControlTemplate.Triggers>
                <Trigger Property="IsMouseOver" Value="True">
                  <Setter TargetName="Bd" Property="Background" Value="#1F8AE0"/>
                </Trigger>
                <Trigger Property="IsPressed" Value="True">
                  <Setter TargetName="Bd" Property="Background" Value="#0061B0"/>
                </Trigger>
              </ControlTemplate.Triggers>
            </ControlTemplate>
          </Button.Template>
        </Button>
 
        <Button x:Name="IsoWizardButton" Content="SETUP"
                Width="150" Height="36" Margin="8,0,0,0"
                Background="#107C41" Foreground="#FFFFFF" BorderThickness="0"
                FontSize="12" FontWeight="SemiBold" Cursor="Hand">
          <Button.Template>
            <ControlTemplate TargetType="Button">
              <Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="6">
                <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
              </Border>
              <ControlTemplate.Triggers>
                <Trigger Property="IsMouseOver" Value="True">
                  <Setter TargetName="Bd" Property="Background" Value="#138A48"/>
                </Trigger>
                <Trigger Property="IsPressed" Value="True">
                  <Setter TargetName="Bd" Property="Background" Value="#0B5A2F"/>
                </Trigger>
              </ControlTemplate.Triggers>
            </ControlTemplate>
          </Button.Template>
        </Button>
        </StackPanel>
 
        <Button Grid.Column="2" x:Name="CleanupButton" Content="CLEANUP VMs"
                Width="132" Height="36"
                Background="#F03A47" Foreground="#FFFFFF" BorderThickness="0"
                FontSize="12" FontWeight="SemiBold" Cursor="Hand">
          <Button.Template>
            <ControlTemplate TargetType="Button">
              <Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="6">
                <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
              </Border>
              <ControlTemplate.Triggers>
                <Trigger Property="IsMouseOver" Value="True">
                  <Setter TargetName="Bd" Property="Background" Value="#FF5560"/>
                </Trigger>
                <Trigger Property="IsPressed" Value="True">
                  <Setter TargetName="Bd" Property="Background" Value="#C92B37"/>
                </Trigger>
              </ControlTemplate.Triggers>
            </ControlTemplate>
          </Button.Template>
        </Button>
 
        <Button Grid.Column="3" x:Name="ExitButton" Content="EXIT"
                Width="84" Height="36" Margin="8,0,0,0"
                Background="#2A2A2A" Foreground="#FFFFFF" BorderThickness="0"
                FontSize="12" FontWeight="SemiBold" Cursor="Hand">
          <Button.Template>
            <ControlTemplate TargetType="Button">
              <Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="6">
                <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
              </Border>
              <ControlTemplate.Triggers>
                <Trigger Property="IsMouseOver" Value="True">
                  <Setter TargetName="Bd" Property="Background" Value="#3A3A3A"/>
                </Trigger>
                <Trigger Property="IsPressed" Value="True">
                  <Setter TargetName="Bd" Property="Background" Value="#1F1F1F"/>
                </Trigger>
              </ControlTemplate.Triggers>
            </ControlTemplate>
          </Button.Template>
        </Button>
      </Grid>
    </Grid>
  </Grid>
</Window>
"@


$reader = New-Object System.Xml.XmlNodeReader $xaml
$window = [Windows.Markup.XamlReader]::Load($reader)

$VMNameBox      = $window.FindName('VMNameBox')
$RunButton      = $window.FindName('RunButton')
$StatusText     = $window.FindName('StatusText')
$ActivityBar    = $window.FindName('ActivityBar')
$ResultText     = $window.FindName('ResultText')
$CompletedIcon  = $window.FindName('CompletedIcon')
$ModeOffline    = $window.FindName('ModeOffline')
$ModeOnline     = $window.FindName('ModeOnline')
$GroupTagBox    = $window.FindName('GroupTagBox')
$GroupTagPanel  = $window.FindName('GroupTagPanel')
$SerialPanel    = $window.FindName('SerialPanel')
$SerialText     = $window.FindName('SerialText')
$HashPanel      = $window.FindName('HashPanel')
$HashPathText   = $window.FindName('HashPathText')
$HashOpenLink   = $window.FindName('HashOpenLink')
$CleanupButton    = $window.FindName('CleanupButton')
$IntuneButton     = $window.FindName('IntuneButton')
$IsoWizardButton  = $window.FindName('IsoWizardButton')
$ExitButton       = $window.FindName('ExitButton')

# --- Dark title bar (DWM immersive dark mode) -----------------------------
$window.Add_SourceInitialized({
    try {
        $hwnd = (New-Object System.Windows.Interop.WindowInteropHelper $window).Handle
        $useDark = 1
        $r = [NativeUtil]::DwmSetWindowAttribute($hwnd, 20, [ref]$useDark, 4)
        if ($r -ne 0) { [NativeUtil]::DwmSetWindowAttribute($hwnd, 19, [ref]$useDark, 4) | Out-Null }
    } catch { }
})

# --- Mode toggle: swap button label + show/hide Group Tag (Offline only) --
function Update-ModeUI {
    if ($ModeOnline.IsChecked) {
        $RunButton.Content       = 'COLLECT & UPLOAD'
        $GroupTagPanel.Visibility = 'Collapsed'
    } else {
        $RunButton.Content       = 'COLLECT HWID'
        $GroupTagPanel.Visibility = 'Visible'
    }
}
$ModeOffline.Add_Checked({ Update-ModeUI })
$ModeOnline.Add_Checked({ Update-ModeUI })
Update-ModeUI   # apply initial state (Offline default → Group Tag visible)

# --- UI helpers -----------------------------------------------------------
function Set-Status {
    param([string]$Text)
    $window.Dispatcher.Invoke([Action]{ $StatusText.Text = $Text })
}
function Set-Result {
    # Error/warning text. Hides the success icon if it was showing.
    param([string]$Text, [string]$Color = '#F03A47')
    $window.Dispatcher.Invoke([Action]{
        $CompletedIcon.Visibility = 'Collapsed'
        $ResultText.Text          = $Text
        $ResultText.Foreground    = $Color
        $ResultText.Visibility    = 'Visible'
    })
}
function Set-Done {
    # Success state: drawn checkmark icon, no verbose text.
    $window.Dispatcher.Invoke([Action]{
        $StatusText.Text          = ''
        $ResultText.Text          = ''
        $ResultText.Visibility    = 'Collapsed'
        $CompletedIcon.Visibility = 'Visible'
    })
}
function Hide-CompletedIcon {
    $window.Dispatcher.Invoke([Action]{
        $CompletedIcon.Visibility = 'Collapsed'
        $SerialPanel.Visibility   = 'Collapsed'
        $SerialText.Text          = ''
        $HashPanel.Visibility     = 'Collapsed'
        $HashPathText.Text        = ''
    })
}
function Show-Serial {
    param([string]$Value)
    if ([string]::IsNullOrWhiteSpace($Value)) { return }
    $window.Dispatcher.Invoke([Action]{
        $SerialText.Text         = $Value
        $SerialPanel.Visibility  = 'Visible'
        try { [System.Windows.Clipboard]::SetText($Value) } catch { }
    })
}

function Get-CheckedRadio {
    param([int[]]$Values, [string]$Prefix, [int]$Default)
    foreach ($v in $Values) {
        $rb = $window.FindName("$Prefix$v")
        if ($rb -and $rb.IsChecked) { return $v }
    }
    return $Default
}

# Guided "Get Windows ISO" wizard. Walks the user through downloading a
# Windows 11 ISO from Microsoft's official page, then runs the builder on
# the ISO they picked (same code path as Get-Win11VHDX.ps1 -PickIso — the
# builder auto-detects the release from the ISO and names the VHDX
# C:\VMs\Win11-<release>.vhdx, exactly where the GUI looks for it). The
# build streams live progress into this window via a runspace + Dispatcher,
# mirroring Start-Workflow. The file picker runs here on the UI thread
# (owned by this window) rather than inside the runspace, so it can't pop
# up behind the wizard.
$script:WizRunspace  = $null
$script:WizPSInst    = $null
$script:WizApplyTimer = $null
function Show-Win11IsoWizard {
    $downloadUrl = 'https://www.microsoft.com/en-us/software-download/windows11'

    [xml]$x = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Get Windows 11 Install Media" Width="560" SizeToContent="Height"
        WindowStartupLocation="CenterOwner"
        Background="#161616" Foreground="#FFFFFF"
        FontFamily="Segoe UI Variable, Segoe UI" ResizeMode="NoResize">
  <Grid Margin="24">
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
 
    <TextBlock Grid.Row="0" Text="Get Windows 11 Install Media" FontSize="20" FontWeight="SemiBold" Margin="0,0,0,6"/>
    <TextBlock Grid.Row="1" Foreground="#C0C0C0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,16"
               Text="Download a Windows 11 ISO from Microsoft, then build the VM-Pilot parent VHDX from it. The VHDX is auto-named after the release inside the ISO."/>
 
    <Border Grid.Row="2" Background="#1B1B1B" CornerRadius="8" Padding="16,14" Margin="0,0,0,18">
      <StackPanel>
        <TextBlock Text="1. Click OPEN DOWNLOAD PAGE below." Foreground="#E0E0E0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,7"/>
        <TextBlock Text="2. Under &quot;Download Windows 11 Disk Image (ISO) for x64 devices&quot;, pick &quot;Windows 11 (multi-edition ISO for x64 devices)&quot; from the drop-down, then click Download." Foreground="#E0E0E0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,7"/>
        <TextBlock Text="3. In &quot;Select the product language&quot;, choose your language and click Confirm. (The page won't download yet - it prepares your link.)" Foreground="#E0E0E0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,7"/>
        <TextBlock Text="4. Click the &quot;64-bit Download&quot; button that now appears and save the .iso file. (The link is valid for 24 hours.)" Foreground="#E0E0E0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,7"/>
        <TextBlock Text="5. Once the download is complete, come back here, click BUILD VHDX FROM ISO, pick the file you saved, and wait for the build to finish." Foreground="#E0E0E0" FontSize="13" TextWrapping="Wrap"/>
      </StackPanel>
    </Border>
 
    <StackPanel Grid.Row="3" Orientation="Horizontal" Margin="0,0,0,8">
      <Button x:Name="BtnOpenPage" Content="OPEN DOWNLOAD PAGE"
              Width="200" Height="40"
              Background="#0078D4" Foreground="#FFFFFF" BorderThickness="0"
              FontSize="12" FontWeight="SemiBold" Cursor="Hand">
        <Button.Template>
          <ControlTemplate TargetType="Button">
            <Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="6">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True"><Setter TargetName="Bd" Property="Background" Value="#1F8AE0"/></Trigger>
              <Trigger Property="IsPressed" Value="True"><Setter TargetName="Bd" Property="Background" Value="#0061B0"/></Trigger>
              <Trigger Property="IsEnabled" Value="False"><Setter TargetName="Bd" Property="Background" Value="#2A2A2A"/><Setter Property="Foreground" Value="#707070"/></Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Button.Template>
      </Button>
 
      <Button x:Name="BtnBuild" Content="BUILD VHDX FROM ISO" Margin="10,0,0,0"
              Width="210" Height="40"
              Background="#107C41" Foreground="#FFFFFF" BorderThickness="0"
              FontSize="12" FontWeight="SemiBold" Cursor="Hand">
        <Button.Template>
          <ControlTemplate TargetType="Button">
            <Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="6">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True"><Setter TargetName="Bd" Property="Background" Value="#138A48"/></Trigger>
              <Trigger Property="IsPressed" Value="True"><Setter TargetName="Bd" Property="Background" Value="#0B5A2F"/></Trigger>
              <Trigger Property="IsEnabled" Value="False"><Setter TargetName="Bd" Property="Background" Value="#2A2A2A"/><Setter Property="Foreground" Value="#707070"/></Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Button.Template>
      </Button>
    </StackPanel>
 
    <StackPanel Grid.Row="4" Margin="0,8,0,0">
      <TextBlock x:Name="WizStatus" Foreground="#C0C0C0" FontSize="12" TextWrapping="Wrap"
                 Visibility="Collapsed" Margin="0,0,0,8"/>
      <ProgressBar x:Name="WizBar" Height="4" IsIndeterminate="True"
                   Foreground="#107C41" Background="#252525" BorderThickness="0"
                   Visibility="Collapsed"/>
    </StackPanel>
 
    <Button Grid.Row="5" x:Name="BtnClose" Content="CLOSE"
            Width="100" Height="32" HorizontalAlignment="Right" Margin="0,16,0,0"
            Background="#2A2A2A" Foreground="#C0C0C0" BorderThickness="0"
            FontWeight="SemiBold" Cursor="Hand"/>
  </Grid>
</Window>
"@

    $dlg = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $x))
    $dlg.Owner = $window

    $btnOpen  = $dlg.FindName('BtnOpenPage')
    $btnBuild = $dlg.FindName('BtnBuild')
    $btnClose = $dlg.FindName('BtnClose')
    $wizStatus = $dlg.FindName('WizStatus')
    $wizBar    = $dlg.FindName('WizBar')

    $btnOpen.Add_Click({
        try { Start-Process $downloadUrl } catch {
            $wizStatus.Visibility = 'Visible'
            $wizStatus.Foreground = '#F03A47'
            $wizStatus.Text = "Couldn't open the browser. Go to: $downloadUrl"
        }
    })

    $btnBuild.Add_Click({
        # Guard: if a parent VHDX already exists, warn BEFORE the file picker
        # and the build. Rebuilding replaces it, and it's blocked entirely
        # while a VM depends on it — so catch that here instead of after a
        # doomed build. Best-effort; if Hyper-V queries fail we still warn
        # about the file existing.
        $existing = @(Get-ChildItem 'C:\VMs\Win11-*.vhdx' -File -ErrorAction SilentlyContinue)
        if ($existing.Count -gt 0) {
            $deps = @()
            try {
                $targets = @($existing | ForEach-Object { [System.IO.Path]::GetFullPath($_.FullName) })
                foreach ($vm in (Get-VM -ErrorAction SilentlyContinue)) {
                    foreach ($d in (Get-VMHardDiskDrive -VM $vm -ErrorAction SilentlyContinue)) {
                        if (-not $d.Path) { continue }
                        $dpFull = [System.IO.Path]::GetFullPath($d.Path)
                        $info   = Get-VHD -Path $d.Path -ErrorAction SilentlyContinue
                        $parent = if ($info -and $info.ParentPath) { [System.IO.Path]::GetFullPath($info.ParentPath) } else { $null }
                        if (($targets -contains $dpFull) -or ($parent -and ($targets -contains $parent))) { $deps += $vm.Name; break }
                    }
                }
            } catch { }
            $deps = @($deps | Select-Object -Unique)

            $names = ($existing | ForEach-Object { $_.Name }) -join ', '
            $msg = "A parent VHDX already exists: $names`r`n`r`nRebuilding replaces it."
            if ($deps.Count) {
                $msg += "`r`n`r`nThese VM(s) depend on it and must be removed first (CLEANUP VMs), or the rebuild will be blocked:`r`n $($deps -join ', ')"
            }
            $msg += "`r`n`r`nRebuild anyway?"
            $ans = [System.Windows.MessageBox]::Show($dlg, $msg, 'Parent VHDX already exists',
                [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Warning)
            if ($ans -ne [System.Windows.MessageBoxResult]::Yes) { return }
        }

        $ofd = New-Object Microsoft.Win32.OpenFileDialog
        $ofd.Filter      = 'Windows ISO (*.iso)|*.iso|All files (*.*)|*.*'
        $ofd.Title       = 'Select the Windows 11 ISO you downloaded'
        $ofd.Multiselect = $false
        if (-not $ofd.ShowDialog($dlg)) { return }
        $isoPath = $ofd.FileName

        if (-not (Test-Path $script:BuilderScript)) {
            $wizStatus.Visibility = 'Visible'; $wizStatus.Foreground = '#F03A47'
            $wizStatus.Text = "Builder not found: $script:BuilderScript"
            return
        }

        # Lock the UI while building; a mid-build close would orphan the runspace.
        $btnBuild.IsEnabled = $false; $btnBuild.Content = 'BUILDING…'
        $btnOpen.IsEnabled  = $false; $btnClose.IsEnabled = $false
        $wizStatus.Visibility = 'Visible'; $wizStatus.Foreground = '#C0C0C0'; $wizStatus.Text = 'Starting build…'
        $wizBar.Visibility = 'Visible'; $wizBar.IsIndeterminate = $true

        # Shared state between the build runspace and the UI-thread apply timer.
        # Applying is flipped true only while Expand-WindowsImage runs; the timer
        # (below) polls ApplyDrive's used space against ApplyTotal for a real %
        # during that window. The builder fills ApplyDrive/ApplyTotal via its
        # "Apply target: <drive> <bytes>" line just before the apply.
        $wizState = [hashtable]::Synchronized(@{ Applying = $false; ApplyDrive = ''; ApplyTotal = [double]0 })

        $shared = @{
            Window        = $dlg
            Status        = $wizStatus
            Bar           = $wizBar
            BuildBtn      = $btnBuild
            OpenBtn       = $btnOpen
            CloseBtn      = $btnClose
            BuilderScript = $script:BuilderScript
            IsoPath       = $isoPath
            WizState      = $wizState
        }

        $rs = [runspacefactory]::CreateRunspace()
        $rs.ApartmentState = 'STA'; $rs.ThreadOptions = 'ReuseThread'; $rs.Open()
        foreach ($k in $shared.Keys) { $rs.SessionStateProxy.SetVariable($k, $shared[$k]) }
        $ps = [powershell]::Create(); $ps.Runspace = $rs
        $script:WizRunspace = $rs; $script:WizPSInst = $ps

        # Drive the apply percentage from a UI-thread timer. Expand-WindowsImage
        # reports progress through DISM's native callback, which does NOT land on
        # the runspace progress stream, so we can't read a % from the cmdlet.
        # Instead, once the builder announces its "Apply target: <drive> <bytes>",
        # poll that volume's used space against the total apply size every 1.5s
        # while Applying is true. Runs on the UI thread, so it touches $wizBar /
        # $wizStatus directly (no Dispatcher marshalling needed).
        $applyTimer = New-Object System.Windows.Threading.DispatcherTimer
        $applyTimer.Interval = [TimeSpan]::FromMilliseconds(1500)
        $applyTimer.add_Tick({
            try {
                if (-not $wizState.Applying -or -not $wizState.ApplyDrive -or $wizState.ApplyTotal -le 0) { return }
                $vol = Get-Volume -DriveLetter $wizState.ApplyDrive -ErrorAction SilentlyContinue
                if (-not $vol) { return }
                $used = [double]($vol.Size - $vol.SizeRemaining)
                $pct  = [int][Math]::Max(1, [Math]::Min(99, ($used / $wizState.ApplyTotal) * 100))
                $wizBar.IsIndeterminate = $false
                $wizBar.Maximum = 100
                $wizBar.Value   = $pct
                $wizStatus.Text = "Applying Windows image... $pct%"
                $wizStatus.Foreground = '#C0C0C0'
            } catch { }
        })
        $applyTimer.Start()
        $script:WizApplyTimer = $applyTimer

        $build = {
            function WSet { param([string]$t, [string]$c = '#C0C0C0')
                $Window.Dispatcher.Invoke([Action]{ $Status.Text = $t; $Status.Foreground = $c }) }
            function WBar { param([int]$p = -1)
                $Window.Dispatcher.Invoke([Action]{
                    if ($p -lt 0) { $Bar.IsIndeterminate = $true }
                    else { $Bar.IsIndeterminate = $false; $Bar.Maximum = 100; $Bar.Value = $p }
                }) }
            function WDone { param([bool]$ok, [string]$msg)
                $Window.Dispatcher.Invoke([Action]{
                    $Bar.Visibility = 'Collapsed'
                    $Status.Text = $msg
                    $Status.Foreground = $(if ($ok) { '#3FB950' } else { '#F03A47' })
                    if (-not $ok) {
                        # Failure: keep the wizard open so the user can read the
                        # error and retry the build.
                        $OpenBtn.IsEnabled  = $true
                        $CloseBtn.IsEnabled = $true
                        $BuildBtn.Content   = 'BUILD VHDX FROM ISO'
                        $BuildBtn.IsEnabled = $true
                    }
                }) }
            function WClose {
                # Success: let the user read the message, then auto-close so they
                # return to the GUI to build their first VM. BeginInvoke (async)
                # so this runspace thread does NOT block on the UI thread while
                # the dialog's Closing handler disposes this very runspace — a
                # synchronous Invoke here would deadlock.
                Start-Sleep -Milliseconds 2200
                $Window.Dispatcher.BeginInvoke([Action]{ $Window.Close() }) | Out-Null
            }

            $script:builtPath = $null
            try {
                # Same code path as Get-Win11VHDX.ps1 -PickIso, but the ISO was
                # already chosen on the UI thread, so feed it via -IsoPath. No
                # -OutVhdx → the builder auto-detects the release and names the
                # VHDX C:\VMs\Win11-<release>.vhdx.
                #
                # *>&1 (NOT 2>&1): the builder reports every phase via
                # Write-Host, which lands on the information stream. Inside a
                # runspace 2>&1 captures only errors, so the phase lines would
                # never reach this parser and the bar would sit frozen. *>&1
                # merges all streams so the status updates actually flow.
                & $BuilderScript -IsoPath $IsoPath *>&1 | ForEach-Object {
                    $line = "$_"
                    if     ($line -match 'Using supplied ISO')                 { WSet 'Using supplied ISO…'; WBar -1 }
                    elseif ($line -match 'Mounting ISO')                       { WSet 'Mounting ISO…'; WBar -1 }
                    elseif ($line -match 'Detected Windows 11 (\S+)')          { WSet "Detected Windows 11 $($Matches[1]) - building..." }
                    elseif ($line -match 'Output VHDX name set from image: (.+)$') { $script:builtPath = $Matches[1].Trim(); WSet "Target: $script:builtPath" }
                    elseif ($line -match 'Using image index')                  { WSet 'Reading install image…' }
                    elseif ($line -match 'Creating .*GB, dynamic')             { WSet 'Creating empty VHDX…'; WBar -1 }
                    elseif ($line -match '^Apply target: (\w) (\d+)')          { $WizState.ApplyDrive = $Matches[1]; $WizState.ApplyTotal = [double]$Matches[2] }
                    elseif ($line -match 'Applying image')                     { WSet 'Applying Windows image... 0%'; WBar 0; $WizState.Applying = $true }
                    elseif ($line -match 'DISM apply verified')                { $WizState.Applying = $false; WSet 'DISM apply verified - install.wim extracted cleanly.'; WBar -1 }
                    elseif ($line -match 'Writing UEFI')                       { WSet 'Writing UEFI boot files…' }
                    elseif ($line -match 'Boot files verified')               { WSet 'UEFI boot files verified.' }
                    elseif ($line -match 'Dismounting')                       { WSet 'Finalizing VHDX…' }
                    elseif ($line -match '^Done: (.+)$')                       { $script:builtPath = $Matches[1].Trim() }
                }
                if ($script:builtPath -and (Test-Path $script:builtPath)) {
                    WDone $true "Parent VHDX built. Build your first VM!"
                } else {
                    WDone $true 'Build your first VM!'
                }
                WClose
            } catch {
                WDone $false "Build failed: $($_.Exception.Message)"
            }
        }
        [void]$ps.AddScript($build)
        [void]$ps.BeginInvoke()
    })

    $btnClose.Add_Click({ $dlg.Close() })
    $dlg.Add_Closing({
        if ($script:WizApplyTimer) { try { $script:WizApplyTimer.Stop() } catch { }; $script:WizApplyTimer = $null }
        if ($script:WizPSInst)   { try { $script:WizPSInst.Stop() | Out-Null; $script:WizPSInst.Dispose() } catch { } }
        if ($script:WizRunspace) { try { $script:WizRunspace.Close(); $script:WizRunspace.Dispose() } catch { } }
        $script:WizPSInst = $null; $script:WizRunspace = $null
    })

    [void]$dlg.ShowDialog()
}

# --- Workflow runspace ----------------------------------------------------
$script:Runspace = $null
$script:PSInst   = $null

function Start-Workflow {
    $vmName   = $VMNameBox.Text.Trim()
    $cpu      = Get-CheckedRadio -Values 1,2,4   -Prefix 'Cpu' -Default 2
    $ramGB    = Get-CheckedRadio -Values 4,8,16  -Prefix 'Ram' -Default 4
    $online   = [bool]$ModeOnline.IsChecked
    $groupTag = $GroupTagBox.Text.Trim()
    # WIN RELEASE is fixed at 25H2 — the only supported Windows 11 release.
    $release    = '25H2'
    $bootSource = "C:\VMs\Win11-$release.vhdx"

    if ([string]::IsNullOrWhiteSpace($vmName)) {
        Set-Result -Text 'VM name cannot be empty.' -Color '#F03A47'
        return
    }

    # If no cached parent VHDX exists yet, send the user through the guided
    # "Get Windows 11 Install Media" wizard (download the ISO from Microsoft's
    # official page, then build the VHDX from it). The wizard is modal and
    # self-contained; it names the VHDX C:\VMs\Win11-<release>.vhdx. When it
    # returns, re-check — if the VHDX still isn't there the user cancelled or
    # the build failed, so bail before spawning the VM-creation runspace.
    if (-not (Test-Path $bootSource -PathType Leaf)) {
        Set-Status -Text 'No 25H2 parent VHDX yet — opening the Windows 11 install-media wizard…'
        Show-Win11IsoWizard
        if (-not (Test-Path $bootSource -PathType Leaf)) {
            Set-Status -Text 'Parent VHDX not built — cancelled.'
            return
        }
    }

    Hide-CompletedIcon
    $window.Dispatcher.Invoke([Action]{ $ResultText.Visibility = 'Collapsed'; $ResultText.Text = '' })
    Set-Status -Text 'Starting…'
    $RunButton.IsEnabled    = $false
    $RunButton.Content      = 'WORKING…'
    $ActivityBar.Visibility = 'Visible'

    # Online mode: VM lands at OOBE region screen (no unattend). We pre-inject
    # the community script to C:\Get-WindowsAutopilotInfoCommunity.ps1 in the VHDX.
    # User does SHIFT+F10 in vmconnect and runs ONE line:
    # powershell C:\Get-WindowsAutopilotInfoCommunity.ps1 -Online -Reboot [-GroupTag X] [-AssignedUser Y]
    # Community script handles Connect-MgGraph (browser sign-in), upload, assignment
    # poll, and -Reboot which returns the VM to OOBE → AutoPilot self-enrolls.
    # Device never leaves OOBE state.

    $sharedVars = @{
        VMName              = $vmName
        CpuCount            = $cpu
        RamGB               = $ramGB
        Online              = $online
        GroupTag            = $groupTag
        Release             = $release
        ScriptDir           = $PSScriptRoot
        BootSource      = $bootSource            # per-release override
        BuilderScript   = $script:BuilderScript
        VMPath          = $script:VMPath
        FilesToCopy     = $script:FilesToCopy
        SearchPattern   = $script:SearchPattern
        SourceFolder    = $script:SourceFolder
        DestinationPath = $script:DestinationPath
        CommunityScriptUrl   = $script:CommunityScriptUrl
        CommunityScriptCache = $script:CommunityScriptCache
        CommunityScriptInVM  = $script:CommunityScriptInVM
        EnrollGuiInVM        = $script:EnrollGuiInVM
        EnrollBatInVM        = $script:EnrollBatInVM
        CollectScriptInVM    = $script:CollectScriptInVM
        Window          = $window
        StatusText      = $StatusText
        ResultText      = $ResultText
        CompletedIcon   = $CompletedIcon
        SerialPanel     = $SerialPanel
        SerialText      = $SerialText
        HashPanel       = $HashPanel
        HashPathText    = $HashPathText
        RunButton       = $RunButton
        ActivityBar     = $ActivityBar
        ModeOnline      = $ModeOnline
    }

    $script:Runspace = [runspacefactory]::CreateRunspace()
    $script:Runspace.ApartmentState = 'STA'
    $script:Runspace.ThreadOptions  = 'ReuseThread'
    $script:Runspace.Open()
    foreach ($k in $sharedVars.Keys) {
        $script:Runspace.SessionStateProxy.SetVariable($k, $sharedVars[$k])
    }

    $script:PSInst = [powershell]::Create()
    $script:PSInst.Runspace = $script:Runspace

    $workflow = {
        function Set-Status {
            param([string]$Text)
            $Window.Dispatcher.Invoke([Action]{ $StatusText.Text = $Text })
        }
        function Set-Result {
            param([string]$Text, [string]$Color = '#F03A47')
            $Window.Dispatcher.Invoke([Action]{
                $CompletedIcon.Visibility = 'Collapsed'
                $ResultText.Text          = $Text
                $ResultText.Foreground    = $Color
                $ResultText.Visibility    = 'Visible'
            })
        }
        function Set-Done {
            $Window.Dispatcher.Invoke([Action]{
                $StatusText.Text          = ''
                $ResultText.Text          = ''
                $ResultText.Visibility    = 'Collapsed'
                $CompletedIcon.Visibility = 'Visible'
            })
        }
        function Show-Serial {
            param([string]$Value)
            if ([string]::IsNullOrWhiteSpace($Value)) { return }
            $Window.Dispatcher.Invoke([Action]{
                $SerialText.Text         = $Value
                $SerialPanel.Visibility  = 'Visible'
                try { [System.Windows.Clipboard]::SetText($Value) } catch { }
            })
        }
        function Show-HashPath {
            param([string]$Path)
            if ([string]::IsNullOrWhiteSpace($Path)) { return }
            $Window.Dispatcher.Invoke([Action]{
                $HashPathText.Text     = $Path
                $HashPanel.Visibility  = 'Visible'
            })
        }
        function Restore-Button {
            $Window.Dispatcher.Invoke([Action]{
                $RunButton.IsEnabled = $true
                if ($ModeOnline.IsChecked) { $RunButton.Content = 'COLLECT & UPLOAD' } else { $RunButton.Content = 'COLLECT HWID' }
                $ActivityBar.Visibility = 'Collapsed'
                $StatusText.Text = ''
            })
        }

        # Switch the progress bar between indeterminate and determinate.
        function Set-Progress {
            param([int]$Percent = -1)
            $Window.Dispatcher.Invoke([Action]{
                if ($Percent -lt 0) {
                    $ActivityBar.IsIndeterminate = $true
                } else {
                    $ActivityBar.IsIndeterminate = $false
                    $ActivityBar.Maximum         = 100
                    $ActivityBar.Value           = $Percent
                }
            })
        }



        try {
            # ===== VHDX template =====
            # The parent VHDX is built up-front via the "Get Windows 11 Install
            # Media" wizard (Show-Win11IsoWizard) on the UI thread before this
            # runspace spawns, so by now it must exist. Guard defensively.
            if (-not (Test-Path $BootSource -PathType Leaf)) {
                Set-Result -Text "Parent VHDX not found: $BootSource. Build it first via SETUP." -Color '#F03A47'
                Restore-Button
                return
            }
            Set-Status 'VHDX template ready (cached)'

            # ===== Create VM =====
            # Free any lingering handles on the parent VHDX before creating a
            # differencing-disk child. wimserv.exe (Windows Imaging Service)
            # commonly holds the parent open after DISM-apply finishes, which
            # makes the New-VM differencing-disk creation fail with
            # 0x80070020 "The process cannot access the file because it is
            # being used by another process".
            $stuck = Get-Process wimserv, wimlib-imagex, dism -ErrorAction SilentlyContinue
            if ($stuck) {
                Set-Status 'Releasing parent VHDX (closing wimserv handles)…'
                $stuck | Stop-Process -Force -ErrorAction SilentlyContinue
                Start-Sleep -Seconds 2
            }

            Set-Status 'Creating virtual machine…'
            if (Get-VM -Name $VMName -ErrorAction SilentlyContinue) {
                Set-Result -Text "A VM named '$VMName' already exists. Pick a different name." -Color '#F03A47'
                Restore-Button
                return
            }
            if (-not (Test-Path $VMPath)) { New-Item -Path $VMPath -ItemType Directory -Force | Out-Null }

            $sw = Get-VMSwitch | Where-Object Name -eq 'Default Switch' | Select-Object -First 1
            if (-not $sw) { $sw = Get-VMSwitch | Select-Object -First 1 }
            if (-not $sw) {
                Set-Result -Text 'No Hyper-V virtual switch found. Create one in Hyper-V Manager first.' -Color '#F03A47'
                Restore-Button
                return
            }

            if (-not (Get-Module -ListAvailable -Name HyperV.VMFactory)) {
                Set-Status 'Installing HyperV.VMFactory module…'
                if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) {
                    Install-PackageProvider -Name NuGet -Force -Scope CurrentUser | Out-Null
                }
                Install-Module -Name HyperV.VMFactory -Scope CurrentUser -Force -ErrorAction Stop
            }
            Import-Module HyperV.VMFactory -ErrorAction Stop

            New-HyperVVM -VMName $VMName `
                         -Path $VMPath `
                         -VMSwitch $sw.Name `
                         -VMGeneration 2 `
                         -VMProcessorCount $CpuCount `
                         -VMMemoryStartupBytes ([int64]$RamGB * 1GB) `
                         -ParentDisk $BootSource `
                         -ErrorAction Stop | Out-Null

            # Force the VM to boot from the hard disk first (not network).
            # New-HyperVVM doesn't always set this, and a Gen 2 VM with
            # Network as the first boot device will hang on "Start PXE
            # over IPv4" until the PXE attempt times out.
            try {
                $vmHd = Get-VMHardDiskDrive -VMName $VMName | Select-Object -First 1
                if ($vmHd) {
                    Set-VMFirmware -VMName $VMName -FirstBootDevice $vmHd -ErrorAction Stop
                }
            } catch {
                Set-Status "Warning: couldn't set boot order ($($_.Exception.Message)) — VM may try PXE first"
            }

            # ===== Inject mode-specific payload into the child VHDX =====
            # Online: pre-injected community AutoPilot script (VM stays at OOBE; user runs via Shift+F10)
            # Offline: collection bat + SetupComplete.cmd (VM auto-collects and shuts down)
            $childVhd = (Get-VMHardDiskDrive -VMName $VMName | Select-Object -First 1).Path

            if ($Online) {
                Set-Status 'Caching community AutoPilot script…'
                $cacheDir = Split-Path $CommunityScriptCache -Parent
                if (-not (Test-Path $cacheDir)) { New-Item -Path $cacheDir -ItemType Directory -Force | Out-Null }
                if (-not (Test-Path $CommunityScriptCache -PathType Leaf)) {
                    try {
                        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
                        Invoke-WebRequest -Uri $CommunityScriptUrl -OutFile $CommunityScriptCache -UseBasicParsing -ErrorAction Stop
                    } catch {
                        Set-Result -Text "Failed to download community script from GitHub: $($_.Exception.Message)"
                        Restore-Button
                        return
                    }
                }
                Set-Status 'Injecting community script into VM…'
            } else {
                Set-Status 'Injecting offline collection script into VM…'
                $collectSrc = Join-Path $ScriptDir $CollectScriptInVM
                if (-not (Test-Path $collectSrc -PathType Leaf)) {
                    Set-Result -Text "Collection script not found in repo at $collectSrc"
                    Restore-Button
                    return
                }
            }

            $mountFolder = Join-Path $env:TEMP "VMPilot-Inject-$(Get-Random)"
            New-Item -Path $mountFolder -ItemType Directory -Force | Out-Null
            # Disable Windows's global automount before Mount-VHD. The child
            # VHDX inherits the parent's multi-partition layout, and even
            # with -NoDriveLetter, Windows's automount service auto-assigns
            # letters to partitions it can read — including MSR partitions
            # with no filesystem, which trigger the "format disk in drive X:"
            # popup. mountvol /N suppresses automount globally; we restore
            # with /E in the finally block.
            & mountvol /N | Out-Null
            Mount-VHD -Path $childVhd -NoDriveLetter -ErrorAction Stop
            $partition = $null
            try {
                $vhdFile = Split-Path $childVhd -Leaf
                $disk = Get-Disk | Where-Object { $_.Location -like "*$vhdFile*" }
                $partition = $disk | Get-Partition | Sort-Object Size -Descending | Select-Object -First 1
                Add-PartitionAccessPath -InputObject $partition -AccessPath $mountFolder -ErrorAction Stop
                Start-Sleep -Seconds 1

                if ($Online) {
                    # Drop the community AutoPilot script + the in-VM enrollment GUI + a one-line .bat
                    # at C:\ on the VHDX. User runs `C:\Enroll.bat` from OOBE Shift+F10 → opens the
                    # in-VM GUI → user clicks Enroll → community script handles sign-in + upload +
                    # assignment + -Reboot which returns VM to OOBE → AutoPilot self-enrolls.
                    Copy-Item -Path $CommunityScriptCache -Destination (Join-Path $mountFolder $CommunityScriptInVM) -Force

                    $enrollGuiSrc = Join-Path $ScriptDir $EnrollGuiInVM
                    if (-not (Test-Path $enrollGuiSrc -PathType Leaf)) {
                        throw "In-VM enrollment GUI not found in repo at $enrollGuiSrc"
                    }
                    Copy-Item -Path $enrollGuiSrc -Destination (Join-Path $mountFolder $EnrollGuiInVM) -Force

                    # Pre-install NuGet + trust PSGallery so the community script's
                    # Install-Script call doesn't prompt the user. Then launch the GUI.
                    # NOTE: `|` is literal inside cmd "..." — do NOT escape with ^|, that
                    # gets passed to PowerShell verbatim and fails to parse.
                    $batContent = @"
@echo off
title VM-Pilot AutoPilot Import
echo Priming PowerShell package management (no interaction needed)...
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Confirm:`$false | Out-Null; Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue"
echo Launching AutoPilot Enrollment GUI...
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\$EnrollGuiInVM
"@

                    [IO.File]::WriteAllText((Join-Path $mountFolder $EnrollBatInVM), $batContent, [Text.UTF8Encoding]::new($false))
                } else {
                    # Offline: copy the collection script + generate SetupComplete.cmd that
                    # invokes it with -GroupTag (omitted if blank). Collection script writes
                    # the CSV with a 'Group Tag' column only when the value is non-empty.
                    $collectSrc = Join-Path $ScriptDir $CollectScriptInVM
                    Copy-Item -Path $collectSrc -Destination (Join-Path $mountFolder $CollectScriptInVM) -Force

                    $tagArg = if (-not [string]::IsNullOrWhiteSpace($GroupTag)) { " -GroupTag `"$GroupTag`"" } else { '' }
                    $setupContent = @"
@echo off
if not exist "C:\HWID" mkdir "C:\HWID"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\$CollectScriptInVM$tagArg > C:\HWID\collection.log 2>&1
shutdown /s /f /t 5
"@

                    $setupDir = Join-Path $mountFolder 'Windows\Setup\Scripts'
                    if (-not (Test-Path $setupDir)) { New-Item -Path $setupDir -ItemType Directory -Force | Out-Null }
                    [IO.File]::WriteAllText((Join-Path $setupDir 'SetupComplete.cmd'), $setupContent, [Text.UTF8Encoding]::new($false))
                }
            } finally {
                if ($partition) { Remove-PartitionAccessPath -InputObject $partition -AccessPath $mountFolder -ErrorAction SilentlyContinue }
                Dismount-VHD -Path $childVhd -ErrorAction SilentlyContinue
                Remove-Item $mountFolder -Force -Recurse -ErrorAction SilentlyContinue
                & mountvol /E | Out-Null  # restore Windows automount
            }

            # ===== Boot the VM and open vmconnect =====
            Set-Status 'Booting VM…'
            Start-VM -Name $VMName -ErrorAction Stop
            try { Start-Process vmconnect.exe -ArgumentList 'localhost', $VMName -ErrorAction Stop } catch { }

            # ===== Online mode: VM is at OOBE — user runs the community script via Shift+F10 =====
            # Community script handles upload + assignment + reboot-from-OOBE → AutoPilot enrolls.
            if ($Online) {
                # Query the VM's BIOS serial from Hyper-V settings so we can show it
                # in the GUI + clipboard (matches what Win32_BIOS will return inside the VM)
                try {
                    $ms = Get-CimInstance -Namespace 'root\virtualization\v2' `
                                          -ClassName 'Msvm_VirtualSystemSettingData' `
                                          -Filter "ElementName='$VMName' AND VirtualSystemType='Microsoft:Hyper-V:System:Realized'" `
                                          -ErrorAction SilentlyContinue
                    $bios = "$($ms.BIOSSerialNumber)"
                    if ($bios) { Show-Serial -Value $bios }
                } catch { }
                Set-Done
                Restore-Button
                return
            }

            Set-Status 'Collecting hardware hash inside VM…'
            $maxWait = 900; $elapsed = 0; $shutdown = $false
            while ($elapsed -lt $maxWait) {
                $state = (Get-VM -Name $VMName -ErrorAction SilentlyContinue).State
                if ($state -eq 'Off') { $shutdown = $true; break }
                Start-Sleep -Seconds 5
                $elapsed += 5
                if ($elapsed % 30 -eq 0) {
                    Set-Status "Collecting hardware hash inside VM… (${elapsed}s)"
                }
            }
            if (-not $shutdown) {
                Stop-VM -Name $VMName -TurnOff -Force -ErrorAction SilentlyContinue
            }

            # ===== Offline-only: wait for VM to self-shutdown, extract CSV, restart =====
            Set-Status 'Extracting CSV from VM…'
            $vhdPath = (Get-VM -Name $VMName).HardDrives | Select-Object -First 1 -ExpandProperty Path
            if ((Get-VM -Name $VMName).State -eq 'Running') {
                Stop-VM -Name $VMName -Force
                $t = 0; while ((Get-VM -Name $VMName).State -ne 'Off' -and $t -lt 60) { Start-Sleep -Seconds 1; $t++ }
                Start-Sleep -Seconds 3
            }

            $mountFolder = Join-Path $env:TEMP "VMPilot-Extract-$(Get-Random)"
            New-Item -Path $mountFolder -ItemType Directory -Force | Out-Null
            # Same automount suppression as the inject step — see comment there
            & mountvol /N | Out-Null
            Mount-VHD -Path $vhdPath -ReadOnly -NoDriveLetter -ErrorAction Stop
            $partition = $null
            $collected = $false
            try {
                $vhdFile = Split-Path $vhdPath -Leaf
                $disk = Get-Disk | Where-Object { $_.Location -like "*$vhdFile*" }
                $partition = $disk | Get-Partition | Where-Object { $_.Size -gt 50GB } | Select-Object -First 1
                Add-PartitionAccessPath -InputObject $partition -AccessPath $mountFolder -ErrorAction Stop
                Start-Sleep -Seconds 1

                $sourcePath = $mountFolder
                $searchPath = Join-Path $sourcePath $SourceFolder
                if (Test-Path $searchPath) { $sourcePath = $searchPath }
                $files = Get-ChildItem -Path $sourcePath -Filter $SearchPattern -Recurse -File -ErrorAction SilentlyContinue |
                         Sort-Object LastWriteTime -Descending

                if ($files.Count -gt 0) {
                    if (-not (Test-Path $DestinationPath)) { New-Item -Path $DestinationPath -ItemType Directory -Force | Out-Null }
                    $destCsv = Join-Path $DestinationPath $files[0].Name
                    Copy-Item -Path $files[0].FullName -Destination $destCsv -Force
                    $collected = $true
                    # Read serial from the CSV so we can surface it in the GUI + clipboard
                    try {
                        $row = Import-Csv -Path $destCsv | Select-Object -First 1
                        if ($row) { $script:CollectedSerial = "$($row.'Device Serial Number')" }
                    } catch { }
                }
            } finally {
                if ($partition) { Remove-PartitionAccessPath -InputObject $partition -AccessPath $mountFolder -ErrorAction SilentlyContinue }
                Dismount-VHD -Path $vhdPath -ErrorAction SilentlyContinue
                Remove-Item $mountFolder -Force -Recurse -ErrorAction SilentlyContinue
                & mountvol /E | Out-Null  # restore Windows automount
            }

            Start-VM -Name $VMName -ErrorAction SilentlyContinue

            if ($collected) {
                if ($script:CollectedSerial) { Show-Serial -Value $script:CollectedSerial }
                Show-HashPath -Path $destCsv
                Set-Done
            } else {
                Set-Result -Text 'No hash CSV found on the VM. Mount its VHDX and check C:\HWID\collection.log.' -Color '#F03A47'
            }

        } catch {
            Set-Result -Text "Error: $($_.Exception.Message)" -Color '#F03A47'
        } finally {
            Restore-Button
        }
    }

    [void]$script:PSInst.AddScript($workflow)
    [void]$script:PSInst.BeginInvoke()
}

# --- Wire up + cleanup ----------------------------------------------------
$RunButton.Add_Click({ Start-Workflow })

# Cleanup hyperlink: opens a modal dialog with all VMs listed for selective or bulk removal
function Show-CleanupDialog {
    [xml]$dlgXaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="VM Cleanup"
        Width="520" Height="560"
        WindowStartupLocation="CenterOwner"
        Background="#161616" Foreground="#FFFFFF"
        FontFamily="Segoe UI Variable, Segoe UI"
        ResizeMode="CanResize">
  <Grid Margin="24">
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="*"/>
      <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
 
    <TextBlock Grid.Row="0" Text="VM Cleanup" FontSize="22" FontWeight="SemiBold"/>
    <TextBlock Grid.Row="1" Foreground="#909090" FontSize="12" Margin="0,4,0,16" TextWrapping="Wrap"
               Text="Select VMs to remove. Each VM is stopped, deleted from Hyper-V, and its C:\VMs\&lt;name&gt; folder is wiped."/>
 
    <ListBox Grid.Row="2" x:Name="VmListBox"
             Background="#1F1F1F" Foreground="#FFFFFF" BorderBrush="#3A3A3A" BorderThickness="1"
             SelectionMode="Single"/>
 
    <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,16,0,0">
      <Button x:Name="BtnRemoveSelected" Content="REMOVE SELECTED" Width="170" Height="36" Margin="0,0,8,0"
              Background="#0078D4" Foreground="#FFFFFF" BorderThickness="0" FontWeight="SemiBold" Cursor="Hand"/>
      <Button x:Name="BtnRemoveAll" Content="REMOVE ALL" Width="120" Height="36" Margin="0,0,8,0"
              Background="#F03A47" Foreground="#FFFFFF" BorderThickness="0" FontWeight="SemiBold" Cursor="Hand"/>
      <Button x:Name="BtnClose" Content="CLOSE" Width="100" Height="36"
              Background="#2A2A2A" Foreground="#FFFFFF" BorderThickness="0" FontWeight="SemiBold" Cursor="Hand"/>
    </StackPanel>
  </Grid>
</Window>
"@

    $dlgReader = New-Object System.Xml.XmlNodeReader $dlgXaml
    $dlg = [Windows.Markup.XamlReader]::Load($dlgReader)
    $dlg.Owner = $window

    $VmListBox        = $dlg.FindName('VmListBox')
    $BtnRemoveSel     = $dlg.FindName('BtnRemoveSelected')
    $BtnRemoveAll     = $dlg.FindName('BtnRemoveAll')
    $BtnClose         = $dlg.FindName('BtnClose')

    function Update-VmList {
        $VmListBox.Items.Clear()
        $vms = Get-VM | Sort-Object Name
        if (-not $vms) {
            $tb = New-Object System.Windows.Controls.TextBlock
            $tb.Text = '(no VMs)'
            $tb.Foreground = '#707070'
            $tb.Padding = '8,12,8,12'
            [void]$VmListBox.Items.Add($tb)
            return
        }
        foreach ($vm in $vms) {
            $cb = New-Object System.Windows.Controls.CheckBox
            $cb.Content    = ('{0,-30} {1}' -f $vm.Name, $vm.State)
            $cb.Tag        = $vm.Name
            $cb.Foreground = '#FFFFFF'
            $cb.Margin     = '8,6,8,6'
            $cb.FontFamily = 'Cascadia Mono, Consolas, Courier New'
            $cb.FontSize   = 13
            [void]$VmListBox.Items.Add($cb)
        }
    }

    function Get-CheckedNames {
        $names = @()
        foreach ($item in $VmListBox.Items) {
            if ($item -is [System.Windows.Controls.CheckBox] -and $item.IsChecked) { $names += "$($item.Tag)" }
        }
        return ,$names
    }

    function Remove-VMs {
        param([string[]]$Names)
        foreach ($n in $Names) {
            Stop-VM   -Name $n -TurnOff -Force -ErrorAction SilentlyContinue
            Remove-VM -Name $n -Force -ErrorAction SilentlyContinue
            Remove-Item -LiteralPath "C:\VMs\$n" -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    $BtnRemoveSel.Add_Click({
        $names = Get-CheckedNames
        if ($names.Count -eq 0) {
            [void][System.Windows.MessageBox]::Show('No VMs are checked.', 'VM Cleanup',
                [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
            return
        }
        $msg = "Permanently remove $($names.Count) VM(s)?`r`n`r`n" + ($names -join "`r`n")
        $ans = [System.Windows.MessageBox]::Show($msg, 'Confirm Cleanup',
            [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Warning)
        if ($ans -ne [System.Windows.MessageBoxResult]::Yes) { return }
        Remove-VMs -Names $names
        Update-VmList
    })

    $BtnRemoveAll.Add_Click({
        $names = @()
        foreach ($item in $VmListBox.Items) {
            if ($item -is [System.Windows.Controls.CheckBox]) { $names += "$($item.Tag)" }
        }
        if ($names.Count -eq 0) { return }
        $msg = "Permanently remove ALL $($names.Count) VM(s)?`r`n`r`nThis includes VMs you did not create with VM-Pilot."
        $ans = [System.Windows.MessageBox]::Show($msg, 'Confirm Remove ALL',
            [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Warning)
        if ($ans -ne [System.Windows.MessageBoxResult]::Yes) { return }
        Remove-VMs -Names $names
        Update-VmList
    })

    $BtnClose.Add_Click({ $dlg.Close() })

    Update-VmList
    [void]$dlg.ShowDialog()
    Set-Status -Text ''
}

$CleanupButton.Add_Click({ Show-CleanupDialog })

# Guided ISO download + parent-VHDX build
$IsoWizardButton.Add_Click({ Show-Win11IsoWizard })

# "Open folder" under the saved hardware-hash path: select the .csv in Explorer.
# The path lives in the shared HashPathText element (set from the workflow
# runspace), so read it from there rather than a cross-runspace variable.
$HashOpenLink.Add_Click({
    $p = $HashPathText.Text
    if ($p -and (Test-Path $p)) { Start-Process explorer.exe "/select,`"$p`"" }
    elseif ($p) { Start-Process explorer.exe (Split-Path $p) }
})

# Open Intune AutoPilot devices page in the default browser
$IntuneButton.Add_Click({
    try { Start-Process $script:IntuneAutopilotUrl -ErrorAction Stop }
    catch { Set-Status -Text "Failed to open browser: $($_.Exception.Message)" }
})

# Exit: close the host GUI (Closing handler still cleans up runspace/PSInst)
$ExitButton.Add_Click({ $window.Close() })

$window.Add_Closing({
    if ($script:PSInst)   { try { $script:PSInst.Stop() | Out-Null; $script:PSInst.Dispose() } catch { } }
    if ($script:Runspace) { try { $script:Runspace.Close(); $script:Runspace.Dispose() } catch { } }
})

[void]$window.ShowDialog()