ict-autopilot.ps1
|
<#PSScriptInfo .VERSION 4.1.3 .GUID 1522f19a-667a-4638-8def-7d5590a61094 .AUTHOR a.twist@imperial.ac.uk .COMPANYNAME Imperial College London .COPYRIGHT .TAGS .LICENSEURI .PROJECTURI .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES #> <# .DESCRIPTION Enrols an ICT laptop into Autopilot, creates a CSV file for the hardware hash with various other tools #> ##### ## Imperial College London ## ----------------------- ## Enrolement script ## ##### ### # -- Bootstrap: ensure we are running under PowerShell 7+ -- if ($PSVersionTable.PSVersion.Major -lt 7) { $pwsh = "$env:ProgramFiles\PowerShell\7\pwsh.exe" if (-not (Test-Path $pwsh)) { Write-Host "PowerShell 7 not found - installing..." -ForegroundColor Cyan $msiPath = Join-Path $env:TEMP "PowerShell-7-win-x64.msi" $msiUrl = "https://github.com/PowerShell/PowerShell/releases/download/v7.4.7/PowerShell-7.4.7-win-x64.msi" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Invoke-WebRequest -Uri $msiUrl -OutFile $msiPath -UseBasicParsing $msiArgs = '/i', $msiPath, '/quiet', '/norestart', 'ADD_EXPLORER_CONTEXT_MENU_OPENPOWERSHELL=1', 'ADD_FILE_CONTEXT_MENU_RUNPOWERSHELL=1', 'ENABLE_PSREMOTING=0', 'REGISTER_MANIFEST=1', 'USE_MU=0', 'ENABLE_MU=0' Start-Process -FilePath 'msiexec.exe' -ArgumentList $msiArgs -Wait Remove-Item $msiPath -Force -ErrorAction SilentlyContinue if (-not (Test-Path $pwsh)) { Write-Host "ERROR: PowerShell 7 installation failed." -ForegroundColor Red $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") exit 1 } } Start-Process -FilePath $pwsh -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',"`"$PSCommandPath`"" -Wait exit $LASTEXITCODE } # -- WPF assemblies -- Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase Add-Type -AssemblyName System.Xaml # ============================================================================ # CONFIGURATION # ============================================================================ $script:AppVersion = '4.1.3' $script:AppTitle = 'ICT Autopilot Toolkit' $script:TempPath = Join-Path $env:TEMP 'ICT-Autopilot' $script:LogPath = Join-Path $script:TempPath 'logs' $script:Graph = @{ TenantId = '2b897507-ee8c-4575-830b-4f8267c3d307' ClientId = '5fa15279-2ada-4a13-b288-76929759e805' # SECURITY: Hard-coded Entra app secret (OWASP A02:2021). Rotate and move # to Windows Credential Manager / DPAPI as soon as possible. AppSecret = 'cfw8Q~PVAvzVxXV2DrUswXZtYg3KHQD2~Dca4bWm' } $script:GroupTags = @{ Production = 'PRODUCTION' Custom = 'CUSTOM' Lapsafe = 'LAPSAFE-DEVICE' SurfaceHub = 'MTR-ICT' } # ============================================================================ # WPF COLOUR / BRUSH HELPERS # ============================================================================ function New-Brush { param([byte]$R,[byte]$G,[byte]$B) [System.Windows.Media.SolidColorBrush]::new( [System.Windows.Media.Color]::FromRgb($R,$G,$B)) } $script:Palette = @{ Bg = New-Brush 243 243 243 Sidebar = New-Brush 255 255 255 Canvas = New-Brush 243 243 243 Card = New-Brush 255 255 255 CardHover = New-Brush 249 249 249 Accent = New-Brush 0 95 184 TextPri = New-Brush 26 26 26 TextSec = New-Brush 93 93 93 TextDim = New-Brush 138 138 138 Output = New-Brush 255 255 255 Divider = New-Brush 229 229 229 Ok = New-Brush 15 123 15 Warn = New-Brush 157 109 0 Bad = New-Brush 196 43 28 CatDeploy = New-Brush 0 95 184 CatDiag = New-Brush 13 148 136 CatSystem = New-Brush 234 88 12 } # ============================================================================ # UTILITIES # ============================================================================ function Initialize-WorkingPaths { foreach ($p in @($script:TempPath, $script:LogPath)) { if (-not (Test-Path $p)) { [void](New-Item -Path $p -ItemType Directory -Force) } } $script:LogFile = Join-Path $script:LogPath ("autopilot-{0}.log" -f (Get-Date -Format 'yyyyMMdd')) } function Get-TempFilePath { param([Parameter(Mandatory)][string]$FileName) Join-Path $script:TempPath $FileName } function Test-IsAdministrator { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator) } function Test-InternetConnectivity { try { (Test-NetConnection -ComputerName 'graph.microsoft.com' -Port 443 -WarningAction SilentlyContinue -InformationLevel Quiet) } catch { $false } } function Write-Log { param([string]$Message,[ValidateSet('INFO','WARN','FAIL','PASS','DEBUG')][string]$Level='INFO') if (-not $script:LogFile) { return } $line = "{0} [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message try { Add-Content -Path $script:LogFile -Value $line -ErrorAction SilentlyContinue } catch {} } # Bootstrap NuGet + PSGallery trust + scripts PATH once per session. # Call before Install-Module or Install-Script to prevent interactive prompts # that deadlock in a WPF host (no console) or Constrained Language Mode. function Initialize-PSGallery { if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { Install-PackageProvider -Name NuGet -Force -Scope CurrentUser | Out-Null } if ((Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue).InstallationPolicy -ne 'Trusted') { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted } # Ensure the scripts directory exists and is on PATH so Install-Script # never prompts "Add to PATH?" (that prompt hangs in a WPF host). $sd = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Scripts' if (-not (Test-Path $sd)) { New-Item -Path $sd -ItemType Directory -Force | Out-Null } if ($env:PATH -notlike "*$sd*") { $env:PATH += ";$sd" } } function Ensure-Module { param([Parameter(Mandatory)][string]$Name,[switch]$AllowClobber) if (-not (Get-Module -ListAvailable -Name $Name)) { Initialize-PSGallery $p = @{ Name=$Name; Force=$true; Scope='CurrentUser'; Confirm=$false; ErrorAction='Stop'; AcceptLicense=$true } if ($AllowClobber) { $p.AllowClobber = $true } Install-Module @p } Import-Module $Name -Force -Global -ErrorAction Stop | Out-Null } function Connect-IntuneGraph { Ensure-Module -Name Microsoft.Graph.Authentication Ensure-Module -Name Microsoft.Graph.Intune Ensure-Module -Name Microsoft.Graph.Identity.DirectoryManagement Ensure-Module -Name WindowsAutopilotIntune -AllowClobber Connect-MgGraph -ClientId $script:Graph.ClientId -TenantId $script:Graph.TenantId } # ============================================================================ # OUTPUT HELPERS (WPF RichTextBox / FlowDocument) # ============================================================================ function Invoke-OnUI { param([scriptblock]$Action) if ($script:Window -and $script:Window.Dispatcher) { $script:Window.Dispatcher.Invoke([Action]$Action) } else { & $Action } } # Pump the dispatcher so long-running synchronous loops still repaint. function Update-UI { if ($script:Window -and $script:Window.Dispatcher) { # Invoke an empty action at Background priority — forces all higher-priority # queued work (render, data-bind, input) to complete first. $script:Window.Dispatcher.Invoke( [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) } } function Update-OutputColored { param( [string]$Message, [System.Windows.Media.Brush]$Color = $script:Palette.TextPri, [switch]$Clear, [switch]$NoNewline ) Write-Log -Message $Message if (-not $script:OutputBox) { Write-Host $Message; return } Invoke-OnUI { if ($Clear) { $script:OutputBox.Document.Blocks.Clear() $script:OutputPara = New-Object System.Windows.Documents.Paragraph $script:OutputPara.Margin = [System.Windows.Thickness]::new(0) $script:OutputBox.Document.Blocks.Add($script:OutputPara) } if (-not $script:OutputPara) { $script:OutputPara = New-Object System.Windows.Documents.Paragraph $script:OutputPara.Margin = [System.Windows.Thickness]::new(0) $script:OutputBox.Document.Blocks.Add($script:OutputPara) } # Add leading newline if box already has content and caller didn't ask NoNewline if (-not $NoNewline -and $script:OutputPara.Inlines.Count -gt 0) { $script:OutputPara.Inlines.Add((New-Object System.Windows.Documents.LineBreak)) } $run = New-Object System.Windows.Documents.Run($Message) $run.Foreground = $Color $script:OutputPara.Inlines.Add($run) $script:OutputBox.ScrollToEnd() } Update-UI } function Out-Pass { param([string]$m) Update-OutputColored -Message " [PASS] $m" -Color $script:Palette.Ok } function Out-Fail { param([string]$m) Update-OutputColored -Message " [FAIL] $m" -Color $script:Palette.Bad } function Out-Warn { param([string]$m) Update-OutputColored -Message " [WARN] $m" -Color $script:Palette.Warn } function Out-Info { param([string]$m) Update-OutputColored -Message " [INFO] $m" -Color $script:Palette.CatDeploy } function Out-Dim { param([string]$m) Update-OutputColored -Message " $m" -Color $script:Palette.TextDim } function Out-Header { param([string]$m) $clean = $m.Trim() Update-OutputColored -Message " == $clean ==" -Color $script:Palette.Accent } function Out-Section { param([string]$m) Update-OutputColored -Message $m -Color $script:Palette.TextDim } function Update-Output { param([string]$message,[switch]$Clear) Update-OutputColored -Message $message -Clear:$Clear } function Clear-Output { Invoke-OnUI { $script:OutputBox.Document.Blocks.Clear() $script:OutputPara = New-Object System.Windows.Documents.Paragraph $script:OutputPara.Margin = [System.Windows.Thickness]::new(0) $script:OutputBox.Document.Blocks.Add($script:OutputPara) } } function Get-OutputText { $doc = $script:OutputBox.Document $range = New-Object System.Windows.Documents.TextRange($doc.ContentStart, $doc.ContentEnd) return $range.Text } # ============================================================================ # PROGRESS BAR HELPERS # ============================================================================ function Start-Busy { if ($script:ProgressBar) { Invoke-OnUI { $script:ProgressBar.Visibility = 'Visible' } } } function Stop-Busy { if ($script:ProgressBar) { Invoke-OnUI { $script:ProgressBar.Visibility = 'Collapsed' } } } # ============================================================================ # MESSAGE BOX HELPERS (WPF) # ============================================================================ function Show-Confirm { param([string]$Text,[string]$Caption='Confirm') [System.Windows.MessageBox]::Show( $script:Window, $Text, $Caption, [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Warning) -eq [System.Windows.MessageBoxResult]::Yes } function Show-Info { param([string]$Text,[string]$Caption='Information') [void][System.Windows.MessageBox]::Show( $script:Window, $Text, $Caption, [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information) } # ============================================================================ # ACTION FUNCTIONS (UI-agnostic except for Show-Confirm / Show-Info) # ============================================================================ function Get-BitLockerRecoveryKey { Out-Header "BITLOCKER RECOVERY KEY" if (-not (Test-IsAdministrator)) { Out-Fail "Please run as Administrator to retrieve the BitLocker recovery key."; return } try { Out-Dim "Querying BitLocker volume..." $key = Get-BitLockerVolume -MountPoint "C:" | Select-Object -ExpandProperty KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } if ($key) { Out-Pass "BitLocker Recovery Key found for C:"; Out-Info "Key: $($key.RecoveryPassword)" } else { Out-Warn "No BitLocker recovery key found for C: drive." } } catch { Out-Fail "Error retrieving BitLocker recovery key: $($_.Exception.Message)" } } function Get-AutopilotStatus { try { Clear-Output Out-Header "AUTOPILOT ENROLMENT STATUS - LOCAL DEVICE" Start-Busy $serial = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber Out-Info "Computer Name : $env:COMPUTERNAME" Out-Info "Serial Number : $serial" Out-Dim "Loading required modules..." Connect-IntuneGraph Out-Pass "Connected to Microsoft Graph" Out-Dim "Querying Autopilot for serial '$serial'..." $apDevice = Get-AutopilotDevice -serial $serial -ErrorAction Stop if (-not $apDevice) { Out-Section "-----------------------------------------------" Out-Fail "NOT ENROLLED - this device is not registered in Autopilot." Out-Section "-----------------------------------------------" return } Out-Pass "ENROLLED IN AUTOPILOT" Out-Header "IDENTIFIERS" $apId = if ($apDevice.id) { $apDevice.id } else { "N/A" } $apManagedId = if ($apDevice.managedDeviceId -and $apDevice.managedDeviceId -ne '00000000-0000-0000-0000-000000000000') { $apDevice.managedDeviceId } else { "Not managed" } $apEntraId = if ($apDevice.azureAdDeviceId -and $apDevice.azureAdDeviceId -ne '00000000-0000-0000-0000-000000000000') { $apDevice.azureAdDeviceId } else { "Not joined" } Out-Info "Autopilot Device ID : $apId" if ($apManagedId -eq 'Not managed') { Out-Warn "Managed Device ID : $apManagedId" } else { Out-Info "Managed Device ID : $apManagedId" } if ($apEntraId -eq 'Not joined') { Out-Warn "Entra (AAD) Device ID : $apEntraId" } else { Out-Info "Entra (AAD) Device ID : $apEntraId" } Out-Header "ENROLMENT DETAILS" $groupTag = if ($apDevice.groupTag) { $apDevice.groupTag } else { "(none)" } if ($groupTag -eq '(none)') { Out-Warn "Group Tag : $groupTag" } else { Out-Pass "Group Tag : $groupTag" } $po = if ($apDevice.purchaseOrderIdentifier) { $apDevice.purchaseOrderIdentifier } else { "(none)" } Out-Info "Purchase Order : $po" $enrolState = if ($apDevice.enrollmentState) { $apDevice.enrollmentState } else { "Unknown" } if ($enrolState -eq 'enrolled') { Out-Pass "Enrolment State : $enrolState" } else { Out-Warn "Enrolment State : $enrolState" } $lastContact = if ($apDevice.lastContactedDateTime) { $apDevice.lastContactedDateTime } else { "Never" } Out-Info "Last Contacted : $lastContact" Out-Header "DEPLOYMENT PROFILE" $profileName = if ($apDevice.deploymentProfileAssignmentStatus) { $apDevice.deploymentProfileAssignmentStatus } else { "Not assigned" } $profileDate = if ($apDevice.deploymentProfileAssignedDateTime) { $apDevice.deploymentProfileAssignedDateTime } else { "N/A" } $profileStatus = if ($apDevice.deploymentProfileAssignmentDetailedStatus){ $apDevice.deploymentProfileAssignmentDetailedStatus }else { "N/A" } $displayName = if ($apDevice.displayName) { $apDevice.displayName } else { $env:COMPUTERNAME } Out-Info "Device Display Name : $displayName" if ($profileName -match 'assigned') { Out-Pass "Profile Assignment Status : $profileName" } elseif ($profileName -in @('Not assigned','notAssigned')) { Out-Fail "Profile Assignment Status : $profileName" } else { Out-Warn "Profile Assignment Status : $profileName" } Out-Info "Profile Assigned Date : $profileDate" Out-Info "Profile Detailed Status : $profileStatus" Out-Header "HARDWARE" Out-Info ("Manufacturer : {0}" -f ($(if ($apDevice.manufacturer) { $apDevice.manufacturer } else { "Unknown" }))) Out-Info ("Model : {0}" -f ($(if ($apDevice.model) { $apDevice.model } else { "Unknown" }))) Out-Header "SUMMARY" $issues = @() if ($groupTag -eq '(none)') { $issues += "No Group Tag assigned" } if ($profileName -in @('Not assigned','notAssigned')) { $issues += "No Deployment Profile assigned" } if ($apManagedId -eq 'Not managed') { $issues += "Device not yet Intune-managed" } if ($apEntraId -eq 'Not joined') { $issues += "Device not yet Entra-joined" } if ($issues.Count -eq 0) { Out-Pass "All looks good - device is enrolled with a profile and group tag." } else { Out-Fail "Potential issues found:"; foreach ($i in $issues) { Out-Warn " - $i" } } Out-Section "===============================================" } catch { Out-Fail "Error querying Autopilot status: $($_.Exception.Message)" } finally { Stop-Busy } } function Test-Windows11Compatibility { try { Out-Header "WINDOWS 11 COMPATIBILITY CHECK" Out-Dim "Some brand new CPUs may incorrectly flag as Not Compatible" Start-Busy $cpuSources = @{ 'AMD-CPU.txt' = 'https://ictautopilot.blob.core.windows.net/win11compat/AMD-CPU.txt' 'INTEL-CPU.txt' = 'https://ictautopilot.blob.core.windows.net/win11compat/Intel-CPU.txt' 'SNAP-CPU.txt' = 'https://ictautopilot.blob.core.windows.net/win11compat/snap-CPU.txt' } foreach ($e in $cpuSources.GetEnumerator()) { $tp = Get-TempFilePath -FileName $e.Key $needs = -not (Test-Path $tp) -or ((Get-Date) - (Get-Item $tp).LastWriteTime).TotalDays -gt 7 if ($needs) { Invoke-WebRequest -Uri $e.Value -OutFile $tp -ErrorAction Stop } } $listIntel = Get-Content (Get-TempFilePath 'INTEL-CPU.txt') $listAMD = Get-Content (Get-TempFilePath 'AMD-CPU.txt') $listSNAP = Get-Content (Get-TempFilePath 'SNAP-CPU.txt') $procs = (Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty Name) $cpuOk = $false foreach ($p in $procs) { $list = if ($p -like '*Intel*') { $listIntel } elseif ($p -like '*AMD*') { $listAMD } else { $listSNAP } if ($list | Where-Object { $p -like "*$_*" } | Select-Object -First 1) { $cpuOk = $true; break } } Out-Header "RESULTS" if ($cpuOk) { Out-Pass "CPU: $($procs -join '; ')" } else { Out-Fail "CPU: $($procs -join '; ')" } $ramGB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2) if ($ramGB -ge 4) { Out-Pass "RAM: $ramGB GB (Min: 4 GB)" } else { Out-Fail "RAM: $ramGB GB (Min: 4 GB)" } $disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'" $diskGB = [math]::Round($disk.Size / 1GB, 2) if ($diskGB -ge 64) { Out-Pass "Storage: $diskGB GB (Min: 64 GB)" } else { Out-Fail "Storage: $diskGB GB (Min: 64 GB)" } $tpmOk = $false try { $tpm = Get-CimInstance -Namespace "root\CIMV2\Security\MicrosoftTpm" -ClassName Win32_Tpm $tpmOk = $tpm.SpecVersion -match "2.0" if ($tpmOk) { Out-Pass "TPM: 2.0" } else { Out-Fail "TPM: $($tpm.SpecVersion) (Required: 2.0)" } } catch { Out-Fail "TPM: Not detected" } try { $sb = Confirm-SecureBootUEFI -ErrorAction Stop } catch { $sb = $false } if ($sb) { Out-Pass "Secure Boot: Enabled" } else { Out-Fail "Secure Boot: Disabled" } Out-Header "OVERALL" if ($cpuOk -and ($ramGB -ge 4) -and ($diskGB -ge 64) -and $tpmOk -and $sb) { Out-Pass "Compatible with Windows 11" } else { Out-Fail "Not Compatible with Windows 11" } } catch { Out-Fail "Error checking Windows 11 compatibility: $_" } finally { Stop-Busy } } function Remove-DeviceFromCloud { try { Out-Header "REMOVE DEVICE FROM CLOUD" if (-not (Test-IsAdministrator)) { Out-Fail "Please run as Administrator to remove device from cloud services"; return } if (-not (Show-Confirm "Are you sure you want to remove this device from Autopilot, Intune, and Entra ID?`n`nThis action cannot be undone." "Confirm Device Removal")) { Out-Warn "Device removal cancelled by user."; return } Start-Busy Out-Dim "Starting device removal process..." $serial = (Get-CimInstance Win32_BIOS).SerialNumber $name = $env:COMPUTERNAME Out-Dim "Installing required PowerShell modules..." Connect-IntuneGraph Out-Pass "Connected" Out-Header "STEP 1/3: AUTOPILOT" try { $ap = Get-AutopilotDevice -serial $serial if ($ap) { Remove-AutopilotDevice -id $ap.id; Out-Pass "Successfully removed from Autopilot" } else { Out-Warn "Device not found in Autopilot" } } catch { Out-Fail "Error removing from Autopilot: $_" } Out-Header "STEP 2/3: INTUNE" try { $intune = Get-MgDeviceManagementManagedDevice -Filter "serialNumber eq '$serial'" if ($intune) { Remove-MgDeviceManagementManagedDevice -ManagedDeviceId $intune.Id; Out-Pass "Successfully removed from Intune" } else { Out-Warn "Device not found in Intune" } } catch { Out-Fail "Error removing from Intune: $_" } Out-Header "STEP 3/3: ENTRA ID" try { $aad = Get-MgDevice -Filter "displayName eq '$name'" if ($aad) { Remove-MgDevice -DeviceId $aad.Id; Out-Pass "Successfully removed from Entra ID" } else { Out-Warn "Device not found in Entra ID" } } catch { Out-Fail "Error removing from Entra ID: $_" } Out-Header "LOCAL CLEANUP" try { $ds = Start-Process "dsregcmd.exe" -ArgumentList "/leave" -PassThru -Wait if ($ds.ExitCode -eq 0) { Out-Pass "Local device state cleaned"; Out-Info "It will take around 5 minutes to show this deletion in Intune" } else { Out-Warn "Local device state cleanup may not be complete" } } catch { Out-Fail "Error cleaning local device state: $_" } Show-Info "Device removal process completed. Please restart your device to complete the process." "Removal Complete" } catch { Out-Fail "Error in device removal process: $_" } finally { Stop-Busy } } function Install-PowerShell7 { try { Out-Header "INSTALL POWERSHELL 7" Start-Busy Out-Dim "Downloading PowerShell 7 installer..." $installer = Get-TempFilePath "PowerShell-7.msi" Invoke-WebRequest -Uri "https://aka.ms/powershell-release?tag=stable" -OutFile $installer Out-Dim "Running installer..." Start-Process msiexec.exe -ArgumentList "/i `"$installer`" /qn" -Wait Out-Pass "PowerShell 7 installation completed." } catch { Out-Fail "Error installing PowerShell 7: $_" } finally { Stop-Busy } } function Restart-PCWithCountdown { try { Out-Header "RESTART PC" if (-not (Test-IsAdministrator)) { Out-Fail "Please run as Administrator to restart the PC"; return } Out-Warn "System will restart in 5 seconds..." for ($i = 5; $i -gt 0; $i--) { Out-Warn "Restarting in $i seconds..."; Start-Sleep -Seconds 1 } Restart-Computer -Force } catch { Out-Fail "Error initiating restart: $_" } } function Test-AutopilotRequirements { [CmdletBinding()] param() $req = @{ OSVersion=$false; OSEdition=$false; TPM=$false; AllPassed=$false } try { Out-Header "Checking Windows version..." $os = Get-CimInstance Win32_OperatingSystem $build = [int]$os.BuildNumber if ($build -ge 17134) { Out-Pass "Windows build $build meets minimum (17134)"; $req.OSVersion=$true } else { Out-Fail "Windows build $build does not meet minimum (17134)" } Out-Header "`nChecking Windows Edition..." if ($os.Caption -match "(Enterprise|Professional|Education|Pro)") { Out-Pass "Windows edition ($($os.Caption)) is supported"; $req.OSEdition=$true } else { Out-Fail "Windows edition ($($os.Caption)) is not supported" } Out-Header "`nChecking TPM 2.0..." try { $tpm = Get-Tpm if ($tpm.TpmPresent -and $tpm.TpmReady -and -not ($tpm.ManufacturerVersionFull20 -match "not supported")) { Out-Pass "TPM 2.0 is present and ready"; $req.TPM=$true } else { Out-Fail "TPM is not present, not ready, or not 2.0" } } catch { Out-Fail "Error checking TPM status: $_" } $req.AllPassed = $req.OSVersion -and $req.OSEdition -and $req.TPM Out-Section "`nRequirements Summary:" if ($req.OSVersion) { Out-Pass "Windows Version: True" } else { Out-Fail "Windows Version: False" } if ($req.OSEdition) { Out-Pass "Windows Edition: True" } else { Out-Fail "Windows Edition: False" } if ($req.TPM) { Out-Pass "TPM 2.0: True" } else { Out-Fail "TPM 2.0: False" } if ($req.AllPassed) { Out-Pass "`nAll requirements met. Device is ready for Autopilot enrollment." } else { Out-Fail "`nDevice does not meet all requirements for Autopilot enrollment." } return $req.AllPassed } catch { Out-Fail "Error checking requirements: $_"; return $false } } function Reset-Device { try { if (-not (Test-IsAdministrator)) { Out-Warn "Please run as Administrator to reset the device"; return } Out-Info "Resetting device..." if (Show-Confirm "Are you sure you want to reset the device?" "Confirm Reset") { systemreset -factoryreset } else { Out-Dim "Device reset cancelled." } } catch { Out-Fail "Error resetting device: $_" } } function Test-Enrolment { $status = @{ IsEnrolled=$false; SerialNumber=$null; Error=$null } try { Out-Info "Checking required modules..." Connect-IntuneGraph Out-Info "Getting device serial number..." $serial = (Get-CimInstance Win32_BIOS -ErrorAction Stop).SerialNumber if ([string]::IsNullOrEmpty($serial)) { throw "Unable to retrieve device serial number" } $status.SerialNumber = $serial Out-Info "Checking Autopilot enrollment status..." if (Get-AutopilotDevice -serial $serial -ErrorAction Stop) { $status.IsEnrolled = $true Out-Pass "Device $serial is already enrolled in Autopilot" } else { Out-Warn "Device $serial is not enrolled in Autopilot" } return $status } catch { $status.Error = "Error in Test-Enrolment: $($_.Exception.Message)" Out-Fail $status.Error return $status } } function Invoke-AutopilotEnrolment { param( [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$GroupTag, [string]$FriendlyName = 'Device' ) if (-not (Test-AutopilotRequirements)) { Out-Fail "Cannot proceed - requirements not met."; return } $st = Test-Enrolment if ($st.Error) { Out-Fail "Error checking enrollment: $($st.Error)"; return } if ($st.IsEnrolled) { Out-Warn "Device $($st.SerialNumber) is already enrolled."; return } if (-not (Test-IsAdministrator)) { Out-Warn "Please run as Administrator to enroll in Autopilot"; return } try { Start-Busy Out-Info "Installing Autopilot PowerShell module..." Update-UI Initialize-PSGallery Install-Script -Name Get-WindowsAutoPilotInfo -Force -AcceptLicense -Scope CurrentUser -ErrorAction Stop Update-UI Out-Info "Enrolling $FriendlyName (Group Tag: $GroupTag)..." Update-UI Get-WindowsAutoPilotInfo ` -tenantid $script:Graph.TenantId ` -appid $script:Graph.ClientId ` -appsecret $script:Graph.AppSecret ` -GroupTag $GroupTag -online -assign Out-Pass "$FriendlyName successfully enrolled in Autopilot. Allow ~15 minutes to register." } catch { Out-Fail "Error enrolling device: $_" } finally { Stop-Busy } } function New-AutopilotCsv { try { Out-Info "[DEBUG] Entry" if (-not (Test-IsAdministrator)) { Out-Warn "Please run as Administrator to create an Autopilot CSV"; return } Out-Info "[DEBUG] Start-Busy" Start-Busy $serial = (Get-CimInstance Win32_BIOS -ErrorAction Stop).SerialNumber $csv = Get-TempFilePath "AutopilotHWID_$serial.csv" Out-Info "[DEBUG] About to install script" Out-Info "Installing Autopilot PowerShell script (please wait)..." Update-UI Out-Info "[DEBUG] Initialize-PSGallery" Initialize-PSGallery Out-Info "[DEBUG] Downloading Get-WindowsAutoPilotInfo.ps1 directly..." $scriptName = 'Get-WindowsAutoPilotInfo.ps1' $scriptsDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Scripts' $scriptPath = Join-Path $scriptsDir $scriptName $galleryUrl = 'https://www.powershellgallery.com/api/v2/package/Get-WindowsAutoPilotInfo' $tmpZip = [System.IO.Path]::GetTempFileName() + '.nupkg' try { # Download the NuPkg (zip) from PSGallery Invoke-WebRequest -Uri $galleryUrl -OutFile $tmpZip -UseBasicParsing -ErrorAction Stop Out-Info "[DEBUG] Downloaded NuPkg" # Extract the script from the NuPkg (search all entries) Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [System.IO.Compression.ZipFile]::OpenRead($tmpZip) $entry = $zip.Entries | Where-Object { $_.FullName -imatch "$scriptName$" } if (-not $entry) { $all = $zip.Entries | ForEach-Object { $_.FullName } Out-Fail "Could not find $scriptName in NuPkg. Entries: $($all -join ', ')" $zip.Dispose() Remove-Item $tmpZip -Force return } # CLM-safe extraction: copy stream manually $inStream = $entry.Open() $outStream = [System.IO.File]::Open($scriptPath, [System.IO.FileMode]::Create) $buffer = New-Object byte[] 8192 while (($read = $inStream.Read($buffer, 0, $buffer.Length)) -gt 0) { $outStream.Write($buffer, 0, $read) } $outStream.Close() $inStream.Close() $zip.Dispose() Remove-Item $tmpZip -Force Unblock-File -Path $scriptPath -ErrorAction SilentlyContinue Out-Info "[DEBUG] Script downloaded to $scriptPath" } catch { Out-Fail "Failed to download $($scriptName): $($_)" return } Update-UI Out-Info "[DEBUG] Before Get-WindowsAutoPilotInfo" Out-Info "Creating Autopilot CSV at $csv ..." Update-UI . $scriptPath Get-WindowsAutoPilotInfo -OutputFile $csv Out-Info "[DEBUG] After Get-WindowsAutoPilotInfo" Out-Pass "Hardware hash saved to $csv" # Add whitespace and accent color for the tip Update-OutputColored -Message "`n`nYou can open the folder containing the CSV by clicking the 'Open Temp' button at the top." -Color $script:Palette.Accent } catch { Out-Fail "Error creating CSV: $_" } finally { Stop-Busy } } function Invoke-WindowsUpdateRun { try { if (-not (Test-IsAdministrator)) { Out-Warn "Please run as Administrator to perform Windows Update"; return } Start-Busy Out-Info "Installing required PowerShell modules..." Ensure-Module -Name PSWindowsUpdate Import-Module PSWindowsUpdate Out-Info "Checking for Windows Updates..." $updates = @(Get-WindowsUpdate) if ($updates.Count -eq 0) { Out-Pass "No Windows Updates are available."; return } Out-Warn "Found $($updates.Count) update(s). Starting installation..." Get-WindowsUpdate -Install -AcceptAll -AutoReboot | ForEach-Object { Out-Info "Installing update: $($_.Title)`nStatus: $($_.Status)" } Out-Pass "Windows Update completed." } catch { Out-Fail "Error running Windows Update: $_" } finally { Stop-Busy } } function Get-SystemInfo { Out-Info "Collecting system information, please wait..." try { $ci = Get-ComputerInfo $os = (Get-CimInstance Win32_OperatingSystem).Caption $cpu = Get-CimInstance Win32_Processor $ram = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2) $d = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'" $free= [math]::Round($d.FreeSpace / 1GB, 2) Out-Header "System Information" Out-Info "CPU: $($cpu.Name)" Out-Info "RAM: $ram GB" Out-Info "Free Disk (C:): $free GB" Out-Info "OS: $os" Out-Info "Model: $($ci.CsModel)" Out-Info "BIOS Version: $($ci.BiosSMBIOSBIOSVersion)" Out-Info "Serial Number: $($ci.BiosSerialNumber)" Out-Info "Locale: $($ci.OsLanguage)" } catch { Out-Fail "Error getting system information: $_" } } function Invoke-HPImageAssistant { try { Clear-Output Out-Header "HP IMAGE ASSISTANT" if (-not (Test-IsAdministrator)) { Out-Fail "Please run as Administrator to use HP Image Assistant."; return } Start-Busy $reportDir = "C:\temp\HPIA" $installDir = "C:\temp\HPIA\Agent" $hpiaExe = Join-Path $installDir "HPImageAssistant.exe" Out-Info "Checking for latest HP Image Assistant version..." try { $page = Invoke-WebRequest -Uri "https://ftp.hp.com/pub/caps-softpaq/cmit/HPIA.html" -UseBasicParsing -ErrorAction Stop $hpiaUrl = ($page.Links | Where-Object { $_.href -match "hp-hpia-.*\.exe$" } | Select-Object -First 1).href if (-not $hpiaUrl) { Out-Fail "Could not find HPIA download link."; return } $ver = if ($hpiaUrl -match "hp-hpia-([\d.]+)\.exe") { $Matches[1] } else { "unknown" } $download = Join-Path "C:\temp" ([System.IO.Path]::GetFileName($hpiaUrl)) Out-Pass "Latest version: $ver" } catch { Out-Fail "Failed to query HP: $($_.Exception.Message)"; return } if (-not (Test-Path "C:\temp")) { New-Item -Path "C:\temp" -ItemType Directory -Force | Out-Null } Out-Info "Downloading HP Image Assistant $ver..." try { Invoke-WebRequest -Uri $hpiaUrl -OutFile $download -UseBasicParsing -ErrorAction Stop; Out-Pass "Downloaded to $download" } catch { Out-Fail "Failed to download: $($_.Exception.Message)"; return } Out-Info "Installing HP Image Assistant..." try { $ip = Start-Process -FilePath $download -ArgumentList "/s","/e","/f `"$installDir`"" -Wait -PassThru -ErrorAction Stop if ($ip.ExitCode -ne 0) { Out-Warn "Installer exited with code $($ip.ExitCode)" } if (Test-Path $hpiaExe) { Out-Pass "Installed to $installDir" } else { Out-Fail "HPImageAssistant.exe not found at $hpiaExe"; return } } catch { Out-Fail "Installation failed: $($_.Exception.Message)"; return } Out-Info "Running HP Image Assistant (silent mode)..." Out-Dim "Report folder: $reportDir" try { $hp = Start-Process -FilePath $hpiaExe ` -ArgumentList "/Operation:Analyze","/Category:All","/Selection:All","/Action:Install","/Silent","/ReportFolder:$reportDir" ` -PassThru -ErrorAction Stop $sw = [System.Diagnostics.Stopwatch]::StartNew() $lastLogSize = 0; $lastLogFile = $null; $lastElapsed = ''; $lastStatus = ''; $logLineCount = 0 while (-not $hp.HasExited) { Update-UI $elapsed = $sw.Elapsed.ToString('mm\:ss') if ($elapsed -ne $lastElapsed) { $lastElapsed = $elapsed $cur = Get-ChildItem -Path $reportDir -Filter "*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $newLines = @() if ($cur) { if ($cur.FullName -ne $lastLogFile) { $lastLogFile = $cur.FullName; $lastLogSize = 0 } try { $st = [System.IO.File]::Open($cur.FullName,'Open','Read','ReadWrite') if ($st.Length -gt $lastLogSize) { $st.Seek($lastLogSize,'Begin') | Out-Null $r = New-Object System.IO.StreamReader($st) $newLines = $r.ReadToEnd() -split "`r?`n" | Where-Object { $_.Trim() } $lastLogSize = $st.Length; $r.Dispose() } $st.Dispose() } catch {} } if ($newLines.Count -gt 0) { $logLineCount += $newLines.Count foreach ($ln in $newLines) { $t = $ln.Trim() if ($t -ne $lastStatus) { $lastStatus = $t; Out-Dim "[HPIA $elapsed] $t" } } } else { Out-Dim "[HPIA $elapsed] Working... ($logLineCount log entries so far)" } } Start-Sleep -Milliseconds 250 } $sw.Stop() Out-Info "HPIA finished in $($sw.Elapsed.ToString('mm\:ss'))" switch ($hp.ExitCode) { 0 { Out-Pass "HPIA completed - no actions required" } 256 { Out-Pass "HPIA completed - updates applied" } 257 { Out-Warn "HPIA completed - reboot required" } 3010 { Out-Warn "HPIA completed - reboot required" } 4096 { Out-Warn "HPIA completed - one or more updates failed" } default { Out-Info "HPIA exited with code $($hp.ExitCode)" } } } catch { Out-Fail "Error running HPIA: $($_.Exception.Message)"; return } Remove-Item $download -Force -ErrorAction SilentlyContinue Out-Header "COMPLETE" Out-Pass "Report saved to: $reportDir" } catch { Out-Fail "Error: $($_.Exception.Message)" } finally { Stop-Busy } } # ============================================================================ # ACTION DISPATCH # ============================================================================ function Invoke-ActionByKey { param([string]$Key) if (-not $Key) { return } Clear-Output # Suppress progress bars and confirmation prompts that freeze the # WPF dispatcher (Install-Script / Install-Module write progress that # blocks when there's no console host). $savedProgress = $global:ProgressPreference $savedConfirm = $global:ConfirmPreference $global:ProgressPreference = 'SilentlyContinue' $global:ConfirmPreference = 'None' try { switch ($Key) { 'enrol-prod' { Invoke-AutopilotEnrolment -GroupTag $script:GroupTags.Production -FriendlyName 'Device' } 'enrol-csv' { New-AutopilotCsv } 'enrol-custom' { Invoke-AutopilotEnrolment -GroupTag $script:GroupTags.Custom -FriendlyName 'Custom Device' } 'enrol-mtr' { Invoke-AutopilotEnrolment -GroupTag $script:GroupTags.SurfaceHub -FriendlyName 'Surface Hub' } 'enrol-lapsafe' { Invoke-AutopilotEnrolment -GroupTag $script:GroupTags.Lapsafe -FriendlyName 'Lapsafe Laptop' } 'win11-check' { Test-Windows11Compatibility } 'sysinfo' { Get-SystemInfo } 'bitlocker' { Get-BitLockerRecoveryKey } 'wu-run' { Invoke-WindowsUpdateRun } 'ap-status' { Get-AutopilotStatus } 'hpia' { Invoke-HPImageAssistant } 'remove-cloud' { Remove-DeviceFromCloud } 'reset' { Reset-Device } 'install-ps7' { Install-PowerShell7 } 'restart' { Restart-PCWithCountdown } default { Out-Warn "Unknown action: $Key" } } } finally { $global:ProgressPreference = $savedProgress $global:ConfirmPreference = $savedConfirm } } # ============================================================================ # ACTION CATALOG # ============================================================================ $script:Categories = @( @{ Label='Deployment'; ColorKey='CatDeploy'; Items=@( @{ Key='enrol-prod'; Text='Enrol Autopilot'; Tip='Enrol this device with the PRODUCTION group tag'; Icon=[char]0xE753 } @{ Key='enrol-csv'; Text='Create CSV'; Tip='Export hardware hash to a CSV file'; Icon=[char]0xE8A5 } @{ Key='enrol-custom'; Text='Enrol Custom Device'; Tip='Enrol with the CUSTOM group tag'; Icon=[char]0xE836 } @{ Key='enrol-mtr'; Text='Enrol Surface Hub'; Tip='Enrol with the MTR-ICT group tag'; Icon=[char]0xE7F4 } @{ Key='enrol-lapsafe'; Text='Enrol Lapsafe'; Tip='Enrol with the LAPSAFE-DEVICE group tag'; Icon=[char]0xE770 } )} @{ Label='Diagnostics'; ColorKey='CatDiag'; Items=@( @{ Key='win11-check'; Text='Win 11 Compat'; Tip='Check Windows 11 hardware compatibility'; Icon=[char]0xE8E8 } @{ Key='sysinfo'; Text='System Info'; Tip='Display CPU, RAM, disk, OS, BIOS, serial'; Icon=[char]0xE946 } @{ Key='bitlocker'; Text='BitLocker Key'; Tip='Retrieve the local BitLocker recovery key'; Icon=[char]0xE8D7 } @{ Key='wu-run'; Text='Windows Update'; Tip='Run Windows Update non-interactively'; Icon=[char]0xE777 } @{ Key='ap-status'; Text='Autopilot Status'; Tip='Query Autopilot enrolment status from Graph'; Icon=[char]0xE8EA } @{ Key='hpia'; Text='HP Image Assistant'; Tip='Download and run HP Image Assistant'; Icon=[char]0xE835 } )} @{ Label='System'; ColorKey='CatSystem'; Items=@( @{ Key='remove-cloud'; Text='Remove from Cloud'; Tip='Remove device from Autopilot, Intune and Entra'; Icon=[char]0xE74D } @{ Key='reset'; Text='Reset Device'; Tip='Factory-reset this device'; Icon=[char]0xE72C } @{ Key='install-ps7'; Text='Install PS 7'; Tip='Install / update PowerShell 7'; Icon=[char]0xE756 } @{ Key='restart'; Text='Restart PC'; Tip='Restart this device after a 5s countdown'; Icon=[char]0xE7E8 } )} ) # ============================================================================ # XAML (Windows 11 Settings-style layout) # ============================================================================ $xaml = @' <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="ICT Autopilot Toolkit" Width="1100" Height="760" MinWidth="860" MinHeight="600" WindowStartupLocation="CenterScreen" Background="#F3F3F3" FontFamily="Segoe UI Variable Display, Segoe UI, sans-serif" FontSize="14"> <Window.Resources> <SolidColorBrush x:Key="BgBrush" Color="#F3F3F3"/> <SolidColorBrush x:Key="NavBgBrush" Color="#F3F3F3"/> <SolidColorBrush x:Key="CardBrush" Color="#FFFFFF"/> <SolidColorBrush x:Key="CardHoverBrush" Color="#F9F9F9"/> <SolidColorBrush x:Key="AccentBrush" Color="#005FB8"/> <SolidColorBrush x:Key="AccentLightBrush" Color="#E8F0FE"/> <SolidColorBrush x:Key="TextPriBrush" Color="#1A1A1A"/> <SolidColorBrush x:Key="TextSecBrush" Color="#5D5D5D"/> <SolidColorBrush x:Key="TextDimBrush" Color="#8A8A8A"/> <SolidColorBrush x:Key="DividerBrush" Color="#E5E5E5"/> <SolidColorBrush x:Key="OkBrush" Color="#0F7B0F"/> <SolidColorBrush x:Key="BadBrush" Color="#C42B1C"/> <SolidColorBrush x:Key="NavHoverBrush" Color="#E9E9E9"/> <SolidColorBrush x:Key="NavSelectedBrush" Color="#FFFFFF"/> <!-- Toolbar button (small, rounded) --> <Style x:Key="ToolBtn" TargetType="Button"> <Setter Property="Background" Value="{StaticResource CardBrush}"/> <Setter Property="Foreground" Value="{StaticResource TextSecBrush}"/> <Setter Property="BorderBrush" Value="{StaticResource DividerBrush}"/> <Setter Property="BorderThickness" Value="1"/> <Setter Property="Padding" Value="12,5"/> <Setter Property="FontSize" Value="12"/> <Setter Property="Cursor" Value="Hand"/> <Setter Property="Margin" Value="4,0,0,0"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="Button"> <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4"> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="{TemplateBinding Padding}"/> </Border> <ControlTemplate.Triggers> <Trigger Property="IsMouseOver" Value="True"> <Setter Property="Background" Value="{StaticResource NavHoverBrush}"/> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style> <!-- Nav item (Settings-style: transparent bg, rounded hover, left accent on selected) --> <Style x:Key="NavItem" TargetType="Button"> <Setter Property="Background" Value="Transparent"/> <Setter Property="Foreground" Value="{StaticResource TextPriBrush}"/> <Setter Property="HorizontalContentAlignment" Value="Stretch"/> <Setter Property="Cursor" Value="Hand"/> <Setter Property="Height" Value="38"/> <Setter Property="Margin" Value="4,1,4,1"/> <Setter Property="FontSize" Value="14"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="Button"> <Border x:Name="bd" Background="{TemplateBinding Background}" CornerRadius="4" Padding="4,0"> <Grid> <!-- Left accent indicator --> <Border x:Name="accent" Width="3" Height="16" HorizontalAlignment="Left" VerticalAlignment="Center" CornerRadius="1.5" Background="{StaticResource AccentBrush}" Visibility="Collapsed"/> <ContentPresenter Margin="10,0,8,0" VerticalAlignment="Center"/> </Grid> </Border> <ControlTemplate.Triggers> <Trigger Property="IsMouseOver" Value="True"> <Setter TargetName="bd" Property="Background" Value="{StaticResource NavHoverBrush}"/> <Setter TargetName="accent" Property="Visibility" Value="Visible"/> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style> <!-- Search box (rounded, Settings-like) --> <Style x:Key="SearchBox" TargetType="TextBox"> <Setter Property="Background" Value="{StaticResource CardBrush}"/> <Setter Property="BorderBrush" Value="{StaticResource DividerBrush}"/> <Setter Property="BorderThickness" Value="1"/> <Setter Property="Padding" Value="10,6"/> <Setter Property="FontSize" Value="14"/> <Setter Property="Height" Value="36"/> <Setter Property="Margin" Value="12,0,12,12"/> <Setter Property="VerticalContentAlignment" Value="Center"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="TextBox"> <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4"> <Grid> <ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/> </Grid> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style> </Window.Resources> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="280"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <!-- ===== LEFT NAV (Settings sidebar) ===== --> <DockPanel Grid.Column="0" Background="{StaticResource NavBgBrush}"> <!-- App title area (like account/profile in Settings) --> <StackPanel DockPanel.Dock="Top" Margin="16,20,16,12"> <StackPanel Orientation="Horizontal" Margin="8,0,0,8"> <TextBlock x:Name="AppIcon" FontFamily="Segoe Fluent Icons" FontSize="22" Foreground="{StaticResource AccentBrush}" VerticalAlignment="Center" Text=""/> <StackPanel Margin="12,0,0,0"> <TextBlock x:Name="TitleLabel" FontSize="16" FontWeight="SemiBold" Foreground="{StaticResource TextPriBrush}"/> <TextBlock x:Name="VersionLabel" FontSize="11" Foreground="{StaticResource TextDimBrush}"/> </StackPanel> </StackPanel> <!-- Search --> <TextBox x:Name="SearchBox" Style="{StaticResource SearchBox}"/> </StackPanel> <!-- Nav items scroll area --> <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Padding="0,0,0,12"> <StackPanel x:Name="NavHost"/> </ScrollViewer> </DockPanel> <!-- ===== MAIN CONTENT (right side) ===== --> <Grid Grid.Column="1" Background="{StaticResource BgBrush}" Margin="0"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <!-- Device info card (like Settings Home header) --> <Border Grid.Row="0" Background="{StaticResource CardBrush}" CornerRadius="8" Margin="24,20,24,0" Padding="20,14"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <!-- Device name + model --> <StackPanel Grid.Column="0" VerticalAlignment="Center"> <TextBlock x:Name="DeviceName" FontSize="18" FontWeight="SemiBold" Foreground="{StaticResource TextPriBrush}"/> <TextBlock x:Name="DeviceModel" FontSize="13" Foreground="{StaticResource TextSecBrush}" Margin="0,2,0,0"/> </StackPanel> <!-- Status chips --> <StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Margin="16,0"> <Border Background="{StaticResource AccentLightBrush}" CornerRadius="4" Padding="10,6" Margin="0,0,8,0"> <StackPanel Orientation="Horizontal"> <TextBlock FontFamily="Segoe Fluent Icons" Text="" Foreground="{StaticResource AccentBrush}" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/> <TextBlock x:Name="ChipAdminValue" FontSize="12" Foreground="{StaticResource TextPriBrush}" VerticalAlignment="Center"/> </StackPanel> </Border> <Border Background="{StaticResource AccentLightBrush}" CornerRadius="4" Padding="10,6" Margin="0,0,8,0"> <StackPanel Orientation="Horizontal"> <TextBlock FontFamily="Segoe Fluent Icons" Text="" Foreground="{StaticResource AccentBrush}" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/> <TextBlock x:Name="ChipNetValue" Text="Checking..." FontSize="12" Foreground="{StaticResource TextPriBrush}" VerticalAlignment="Center"/> </StackPanel> </Border> </StackPanel> <!-- Clock --> <TextBlock Grid.Column="2" x:Name="StatusClock" FontSize="12" Foreground="{StaticResource TextDimBrush}" VerticalAlignment="Center"/> </Grid> </Border> <!-- Output panel (white card with toolbar inside) --> <Border Grid.Row="1" Background="{StaticResource CardBrush}" CornerRadius="8" Margin="24,12,24,20"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="3"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <!-- Output toolbar --> <Grid Grid.Row="0" Margin="16,10,16,6"> <TextBlock Text="Output" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource TextSecBrush}" VerticalAlignment="Center"/> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> <Button x:Name="BtnFolder" Content="Open Temp" Style="{StaticResource ToolBtn}" ToolTip="Open the working temp folder"/> <Button x:Name="BtnSave" Content="Save Log" Style="{StaticResource ToolBtn}" ToolTip="Save current output to a file"/> <Button x:Name="BtnCopy" Content="Copy" Style="{StaticResource ToolBtn}" ToolTip="Copy all output to clipboard"/> <Button x:Name="BtnClear" Content="Clear" Style="{StaticResource ToolBtn}" ToolTip="Clear output (Esc)"/> </StackPanel> </Grid> <!-- Progress bar --> <ProgressBar Grid.Row="1" x:Name="ProgressBar" IsIndeterminate="True" Visibility="Collapsed" Height="3" BorderThickness="0" Background="Transparent" Foreground="{StaticResource AccentBrush}"/> <!-- Output RichTextBox --> <RichTextBox Grid.Row="2" x:Name="OutputBox" IsReadOnly="True" BorderThickness="0" Background="Transparent" Padding="18,8,18,14" VerticalScrollBarVisibility="Auto" FontFamily="Cascadia Mono, Consolas, Courier New" FontSize="12.5"> <FlowDocument PageWidth="2000"/> </RichTextBox> </Grid> </Border> </Grid> </Grid> </Window> '@ # ============================================================================ # WINDOW BUILD # ============================================================================ function New-MainWindow { $reader = New-Object System.Xml.XmlNodeReader ([xml]$xaml) $window = [Windows.Markup.XamlReader]::Load($reader) # ---- Resolve named elements ------------------------------------------ $script:Window = $window $script:OutputBox = $window.FindName('OutputBox') $script:ProgressBar = $window.FindName('ProgressBar') $script:SearchBox = $window.FindName('SearchBox') $script:NavHost = $window.FindName('NavHost') $script:StatusClock = $window.FindName('StatusClock') $script:ChipNetValue = $window.FindName('ChipNetValue') $window.Title = "$script:AppTitle v$script:AppVersion" $window.FindName('TitleLabel').Text = $script:AppTitle $window.FindName('VersionLabel').Text = "v$script:AppVersion" # Window icon try { $iconPath = Get-TempFilePath 'win11.ico' if (-not (Test-Path $iconPath)) { Invoke-WebRequest -Uri 'https://ictautopilot.blob.core.windows.net/assets/win11.ico' ` -OutFile $iconPath -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop } if (Test-Path $iconPath) { $window.Icon = New-Object System.Windows.Media.Imaging.BitmapImage([Uri]$iconPath) } } catch {} # Pick best icon font $script:IconFont = 'Segoe Fluent Icons' # ---- Device facts ---------------------------------------------------- try { $script:DevSerial = (Get-CimInstance Win32_BIOS).SerialNumber } catch { $script:DevSerial = 'N/A' } try { $script:DevModel = (Get-CimInstance Win32_ComputerSystem).Model } catch { $script:DevModel = 'Unknown' } try { $script:DevName = $env:COMPUTERNAME } catch { $script:DevName = 'PC' } try { $script:DevOS = ((Get-CimInstance Win32_OperatingSystem).Caption -replace 'Microsoft ','') } catch { $script:DevOS = 'Unknown' } $script:IsAdmin = Test-IsAdministrator $script:AdminText = if ($script:IsAdmin) { 'Administrator' } else { 'Standard User' } $window.FindName('DeviceName').Text = "$script:DevName" $window.FindName('DeviceModel').Text = "$script:DevModel · $script:DevSerial" $window.FindName('ChipAdminValue').Text = $script:AdminText $script:StatusClock.Text = (Get-Date -Format 'HH:mm:ss') # ---- Build nav items (Settings style: icon + text, flat list) -------- $script:AllCards = @() foreach ($cat in $script:Categories) { $catBrush = $script:Palette[$cat.ColorKey] # Category header (small, dim label) $catLabel = New-Object System.Windows.Controls.TextBlock $catLabel.Text = $cat.Label $catLabel.FontSize = 12 $catLabel.FontWeight = 'SemiBold' $catLabel.Foreground = $script:Palette.TextDim $catLabel.Margin = [System.Windows.Thickness]::new(20,14,0,4) [void]$script:NavHost.Children.Add($catLabel) foreach ($item in $cat.Items) { # Button content: icon + label $contentGrid = New-Object System.Windows.Controls.Grid $col1 = New-Object System.Windows.Controls.ColumnDefinition; $col1.Width = '30' $col2 = New-Object System.Windows.Controls.ColumnDefinition; $col2.Width = '*' $contentGrid.ColumnDefinitions.Add($col1); $contentGrid.ColumnDefinitions.Add($col2) $iconTb = New-Object System.Windows.Controls.TextBlock $iconTb.Text = [string]$item.Icon $iconTb.FontFamily = New-Object System.Windows.Media.FontFamily($script:IconFont) $iconTb.FontSize = 16 $iconTb.Foreground = $catBrush $iconTb.HorizontalAlignment = 'Center' $iconTb.VerticalAlignment = 'Center' [System.Windows.Controls.Grid]::SetColumn($iconTb, 0) [void]$contentGrid.Children.Add($iconTb) $textTb = New-Object System.Windows.Controls.TextBlock $textTb.Text = $item.Text $textTb.VerticalAlignment = 'Center' $textTb.TextTrimming = 'CharacterEllipsis' $textTb.Margin = [System.Windows.Thickness]::new(8,0,0,0) [System.Windows.Controls.Grid]::SetColumn($textTb, 1) [void]$contentGrid.Children.Add($textTb) $btn = New-Object System.Windows.Controls.Button $btn.Style = $window.FindResource('NavItem') $btn.Content = $contentGrid $btn.ToolTip = $item.Tip $btn.Tag = $item.Key $btn.Add_Click({ param($sender, $e) Invoke-ActionByKey -Key $sender.Tag }) [void]$script:NavHost.Children.Add($btn) $script:AllCards += [pscustomobject]@{ Button = $btn Category = $catLabel CategoryLabel = $cat.Label Text = $item.Text Tip = $item.Tip } } } # ---- Search filter --------------------------------------------------- $script:SearchBox.Add_TextChanged({ $q = $script:SearchBox.Text if ($null -eq $q) { $q = '' } $q = $q.Trim().ToLower() $shownByCat = @{} foreach ($c in $script:AllCards) { $match = ($q -eq '') -or ($c.Text.ToLower().Contains($q)) -or ($c.Tip.ToLower().Contains($q)) $c.Button.Visibility = if ($match) { 'Visible' } else { 'Collapsed' } if ($match) { $shownByCat[$c.CategoryLabel] = $true } } foreach ($cat in $script:Categories) { $catCtrl = ($script:AllCards | Where-Object CategoryLabel -eq $cat.Label | Select-Object -First 1).Category $catCtrl.Visibility = if ($shownByCat.ContainsKey($cat.Label)) { 'Visible' } else { 'Collapsed' } } }) # ---- Toolbar handlers ------------------------------------------------ $window.FindName('BtnClear').Add_Click({ Clear-Output }) $window.FindName('BtnCopy').Add_Click({ $text = Get-OutputText if ($text) { [System.Windows.Clipboard]::SetText($text) } }) $window.FindName('BtnSave').Add_Click({ $sfd = New-Object Microsoft.Win32.SaveFileDialog $sfd.Filter = 'Log files (*.log)|*.log|Text files (*.txt)|*.txt|All files (*.*)|*.*' $sfd.FileName = "ictautopilot-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" if ($sfd.ShowDialog($script:Window)) { Set-Content -Path $sfd.FileName -Value (Get-OutputText) -Encoding UTF8 } }) $window.FindName('BtnFolder').Add_Click({ Start-Process explorer.exe $script:TempPath }) # ---- Keyboard shortcuts ---------------------------------------------- $window.Add_PreviewKeyDown({ param($s,$e) if ($e.Key -eq 'Escape') { Clear-Output; $e.Handled = $true } elseif ($e.Key -eq 'F' -and ([System.Windows.Input.Keyboard]::Modifiers -band [System.Windows.Input.ModifierKeys]::Control)) { $script:SearchBox.Focus(); $e.Handled = $true } }) # ---- Clock + connectivity timer -------------------------------------- $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromSeconds(1) $script:tickCount = 0 $timer.Add_Tick({ $script:StatusClock.Text = (Get-Date -Format 'HH:mm:ss') $script:tickCount++ if ($script:tickCount % 30 -eq 1) { try { $ok = Test-Connection -ComputerName 'graph.microsoft.com' -Count 1 -Quiet -ErrorAction SilentlyContinue if ($ok) { $script:ChipNetValue.Text = 'Online'; $script:ChipNetValue.Foreground = $script:Palette.Ok } else { $script:ChipNetValue.Text = 'Offline'; $script:ChipNetValue.Foreground = $script:Palette.Bad } } catch { $script:ChipNetValue.Text = 'Offline'; $script:ChipNetValue.Foreground = $script:Palette.Bad } } }) $timer.Start() # ---- Welcome screen -------------------------------------------------- $window.Add_ContentRendered({ Out-Header "WELCOME" Out-Info "Hello! This is ICT Autopilot Toolkit v$script:AppVersion." Out-Dim "Pick an action from the sidebar, or press Ctrl+F to search." Out-Dim "" if (-not $script:IsAdmin) { Out-Warn "You are not running as Administrator. Most actions will fail." } Out-Header "DEVICE" Out-Info "Serial Number : $script:DevSerial" Out-Info "Model : $script:DevModel" Out-Info "OS : $script:DevOS" Out-Info "Logged in as : $env:USERNAME ($script:AdminText)" Out-Info "Log file : $script:LogFile" }) return $window } # ============================================================================ # ENTRY POINT # ============================================================================ Initialize-WorkingPaths Write-Log -Message "Starting $script:AppTitle v$script:AppVersion" $window = New-MainWindow [void]$window.ShowDialog() |