diff --git a/MDM-ODA V0.81.ps1 b/MDM-ODA V0.81.ps1
new file mode 100644
index 0000000..d4594bc
--- /dev/null
+++ b/MDM-ODA V0.81.ps1
@@ -0,0 +1,21178 @@
+# -- param must be first statement for PS5 compatibility --
+param([switch]$SkipUpgrade)
+
+# ============================================================
+# MDM-ODA - Orchestrator Launcher
+# Author : Satish Singhi
+# Description : PS5-compatible launcher. Installs/upgrades
+# PowerShell 7 via winget (user scope, no admin)
+# then relaunches self in PS7 to show the GUI.
+#
+# Distribute alongside: Orchestrator_GUI.ps1
+# Users run this file only. Orchestrator_GUI.ps1 is copied
+# to a staging directory and launched via PS7.
+#
+# -- DEVELOPER CONFIG -----------------------------------------
+# ============================================================
+
+# ============================================================
+#region ORCHESTRATOR CONFIG
+# ============================================================
+$OrcConfig = @{
+ StagingRoot = "C:\Logs\MDM-ODA"
+ StagingPath = "C:\Logs\MDM-ODA\Staging"
+ LogPath = "C:\Logs\MDM-ODA\Logs"
+ ToolFileName = "MDM-ODA.ps1"
+}
+# Ensure all folders exist upfront
+foreach ($dir in @($OrcConfig.StagingRoot, $OrcConfig.StagingPath, $OrcConfig.LogPath)) {
+ if (-not (Test-Path $dir)) {
+ try { New-Item -ItemType Directory -Path $dir -Force | Out-Null } catch {}
+ }
+}
+#endregion ORCHESTRATOR CONFIG
+# ============================================================
+
+# ============================================================
+# PS5 SECTION - Everything below here runs in PS5.
+# PS7 code begins after the exit statement further down.
+# PS5 exits before reaching the PS7 section so PS7-only
+# syntax there never causes a parse error in PS5.
+# ============================================================
+
+if ($PSVersionTable.PSVersion.Major -lt 7) {
+
+ Write-Host ""
+ Write-Host " MDM ODA Tool" -ForegroundColor Cyan
+ Write-Host " Checking PowerShell 7..." -ForegroundColor Cyan
+ Write-Host ""
+
+ # -- Verify winget is available -------------------------------------------
+ $winget = Get-Command winget -ErrorAction SilentlyContinue
+ if (-not $winget) {
+ Write-Host " winget is not available on this device." -ForegroundColor Red
+ Write-Host " Please install PowerShell 7 from:" -ForegroundColor Yellow
+ Write-Host " https://github.com/PowerShell/PowerShell/releases/latest" -ForegroundColor Cyan
+ Write-Host ""
+ Read-Host " Press Enter to exit"
+ exit 1
+ }
+
+ # -- PS7 detection: validate real executable (not a Windows Store stub) -----
+ Write-Host " Checking PowerShell 7..." -ForegroundColor Gray
+
+ # Windows 11/10 places a tiny stub at WindowsApps\pwsh.exe even when PS7 is NOT
+ # installed — Test-Path returns $true but running it just opens the Store.
+ # Validates executables via runtime check (handles both MSI and MSIX installs).
+ function Test-RealPwsh {
+ param([string]$Path)
+ if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
+ if (-not (Test-Path $Path -ErrorAction SilentlyContinue)) { return $false }
+ try {
+ $verOut = & $Path --version 2>&1
+ if ($verOut -match "^PowerShell\s+\d+\.\d+") {
+ Write-Host " Validated: $Path ($($verOut.Trim()))" -ForegroundColor DarkGray
+ return $true
+ }
+ } catch { }
+ Write-Host " Skipping invalid: $Path" -ForegroundColor DarkGray
+ return $false
+ }
+
+ # Probe in priority order:
+ # 1. System-wide MSI (most reliable on corporate devices)
+ # 2. User-scope winget non-Store
+ # 3. PATH (covers custom/Chocolatey/SCCM installs)
+ # 4. WindowsApps (Store install — validated last because stubs live here)
+ $probePaths = [System.Collections.Generic.List[string]]::new()
+ $probePaths.Add('C:\Program Files\PowerShell\7\pwsh.exe') # system MSI
+ $probePaths.Add((Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\Microsoft.PowerShell_8wekyb3d8bbwe\pwsh.exe')) # MSIX user-scope
+ $probePaths.Add((Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\pwsh.exe')) # generic alias
+ $pwshCmd = Get-Command pwsh -All -ErrorAction SilentlyContinue
+ if ($pwshCmd) {
+ foreach ($p in ($pwshCmd | Where-Object { $_.Source -notlike '*WindowsApps*' })) {
+ if (-not $probePaths.Contains($p.Source)) { $probePaths.Add($p.Source) }
+ }
+ }
+
+ $pwsh7Detected = $null
+ foreach ($p in $probePaths) {
+ if (Test-RealPwsh $p) { $pwsh7Detected = $p; break }
+ }
+ $ps7Installed = $null -ne $pwsh7Detected
+
+ if (-not $ps7Installed) {
+ Write-Host " PowerShell 7 not found. Installing (user-scope, no admin required)..." -ForegroundColor Yellow
+ Write-Host " This may take 1-2 minutes. Please wait." -ForegroundColor Gray
+ & winget install --id Microsoft.PowerShell --exact --scope user --source winget `
+ --accept-source-agreements --accept-package-agreements --silent
+ Start-Sleep -Seconds 3
+ $env:Path = [System.Environment]::GetEnvironmentVariable("Path","User") + ";" +
+ [System.Environment]::GetEnvironmentVariable("Path","Machine")
+ # Re-probe after install (using same stub-safe validator)
+ foreach ($p in $probePaths) {
+ if (Test-RealPwsh $p) { $pwsh7Detected = $p; break }
+ }
+ $ps7Installed = $null -ne $pwsh7Detected
+ if ($ps7Installed) {
+ Write-Host " PowerShell 7 installed at: $pwsh7Detected" -ForegroundColor Green
+ } else {
+ Write-Host " Installation may have succeeded but path not yet visible." -ForegroundColor Yellow
+ Write-Host " Please close this window and open a new terminal, then run the script again." -ForegroundColor Yellow
+ Read-Host " Press Enter to exit"
+ exit 1
+ }
+ } else {
+ $scopeLabel = if ($pwsh7Detected -like "*LocalAppData*") { "(user-scope)" } else { "(system-wide)" }
+ Write-Host " PowerShell 7 detected $scopeLabel : $pwsh7Detected" -ForegroundColor Green
+ }
+
+ # -- Upgrade to latest stable every launch (skip previews) -------------------
+ if (-not $SkipUpgrade) {
+ Write-Host " Checking for updates (stable only)..." -ForegroundColor Gray
+
+ $showOutput = & winget show --id Microsoft.PowerShell --exact --source winget 2>&1
+ $verLine = $showOutput | Where-Object { $_ -match '^\s*Version\s*:' } | Select-Object -First 1
+
+ $latestStable = $null
+ if ($verLine -and $verLine -match ':\s*([\d\.]+)') {
+ $rawVer = $Matches[1]
+ if ($rawVer -notmatch 'preview|rc|beta|alpha') { $latestStable = $rawVer }
+ }
+
+ if ($latestStable) {
+ Write-Host " Latest stable PS7: $latestStable" -ForegroundColor Gray
+ $upgradeOut = & winget upgrade --id Microsoft.PowerShell --exact --scope user `
+ --source winget --accept-source-agreements --accept-package-agreements --silent 2>&1
+ $done = $upgradeOut | Where-Object {
+ $_ -match 'No applicable upgrade' -or
+ $_ -match 'already installed' -or
+ $_ -match 'Successfully installed'
+ }
+ if ($done) {
+ Write-Host " PowerShell 7 is up to date (v$latestStable)." -ForegroundColor Green
+ } else {
+ Write-Host " Update applied." -ForegroundColor Green
+ }
+ }
+ }
+
+ # -- Find pwsh.exe and relaunch in PS7 ---------------------------------------
+ $env:Path = [System.Environment]::GetEnvironmentVariable("Path","User") + ";" +
+ [System.Environment]::GetEnvironmentVariable("Path","Machine")
+
+ # Use already-detected path; fall back to re-probe if needed
+ $pwsh7 = $pwsh7Detected
+ if (-not $pwsh7) {
+ $pwshAll = Get-Command pwsh -All -ErrorAction SilentlyContinue
+ if ($pwshAll) {
+ foreach ($p in ($pwshAll | Where-Object { $_.Source -notlike '*WindowsApps*' })) {
+ if (Test-Path $p.Source -ErrorAction SilentlyContinue) { $pwsh7 = $p.Source; break }
+ }
+ }
+ }
+
+ if ($pwsh7) {
+ Write-Host " Staging and launching in PowerShell 7 ($pwsh7)..." -ForegroundColor Green
+ # Hide this console window before handing off
+ try {
+ Add-Type -TypeDefinition @"
+using System.Runtime.InteropServices;
+public class OrcLaunchConsole {
+ [DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow);
+ [DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow();
+}
+"@ -ErrorAction SilentlyContinue
+ $null = [OrcLaunchConsole]::ShowWindow([OrcLaunchConsole]::GetConsoleWindow(), 0)
+ } catch {}
+
+ # Extract PS7 payload from the block-comment section embedded in this script.
+ # PS5 never compiled that code — it was just a comment token to the PS5 tokenizer.
+ $scriptText = [System.IO.File]::ReadAllText($PSCommandPath, [System.Text.Encoding]::UTF8)
+ $startTag = '<#__PS7' + '_PAYLOAD_START__'
+ $endTag = '__PS7_PAYLOAD' + '_END__#>'
+ $startOffset = $scriptText.IndexOf($startTag)
+ $endOffset = $scriptText.IndexOf($endTag)
+
+ if ($startOffset -lt 0 -or $endOffset -le $startOffset) {
+ Write-Host " ERROR: PS7 payload boundary markers not found in script." -ForegroundColor Red
+ Read-Host " Press Enter to exit"
+ exit 1
+ }
+
+ $payloadText = $scriptText.Substring($startOffset + $startTag.Length, $endOffset - ($startOffset + $startTag.Length)).TrimStart("`r", "`n")
+ $stagingScript = Join-Path $OrcConfig.StagingPath $OrcConfig.ToolFileName
+
+ # Ensure staging folders exist
+ foreach ($dir in @($OrcConfig.StagingRoot, $OrcConfig.StagingPath, $OrcConfig.LogPath)) {
+ if (-not (Test-Path $dir)) {
+ try { New-Item -ItemType Directory -Path $dir -Force | Out-Null } catch {}
+ }
+ }
+
+ # Write PS7 payload to staging directory as MDM-ODA.ps1
+ [System.IO.File]::WriteAllText($stagingScript, $payloadText, [System.Text.Encoding]::UTF8)
+ Write-Host " Staged: $stagingScript" -ForegroundColor Green
+
+ # Launch staged PS7 script — hidden console window, wait for exit
+ Start-Process -FilePath $pwsh7 `
+ -ArgumentList "-ExecutionPolicy Bypass -STA -File `"$stagingScript`"" `
+ -WindowStyle Hidden `
+ -Wait
+ exit 0
+ } else {
+ Write-Host ""
+ Write-Host " Could not locate pwsh.exe." -ForegroundColor Red
+ Write-Host " Please close this window, open a new terminal and run the script again." -ForegroundColor Yellow
+ Write-Host ""
+ Read-Host " Press Enter to exit"
+ exit 1
+ }
+}
+
+# ============================================================
+# PS7 PAYLOAD — embedded as a PS5 block comment so PS5 never
+# compiles this code. The PS5 launcher reads this file at
+# runtime, extracts the payload between the sentinel tags,
+# stages it as MDM-ODA.ps1, and launches it with pwsh.exe.
+# DO NOT edit or remove the sentinel tags below.
+# ============================================================
+<#__PS7_PAYLOAD_START__
+# ── Orchestrator config ─────────────────────────────────────────────────────
+# Defined here so MDM-ODA.ps1 is self-contained when staged and run directly.
+# Values must match the $OrcConfig in the PS5 launcher section above.
+$OrcConfig = @{
+ StagingRoot = 'C:\Logs\MDM-ODA'
+ StagingPath = 'C:\Logs\MDM-ODA\Staging'
+ LogPath = 'C:\Logs\MDM-ODA\Logs'
+ ToolFileName = 'MDM-ODA.ps1'
+}
+
+# ── Startup diagnostic log (captures fatal errors before the form appears) ─
+$script:OrcDiagLog = Join-Path $OrcConfig.LogPath ("OrcDiag_" + (Get-Date -f 'yyyyMMdd_HHmmss') + ".log")
+function Write-OrcDiag { param([string]$Msg)
+ try { [System.IO.Directory]::CreateDirectory($OrcConfig.LogPath)|Out-Null
+ [System.IO.File]::AppendAllText($script:OrcDiagLog,"[$(Get-Date -f 'HH:mm:ss')] $Msg`n",[System.Text.Encoding]::UTF8) } catch {} }
+trap { Write-OrcDiag "FATAL: $($_.Exception.Message)`n$($_.ScriptStackTrace)"; break }
+Write-OrcDiag "MDM-ODA.ps1 started. PSCommandPath=$PSCommandPath"
+
+Add-Type -AssemblyName System.Windows.Forms
+Add-Type -AssemblyName System.Drawing
+
+# -- Read WindowTitle from the embedded GUI section ----------------------------
+$toolTitle = "Launching..."
+try {
+ $selfContent = [System.IO.File]::ReadAllText($PSCommandPath, [System.Text.Encoding]::UTF8)
+ if ($selfContent -match 'WindowTitle\s*=\s*"([^"]+)"') { $toolTitle = $Matches[1] }
+} catch {}
+
+# -- Colours ------------------------------------------------------------------
+$C = @{
+ Header = [System.Drawing.ColorTranslator]::FromHtml('#007A00')
+ HeaderFg = [System.Drawing.Color]::White
+ BtnGreen = [System.Drawing.ColorTranslator]::FromHtml('#007A00')
+ BtnFg = [System.Drawing.Color]::White
+ Bg = [System.Drawing.ColorTranslator]::FromHtml('#F8FBF8')
+ LogBg = [System.Drawing.ColorTranslator]::FromHtml('#1A1F2E')
+ LogFg = [System.Drawing.ColorTranslator]::FromHtml('#9DB8D8')
+ Success = [System.Drawing.ColorTranslator]::FromHtml('#5DC98B')
+ Warning = [System.Drawing.ColorTranslator]::FromHtml('#F5A623')
+ Error = [System.Drawing.ColorTranslator]::FromHtml('#F06C6C')
+ Info = [System.Drawing.ColorTranslator]::FromHtml('#C8DEF5')
+}
+
+# -- Build form ----------------------------------------------------------------
+$form = [System.Windows.Forms.Form]::new()
+$form.Text = $toolTitle
+$form.Width = 580
+$form.Height = 380
+$form.MinimumSize = [System.Drawing.Size]::new(480, 300)
+$form.StartPosition = 'CenterScreen'
+$form.BackColor = $C.Bg
+$form.Font = [System.Drawing.Font]::new('Segoe UI', 10)
+$form.FormBorderStyle = 'FixedDialog'
+$form.MaximizeBox = $false
+
+$header = [System.Windows.Forms.Panel]::new()
+$header.Dock = 'Top'
+$header.Height = 52
+$header.BackColor = $C.Header
+$headerLbl = [System.Windows.Forms.Label]::new()
+$headerLbl.Text = $toolTitle
+$headerLbl.ForeColor = $C.HeaderFg
+$headerLbl.Font = [System.Drawing.Font]::new('Segoe UI', 13, [System.Drawing.FontStyle]::Bold)
+$headerLbl.AutoSize = $true
+$headerLbl.Location = [System.Drawing.Point]::new(16, 13)
+$header.Controls.Add($headerLbl)
+
+$content = [System.Windows.Forms.TableLayoutPanel]::new()
+$content.Dock = 'Fill'
+$content.Padding = [System.Windows.Forms.Padding]::new(14, 10, 14, 10)
+$content.BackColor = $C.Bg
+$content.ColumnCount = 1
+$content.RowCount = 3
+$null = $content.ColumnStyles.Add([System.Windows.Forms.ColumnStyle]::new([System.Windows.Forms.SizeType]::Percent, 100))
+$null = $content.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Absolute, 30))
+$null = $content.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Percent, 100))
+$null = $content.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Absolute, 44))
+$form.Controls.Add($content)
+$form.Controls.Add($header)
+
+$statusLbl = [System.Windows.Forms.Label]::new()
+$statusLbl.Text = 'Initialising...'
+$statusLbl.Dock = 'Fill'
+$statusLbl.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
+$statusLbl.ForeColor = [System.Drawing.ColorTranslator]::FromHtml('#1A1F2E')
+$content.Controls.Add($statusLbl, 0, 0)
+
+$logBox = [System.Windows.Forms.RichTextBox]::new()
+$logBox.ReadOnly = $true
+$logBox.BackColor = $C.LogBg
+$logBox.ForeColor = $C.LogFg
+$logBox.Font = [System.Drawing.Font]::new('Consolas', 9)
+$logBox.BorderStyle = 'None'
+$logBox.Dock = 'Fill'
+$content.Controls.Add($logBox, 0, 1)
+
+$btnPanel = [System.Windows.Forms.Panel]::new()
+$btnPanel.Dock = 'Fill'
+$btnPanel.BackColor = $C.Bg
+$content.Controls.Add($btnPanel, 0, 2)
+
+$btnClose = [System.Windows.Forms.Button]::new()
+$btnClose.Text = 'Close'
+$btnClose.Width = 100
+$btnClose.Height = 32
+$btnClose.Anchor = 'Right,Bottom'
+$btnClose.Location = [System.Drawing.Point]::new(440, 6)
+$btnClose.BackColor = $C.BtnGreen
+$btnClose.ForeColor = $C.BtnFg
+$btnClose.FlatStyle = 'Flat'
+$btnClose.FlatAppearance.BorderSize = 0
+$btnClose.Visible = $false
+$btnClose.Add_Click({ $form.Close() })
+$btnPanel.Controls.Add($btnClose)
+
+function OLog {
+ param([string]$Line, [string]$Level = 'Info')
+ $col = switch ($Level) {
+ 'Success' { $C.Success } 'Warning' { $C.Warning }
+ 'Error' { $C.Error } 'Action' { $C.Info }
+ default { $C.LogFg }
+ }
+ $logBox.SelectionStart = $logBox.TextLength
+ $logBox.SelectionLength = 0
+ $logBox.SelectionColor = $col
+ $logBox.AppendText("[$((Get-Date).ToString('HH:mm:ss'))] $Line`n")
+ $logBox.ScrollToCaret()
+ $form.Refresh()
+}
+function OStatus { param([string]$T) $statusLbl.Text = $T; $form.Refresh() }
+
+$form.Add_Shown({
+
+ # -- Extract GUI tool content FIRST (before clearing staging) ----------------
+ # MDM-ODA.ps1 runs FROM the staging directory — read it before deleting it.
+ OStatus 'Extracting GUI tool...'
+ $toolPath = Join-Path $OrcConfig.StagingPath $OrcConfig.ToolFileName
+ $beginMark = '#' + '=' * 10 + ' BEGIN_GUI_TOOL_CONTENT ' + '=' * 10
+ $endMark = '#' + '=' * 10 + ' END_GUI_TOOL_CONTENT ' + '=' * 11
+ $guiLines = $null
+ try {
+ $selfLines = [System.IO.File]::ReadAllLines($PSCommandPath, [System.Text.Encoding]::UTF8)
+ $inBlock = $false
+ $guiLines = [System.Collections.Generic.List[string]]::new()
+ foreach ($line in $selfLines) {
+ if ($line -eq $beginMark) { $inBlock = $true; continue }
+ if ($line -eq $endMark) { $inBlock = $false; break }
+ if ($inBlock) { $guiLines.Add($line) }
+ }
+ if ($guiLines.Count -eq 0) { throw 'GUI tool section not found in orchestrator.' }
+ OLog ('GUI tool read (' + $guiLines.Count + ' lines).') 'Success'
+ Write-OrcDiag "GUI tool read: $($guiLines.Count) lines"
+ } catch {
+ OLog "Extraction failed: $($_.Exception.Message)" 'Error'
+ OStatus 'Error - see log.'
+ Write-OrcDiag "Extraction failed: $($_.Exception.Message)"
+ $btnClose.Visible = $true; return
+ }
+
+ # -- Prepare staging (AFTER reading self — Remove-Item deletes PSCommandPath) --
+ OStatus 'Preparing staging...'
+ OLog "Staging: $($OrcConfig.StagingPath)" 'Info'
+ try {
+ if (Test-Path $OrcConfig.StagingPath) {
+ Remove-Item $OrcConfig.StagingPath -Recurse -Force -ErrorAction Stop
+ }
+ New-Item -ItemType Directory -Path $OrcConfig.StagingPath -Force | Out-Null
+ OLog 'Staging ready.' 'Success'
+ } catch {
+ OLog "Staging failed: $($_.Exception.Message)" 'Error'
+ OStatus 'Error - see log.'
+ $btnClose.Visible = $true; return
+ }
+
+ # -- Write GUI tool to staging ---------------------------------------------
+ try {
+ [System.IO.File]::WriteAllText($toolPath, ($guiLines -join "`n"), [System.Text.Encoding]::UTF8)
+ OLog ('GUI tool staged (' + $guiLines.Count + ' lines).') 'Success'
+ Write-OrcDiag "GUI tool written to: $toolPath"
+ } catch {
+ OLog "Write to staging failed: $($_.Exception.Message)" 'Error'
+ OStatus 'Error - see log.'
+ $btnClose.Visible = $true; return
+ }
+
+
+ # -- Launch GUI tool -------------------------------------------------------
+ OStatus "Launching $toolTitle..."
+ $pwsh7 = (Get-Process -Id $PID).MainModule.FileName
+ OLog "Launching via: $pwsh7" 'Action'
+
+ # Write orchestrator launch log for diagnostics
+ $orcLogPath = Join-Path $OrcConfig.LogPath ("OrcLaunch_{0}.log" -f (Get-Date -f 'yyyyMMdd_HHmmss'))
+ Set-Content -Path $orcLogPath -Value "Orchestrator Launch Log" -Encoding UTF8
+ Add-Content -Path $orcLogPath -Value "========================" -Encoding UTF8
+ Add-Content -Path $orcLogPath -Value ("Timestamp : " + (Get-Date)) -Encoding UTF8
+ Add-Content -Path $orcLogPath -Value ("Host : " + $env:COMPUTERNAME) -Encoding UTF8
+ Add-Content -Path $orcLogPath -Value ("User : " + $env:USERNAME) -Encoding UTF8
+ Add-Content -Path $orcLogPath -Value ("PS7 Path : " + $pwsh7) -Encoding UTF8
+ Add-Content -Path $orcLogPath -Value ("Tool Path : " + $toolPath) -Encoding UTF8
+ Add-Content -Path $orcLogPath -Value ("Tool Lines : " + $guiLines.Count) -Encoding UTF8
+ Add-Content -Path $orcLogPath -Value ("Staging : " + $OrcConfig.StagingPath) -Encoding UTF8
+ Add-Content -Path $orcLogPath -Value ("Log Dir : " + $OrcConfig.LogPath) -Encoding UTF8
+ OLog "Launch log: $orcLogPath" 'Info'
+
+ # Transcript path for the GUI tool startup diagnostics
+ $transcriptPath = Join-Path $OrcConfig.LogPath ("MDMODA_Transcript_{0}.log" -f (Get-Date -f 'yyyyMMdd_HHmmss'))
+
+ # Pass log paths to GUI tool via environment variables.
+ # Note: param() blocks in mid-file cause PS5 parse errors, so env vars are used.
+ $env:MDMODA_LogFolder = $OrcConfig.LogPath
+ $env:MDMODA_Transcript = $transcriptPath
+
+ $launchArgs = "-ExecutionPolicy Bypass -STA -File `"$toolPath`""
+ try {
+ # WindowStyle Normal (visible) so startup crashes surface in a console window.
+ # Switch to Hidden once the tool is confirmed stable.
+ $proc = Start-Process $pwsh7 -ArgumentList $launchArgs -WindowStyle Hidden -PassThru
+ Add-Content -Path $orcLogPath -Value ("Process ID : " + $proc.Id) -Encoding UTF8
+ OLog ("Tool launched. PID=" + $proc.Id + " Transcript=" + $transcriptPath) 'Success'
+ OStatus ($toolTitle + " launched.")
+ # Hide orchestrator console window now that the GUI tool is running
+ try {
+ if (-not ([System.Management.Automation.PSTypeName]'OrcConsole').Type) {
+ Add-Type -TypeDefinition @"
+using System.Runtime.InteropServices;
+public class OrcConsole {
+ [DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow);
+ [DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow();
+}
+"@
+ }
+ $null = [OrcConsole]::ShowWindow([OrcConsole]::GetConsoleWindow(), 0)
+ } catch {}
+ Start-Sleep -Milliseconds 800
+ $form.Close()
+ } catch {
+ $errMsg = "Launch failed: " + $_.Exception.Message
+ Add-Content -Path $orcLogPath -Value ("ERROR: " + $errMsg) -Encoding UTF8
+ OLog $errMsg 'Error'
+ OStatus 'Launch failed. Check log.'
+ $btnClose.Visible = $true
+ }
+})
+
+$form.ShowDialog() | Out-Null
+exit 0
+
+#========== BEGIN_GUI_TOOL_CONTENT ==========
+# Note: #Requires omitted - orchestrator guarantees PS7
+# Module requirements checked at runtime by the prereq window (see #region PREREQ CHECK)
+# Values passed from orchestrator via environment variables (no param block -
+# a param() in the middle of the orchestrator file causes PS5 parse errors)
+$OverrideLogFolder = if ($env:MDMODA_LogFolder) { $env:MDMODA_LogFolder } else { "" }
+$TranscriptPath = if ($env:MDMODA_Transcript) { $env:MDMODA_Transcript } else { "" }
+
+# -- Early-crash diagnostic: written to TEMP before DevConfig initialised --
+$_earlyLog = Join-Path $env:TEMP ("MDM-ODA_Early_{0}.log" -f (Get-Date -f 'yyyyMMdd_HHmmss'))
+function Write-EarlyLog { param([string]$Msg) try { Add-Content -Path $_earlyLog -Value "[$(Get-Date -f 'HH:mm:ss')] $Msg" -Encoding UTF8 } catch {} }
+Write-EarlyLog "GUI tool started. PS=$($PSVersionTable.PSVersion) User=$($env:USERNAME)"
+
+
+# .SYNOPSIS
+ # MDM On-Demand Actions (MDM-ODA) - PowerShell 7 & WPF tool for Entra ID and Intune on-demand operations.
+#
+# .DESCRIPTION
+ # A single-file PowerShell 7+ WPF GUI application for Entra ID and Intune on-demand
+ # operations with an embedded WPF interface. Features include:
+ # - Group Management: Search, list members, create, rename, compare, bulk owners,
+ # add user devices, find common/distinct, dynamic membership, object membership
+ # - Device Management: Device info, Intune policy assignments across all policy types
+ # - Productivity: Session notes, keyword filter, verbose logging, XLSX export
+ # - Security: OAuth 2.0 delegated auth, read-only API scopes by default,
+ # validation-before-commit for all write operations, least privilege enforcement
+ # All write operations require mandatory Entra validation + user confirmation before execution.
+ # Real-time verbose logging in the lower pane, simultaneously written to a local log file.
+#
+# .NOTES
+ # Author : Satish Singhi
+ # Version : 0.81
+ # Requires : PowerShell 7+, Microsoft.Graph SDK
+ # Auth : Azure App Registration - Delegated permissions (interactive browser)
+ # Connect-MgGraph is called WITHOUT -Scopes parameter
+ # Permissions : User.Read, User.Read.All, Group.Read.All, GroupMember.Read.All,
+ # Directory.Read.All, Device.Read.All, DeviceManagementConfiguration.Read.All,
+ # DeviceManagementManagedDevices.Read.All, DeviceManagementRBAC.Read.All, offline_access
+#
+ # ===================== DEVELOPER CONFIG - EDIT THIS SECTION =====================
+ # All developer-facing settings are in the #region DEVELOPER CONFIG block below.
+ # This includes: theme colours, background image, logo, log path, window size, etc.
+ # ================================================================================
+
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+# ============================================================
+#region DEVELOPER CONFIG
+# ============================================================
+
+$DevConfig = [ordered]@{
+
+ # ---- Azure App Registration ----
+ TenantId = ""
+ ClientId = ""
+
+ # ---- Local log file path ----
+ # A timestamped log file will be created here each session
+ LogFolder = "C:\Logs\MDM-ODA\Logs"
+
+ # ---- Window ----
+ WindowTitle = "MDM On-Demand Actions v0.81"
+ WindowWidth = 1100
+ WindowHeight = 780
+ MinWidth = 900
+ MinHeight = 600
+
+ # ---- Verbose pane ----
+ VerbosePaneDefaultHeight = 200 # pixels; user can drag the splitter
+
+ # ---- Background image ----
+ # Local file path takes priority over URL. Set both to "" to use solid colour.
+ BackgroundImagePath = "" # e.g. "C:\Assets\bg.jpg" (local file)
+ BackgroundImageUrl = "" # e.g. "https://intranet/assets/bg.jpg" (web URL)
+ BackgroundImageOpacity = 0.08 # 0.0 (invisible) to 1.0 (fully opaque)
+
+ # ---- Logo ----
+ # Local file path takes priority over URL. Set both to "" to hide the logo.
+ LogoImagePath = "" # e.g. "C:\Assets\logo.png" (local file)
+ LogoImageUrl = "" # e.g. "https://intranet/assets/logo.png" (web URL)
+ LogoWidth = 240
+ LogoHeight = 80
+ LogoBase64 = "" # Base64-encoded image (PNG/JPG/ICO). Takes priority over Path/URL.
+ LogoTitleSpacing = 12 # Pixels between logo and title text
+ # To encode: [Convert]::ToBase64String([IO.File]::ReadAllBytes("C:\logo.png"))
+ # To encode from URL: [Convert]::ToBase64String((Invoke-WebRequest "https://...").Content)
+
+ # ---- Colour theme ----
+ # Primary colour used for header bar, buttons, accents
+ ColorPrimary = "#007A00" # Primary green
+ ColorPrimaryLight = "#009900" # Primary green (hover)
+ ColorPrimaryDark = "#006400" # Primary green (pressed)
+ ColorAccent = "#007A00" # Accent green (unified with primary)
+ ColorAccentLight = "#009900"
+ ColorBackground = "#F8FBF8" # Page background
+ ColorSurface = "#FFFFFF" # Card / panel background
+ ColorBorder = "#B0D0B0"
+ ColorText = "#1A1F2E"
+ ColorTextMuted = "#5A6480"
+ ColorSuccess = "#1E7A4A"
+ ColorWarning = "#B45B00"
+ ColorError = "#C0392B"
+ ColorInfo = "#1A5FA8"
+
+ # ---- Verbose pane log colours (foreground hex) ----
+ LogColorInfo = "#9DB8D8"
+ LogColorSuccess = "#5DC98B"
+ LogColorWarning = "#F5A623"
+ LogColorError = "#F06C6C"
+ LogColorAction = "#C8DEF5"
+
+ # ---- Font ----
+ FontFamily = "Segoe UI"
+ FontSizeBase = 13
+
+ # ---- Max objects per batch Graph request ----
+ GraphBatchSize = 20
+
+ # ---- PIM role auto-refresh interval (minutes). Set to 0 to disable.
+ PimRefreshIntervalMinutes = 5
+
+ # ---- Feedback URL (opened when Feedback button is clicked) ----
+ # Set to the URL of your feedback form, Teams channel, or any web page.
+ # Leave empty to hide the Feedback button.
+ FeedbackUrl = "" # e.g. "https://forms.office.com/yourformid"
+
+ # ---- M365 Group Mail Domain ----
+ # Domain suffix shown next to the mail nickname field when creating M365 groups.
+ # e.g. "contoso.onmicrosoft.com" or "contoso.com"
+ # Leave empty to show only "@" (tenant assigns the domain automatically).
+ M365MailDomain = "" # e.g. "contoso.onmicrosoft.com"
+}
+
+#endregion DEVELOPER CONFIG
+# ============================================================
+
+
+# ============================================================
+#region BOOTSTRAP - STA check & assemblies
+# ============================================================
+
+# ── Start transcript immediately for startup diagnostics ─────────────────────
+$script:TranscriptStarted = $false
+try {
+ $tsPath = if ($TranscriptPath -and $TranscriptPath -ne "") {
+ $TranscriptPath
+ } else {
+ Join-Path $env:TEMP ("MDMODA_Transcript_{0}.log" -f (Get-Date -f "yyyyMMdd_HHmmss"))
+ }
+ $tsDir = Split-Path $tsPath -Parent
+ if (-not (Test-Path $tsDir)) { New-Item -ItemType Directory -Path $tsDir -Force | Out-Null }
+ Start-Transcript -Path $tsPath -Append -ErrorAction Stop
+ $script:TranscriptStarted = $true
+ Write-Host "[STARTUP] Transcript: $tsPath"
+} catch {
+ Write-Host "[STARTUP] Transcript failed: $($_.Exception.Message)"
+}
+
+Add-Type -AssemblyName PresentationFramework
+Add-Type -AssemblyName PresentationCore
+Add-Type -AssemblyName WindowsBase
+Add-Type -AssemblyName System.Windows.Forms
+
+# ── WPF Application singleton ─────────────────────────────────────────────────
+# Required for transparent/custom-chrome windows (WindowStyle=None).
+# ShutdownMode=OnExplicitShutdown keeps the dispatcher alive across
+# multiple windows (prereq -> main). Each window uses DispatcherFrame
+# to pump its own message loop without calling Application.Run().
+if (-not [System.Windows.Application]::Current) {
+ $script:WpfApp = [System.Windows.Application]::new()
+ $script:WpfApp.ShutdownMode = [System.Windows.ShutdownMode]::OnExplicitShutdown
+} else {
+ [System.Windows.Application]::Current.ShutdownMode = [System.Windows.ShutdownMode]::OnExplicitShutdown
+}
+
+# ── DPI awareness - must be set before any WPF window is created ──
+# Per-Monitor v2 tells WPF to render at native DPI on each monitor,
+# preventing the window from being clipped or mis-sized on high-DPI displays.
+try {
+ Add-Type -TypeDefinition @'
+using System;
+using System.Runtime.InteropServices;
+public class DpiAware {
+ [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
+ // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
+ public static void SetPerMonitorV2() { SetProcessDpiAwarenessContext(new IntPtr(-4)); }
+}
+'@ -ErrorAction SilentlyContinue
+ [DpiAware]::SetPerMonitorV2()
+} catch {}
+
+# Ensure we are on an STA thread (required for WPF)
+# The orchestrator launcher guarantees -STA; this is a safety net for direct launches.
+if ([System.Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') {
+ Write-Error "This script must run on an STA thread. Use the orchestrator launcher, or run: pwsh -STA -File '$PSCommandPath'"
+ exit 1
+}
+
+# Override LogFolder from orchestrator if provided
+if (-not [string]::IsNullOrWhiteSpace($OverrideLogFolder)) {
+ $DevConfig.LogFolder = $OverrideLogFolder
+ Write-Host "[STARTUP] LogFolder overridden to: $OverrideLogFolder"
+}
+
+# Ensure log folder exists
+if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogFolder)) {
+ if (-not (Test-Path $DevConfig.LogFolder)) {
+ New-Item -ItemType Directory -Path $DevConfig.LogFolder -Force | Out-Null
+ }
+}
+
+$script:SessionStamp = (Get-Date).ToString("yyyyMMdd_HHmmss")
+$script:LogFile = if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogFolder)) {
+ Join-Path $DevConfig.LogFolder ("MDM-ODA_{0}.log" -f $script:SessionStamp)
+} else { $null }
+
+#endregion
+
+
+# ============================================================
+#region PREREQ CHECK WINDOW
+# ============================================================
+
+function Start-PrereqCheck {
+
+ # Prereq window - runs checks on a background runspace.
+ # Results are posted to a ConcurrentQueue drained by a DispatcherTimer.
+ # No STA thread blocking. Find-Module (network) runs in the same runspace.
+
+
+ $requiredModules = @(
+ [PSCustomObject]@{ Name = 'Microsoft.Graph.Authentication'; Label = 'Graph . Authentication' }
+ )
+ $optionalModules = @(
+ [PSCustomObject]@{ Name = 'ImportExcel'; Label = 'ImportExcel (XLSX export)' }
+ )
+
+ # ── Shared queue between runspace and UI ──
+ Write-Host "[PREREQ] Entering Start-PrereqCheck"
+ $queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new()
+ Write-Host "[PREREQ] Queue created"
+
+ # ── Build XAML (simple Grid rows - no data binding) ──
+ [xml]$prereqXaml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0,0,0,1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ Write-Host "[PREREQ] Parsing XAML..."
+ $reader = [System.Xml.XmlNodeReader]::new($prereqXaml)
+ Write-Host "[PREREQ] XmlNodeReader ready"
+ $prereqWin = [Windows.Markup.XamlReader]::Load($reader)
+ Write-Host "[PREREQ] Window loaded: $($prereqWin -ne $null)"
+ Write-Host "[PREREQ] Wiring elements..."
+ $spChecks = $prereqWin.FindName('SpChecks')
+ $rtbLog = $prereqWin.FindName('RtbPrereqLog')
+ $btnInstall = $prereqWin.FindName('BtnPrereqInstall')
+ $btnContinue = $prereqWin.FindName('BtnPrereqContinue')
+ $txtSummary = $prereqWin.FindName('TxtPrereqSummary')
+ $prereqLogo = $prereqWin.FindName('PrereqLogoImg')
+
+ # ── Load logo into prereq header (same DevConfig sources as main tool) ──
+ try {
+ $logoSrc = $null
+ # Priority 1: Base64
+ if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogoBase64)) {
+ $bytes = [Convert]::FromBase64String($DevConfig.LogoBase64.Trim())
+ $ms = [System.IO.MemoryStream]::new($bytes)
+ $bmp = [System.Windows.Media.Imaging.BitmapImage]::new()
+ $bmp.BeginInit()
+ $bmp.StreamSource = $ms
+ $bmp.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
+ $bmp.EndInit(); $null = $bmp.Freeze()
+ $logoSrc = $bmp
+ }
+ # Priority 2: Local path
+ if (-not $logoSrc -and -not [string]::IsNullOrWhiteSpace($DevConfig.LogoImagePath) -and (Test-Path $DevConfig.LogoImagePath)) {
+ $logoSrc = [System.Windows.Media.Imaging.BitmapFrame]::Create([Uri]::new((Resolve-Path $DevConfig.LogoImagePath).Path))
+ }
+ # Priority 3: URL
+ if (-not $logoSrc -and -not [string]::IsNullOrWhiteSpace($DevConfig.LogoImageUrl)) {
+ $bmp = [System.Windows.Media.Imaging.BitmapImage]::new()
+ $bmp.BeginInit()
+ $bmp.UriSource = [Uri]::new($DevConfig.LogoImageUrl)
+ $bmp.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
+ $bmp.EndInit()
+ $logoSrc = $bmp
+ }
+ if ($logoSrc) {
+ $prereqLogo.Source = $logoSrc
+ $prereqLogo.Visibility = 'Visible'
+ Write-Host "[PREREQ] Logo loaded into header"
+ }
+ } catch { Write-Host "[PREREQ] Logo load skipped: $($_.Exception.Message)" }
+ Write-Host "[PREREQ] Elements wired ok"
+
+ $script:PrereqResult = $false
+ $script:MissingMandatory = [System.Collections.Generic.List[string]]::new()
+ $script:MissingOptional = [System.Collections.Generic.List[string]]::new()
+ $script:UpdateAvailable = [System.Collections.Generic.List[string]]::new()
+
+ # Row registry: key = row-id string, value = hashtable of TextBlock refs
+ $rowRegistry = [hashtable]::Synchronized(@{})
+
+ # ── UI helpers (run on dispatcher) ──────────────────────────────────────
+ function Add-CheckRow {
+ param([string]$RowId, [string]$Label, [string]$Detail)
+ $prereqWin.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
+ $outer = [System.Windows.Controls.Grid]::new()
+ $outer.Margin = [System.Windows.Thickness]::new(8,5,8,5)
+ $c0 = [System.Windows.Controls.ColumnDefinition]::new(); $c0.Width = [System.Windows.GridLength]::new(22)
+ $c1 = [System.Windows.Controls.ColumnDefinition]::new(); $c1.Width = [System.Windows.GridLength]::new(1,[System.Windows.GridUnitType]::Star)
+ $c2 = [System.Windows.Controls.ColumnDefinition]::new(); $c2.Width = [System.Windows.GridLength]::new(1,[System.Windows.GridUnitType]::Auto)
+ $outer.ColumnDefinitions.Add($c0); $outer.ColumnDefinitions.Add($c1); $outer.ColumnDefinitions.Add($c2)
+
+ # Icon — hourglass U+231B, rendered in Segoe UI Symbol for reliable glyph
+ $tbIcon = [System.Windows.Controls.TextBlock]::new()
+ $tbIcon.Text = [string][char]0x231B; $tbIcon.FontSize = 13
+ $tbIcon.FontFamily = [System.Windows.Media.FontFamily]::new("Segoe UI Symbol, Segoe UI Emoji, Segoe UI")
+ $tbIcon.VerticalAlignment = 'Center'
+ $tbIcon.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#999999'))
+ $tbIcon.Opacity = 0.45
+ # Opacity pulse animation (pre-built, started on demand)
+ $pulseAnim = [System.Windows.Media.Animation.DoubleAnimation]::new()
+ $pulseAnim.From = 0.35; $pulseAnim.To = 1.0
+ $pulseAnim.Duration = [System.Windows.Duration]::new([TimeSpan]::FromMilliseconds(700))
+ $pulseAnim.AutoReverse = $true
+ $pulseAnim.RepeatBehavior = [System.Windows.Media.Animation.RepeatBehavior]::Forever
+ [System.Windows.Controls.Grid]::SetColumn($tbIcon, 0)
+
+ $inner = [System.Windows.Controls.StackPanel]::new()
+ $inner.Margin = [System.Windows.Thickness]::new(6,0,0,0)
+ $inner.VerticalAlignment = 'Center'
+ $tbLabel = [System.Windows.Controls.TextBlock]::new()
+ $tbLabel.Text = $Label; $tbLabel.FontSize = 12; $tbLabel.FontWeight = 'SemiBold'
+ $tbLabel.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#888888'))
+ $tbDetail = [System.Windows.Controls.TextBlock]::new()
+ $tbDetail.Text = $Detail; $tbDetail.FontSize = 10.5; $tbDetail.TextWrapping = 'Wrap'
+ $tbDetail.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#AAAAAA'))
+ $tbDetail.Margin = [System.Windows.Thickness]::new(0,1,0,0)
+ $inner.Children.Add($tbLabel) | Out-Null
+ $inner.Children.Add($tbDetail) | Out-Null
+ [System.Windows.Controls.Grid]::SetColumn($inner, 1)
+
+ $tbStatus = [System.Windows.Controls.TextBlock]::new()
+ $tbStatus.Text = 'Queued'; $tbStatus.FontSize = 11; $tbStatus.FontWeight = 'SemiBold'
+ $tbStatus.VerticalAlignment = 'Center'
+ $tbStatus.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#AAAAAA'))
+ [System.Windows.Controls.Grid]::SetColumn($tbStatus, 2)
+
+ $outer.Children.Add($tbIcon) | Out-Null
+ $outer.Children.Add($inner) | Out-Null
+ $outer.Children.Add($tbStatus) | Out-Null
+ $spChecks.Children.Add($outer) | Out-Null
+
+ $rowRegistry[$RowId] = @{ Icon = $tbIcon; Label = $tbLabel; Detail = $tbDetail; Status = $tbStatus; Outer = $outer; PulseAnimation = $pulseAnim }
+ })
+ }
+
+ function Apply-RowUpdate {
+ param([hashtable]$Msg)
+ $rid = $Msg['RowId']
+ if (-not $rowRegistry.ContainsKey($rid)) { return }
+ $row = $rowRegistry[$rid]
+ # Stop opacity pulse animation
+ $row['Icon'].BeginAnimation([System.Windows.UIElement]::OpacityProperty, $null)
+ $row['Icon'].Opacity = 1.0
+ # Reset icon font to default for result glyphs (tick/cross render fine in Segoe UI)
+ $row['Icon'].FontFamily = [System.Windows.Media.FontFamily]::new("Segoe UI")
+ # Clear row highlight background
+ if ($row['Outer']) { $row['Outer'].Background = $null }
+ # Apply field updates
+ if ($Msg['Icon']) { $row['Icon'].Text = $Msg['Icon'] }
+ if ($Msg['Status']) { $row['Status'].Text = $Msg['Status'] }
+ if ($Msg['Detail']) { $row['Detail'].Text = $Msg['Detail'] }
+ if ($Msg['LabelColor']) { $row['Label'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Msg['LabelColor'])) }
+ if ($Msg['StatusColor']) { $row['Status'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Msg['StatusColor'])) }
+ if ($Msg['IconColor']) { $row['Icon'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Msg['IconColor'])) }
+ }
+
+ function Append-LogLine {
+ param([string]$Line, [string]$Color = '#9DB8D8')
+ $para = [System.Windows.Documents.Paragraph]::new()
+ $run = [System.Windows.Documents.Run]::new($Line)
+ $run.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Color))
+ $para.Margin = [System.Windows.Thickness]::new(0)
+ $para.Inlines.Add($run)
+ $rtbLog.Document.Blocks.Add($para)
+ $rtbLog.ScrollToEnd()
+ }
+
+ # ── Pre-create all rows before runspace starts ───────────────────────────
+ Add-CheckRow 'ps' 'PowerShell 7+' 'Required for WPF and modern syntax'
+ Add-CheckRow 'os' 'Windows OS + WPF' 'WPF requires Windows'
+ Add-CheckRow 'ep' 'Execution Policy' 'Must allow script execution'
+ foreach ($m in $requiredModules) { Add-CheckRow $m.Name $m.Label "Required module - $($m.Name)" }
+ foreach ($m in $optionalModules) { Add-CheckRow $m.Name $m.Label "Optional - $($m.Name)" }
+
+ # ── Launch background runspace ───────────────────────────────────────────
+ $rs = [runspacefactory]::CreateRunspace()
+ $rs.ApartmentState = 'MTA'
+ $rs.ThreadOptions = 'ReuseThread'
+ $rs.Open()
+ $rs.SessionStateProxy.SetVariable('Queue', $queue)
+ $rs.SessionStateProxy.SetVariable('RequiredModules', $requiredModules)
+ $rs.SessionStateProxy.SetVariable('OptionalModules', $optionalModules)
+
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+
+ $null = $ps.AddScript({
+ function Q {
+ param([hashtable]$Msg)
+ $Queue.Enqueue($Msg)
+ }
+ function TS { (Get-Date).ToString('HH:mm:ss') }
+
+ Q @{ Type='log'; Line="[$(TS)] Starting prerequisite checks..."; Color='#C8DEF5' }
+ Start-Sleep -Milliseconds 200
+
+ # 1. PowerShell version
+ Q @{ Type='checking'; RowId='ps' }
+ Start-Sleep -Milliseconds 250
+ Q @{ Type='log'; Line="[$(TS)] Checking PowerShell version..."; Color='#9DB8D8' }
+ $psv = $PSVersionTable.PSVersion
+ if ($psv.Major -ge 7) {
+ Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2713) PowerShell $($psv.ToString())"; Color='#5DC98B'; RowId='ps'; Icon="$([char]0x2713)"; Status="OK v$($psv.Major).$($psv.Minor).$($psv.Build)"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D' }
+ } else {
+ Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2717) PowerShell $($psv.ToString()) - need 7+"; Color='#F06C6C'; RowId='ps'; Icon="$([char]0x2717)"; Status="FAIL v$($psv.ToString())"; LabelColor='#8B0000'; StatusColor='#8B0000'; IconColor='#8B0000' }
+ Q @{ Type='mandatory'; Item='PowerShell 7+ - https://github.com/PowerShell/PowerShell/releases' }
+ }
+
+ # 2. Windows OS
+ Q @{ Type='checking'; RowId='os' }
+ Start-Sleep -Milliseconds 250
+ Q @{ Type='log'; Line="[$(TS)] Checking OS..."; Color='#9DB8D8' }
+ if ($IsWindows) {
+ Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2713) Windows - WPF available"; Color='#5DC98B'; RowId='os'; Icon="$([char]0x2713)"; Status="OK Windows"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D'; Detail=[System.Environment]::OSVersion.VersionString }
+ } else {
+ Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2717) Not Windows - tool is Windows-only"; Color='#F06C6C'; RowId='os'; Icon="$([char]0x2717)"; Status='NOT WINDOWS'; LabelColor='#8B0000'; StatusColor='#8B0000'; IconColor='#8B0000' }
+ Q @{ Type='mandatory'; Item='Windows OS - WPF is Windows-only' }
+ }
+
+ # 3. Execution Policy
+ Q @{ Type='checking'; RowId='ep' }
+ Start-Sleep -Milliseconds 250
+ Q @{ Type='log'; Line="[$(TS)] Checking execution policy..."; Color='#9DB8D8' }
+ $ep = Get-ExecutionPolicy -Scope CurrentUser
+ $epm = Get-ExecutionPolicy -Scope LocalMachine
+ $epOk = $ep -in @('RemoteSigned','Unrestricted','Bypass') -or $epm -in @('RemoteSigned','Unrestricted','Bypass')
+ if ($epOk) {
+ Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2713) Execution policy: $ep"; Color='#5DC98B'; RowId='ep'; Icon="$([char]0x2713)"; Status="OK ($ep)"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D' }
+ } else {
+ Q @{ Type='rowlog'; Line="[$(TS)] [!] Policy '$ep' may block scripts"; Color='#F5A623'; RowId='ep'; Icon='[!]'; Status="$ep"; StatusColor='#B45B00' }
+ Q @{ Type='mandatory'; Item="ExecutionPolicy - run: Set-ExecutionPolicy RemoteSigned -Scope CurrentUser" }
+ }
+
+ # 4. Required modules (local check only, fast)
+ foreach ($mod in $RequiredModules) {
+ Q @{ Type='checking'; RowId=$mod.Name }
+ Start-Sleep -Milliseconds 250
+ Q @{ Type='log'; Line="[$(TS)] Checking $($mod.Name)..."; Color='#9DB8D8' }
+ $inst = Get-Module -ListAvailable -Name $mod.Name -ErrorAction SilentlyContinue |
+ Sort-Object Version -Descending | Select-Object -First 1
+ if ($inst) {
+ $v = $inst.Version.ToString()
+ Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2713) $($mod.Name) v$v"; Color='#5DC98B'; RowId=$mod.Name; Icon="$([char]0x2713)"; Status="OK v$v"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D'; Detail="Installed: v$v" }
+ Q @{ Type='installed'; ModName=$mod.Name; Version=$v }
+ } else {
+ Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2717) $($mod.Name) - NOT INSTALLED"; Color='#F06C6C'; RowId=$mod.Name; Icon="$([char]0x2717)"; Status='NOT INSTALLED'; LabelColor='#8B0000'; StatusColor='#8B0000'; IconColor='#8B0000'; Detail="Missing" }
+ Q @{ Type='mandatory'; Item=$mod.Name }
+ }
+ }
+
+ # 5. Optional modules
+ foreach ($mod in $OptionalModules) {
+ Q @{ Type='checking'; RowId=$mod.Name }
+ Start-Sleep -Milliseconds 250
+ Q @{ Type='log'; Line="[$(TS)] Checking $($mod.Name) (optional)..."; Color='#9DB8D8' }
+ $inst = Get-Module -ListAvailable -Name $mod.Name -ErrorAction SilentlyContinue |
+ Sort-Object Version -Descending | Select-Object -First 1
+ if ($inst) {
+ $v = $inst.Version.ToString()
+ Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2713) $($mod.Name) v$v"; Color='#5DC98B'; RowId=$mod.Name; Icon="$([char]0x2713)"; Status="OK v$v"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D'; Detail="Installed: v$v" }
+ Q @{ Type='installed'; ModName=$mod.Name; Version=$v }
+ } else {
+ Q @{ Type='rowlog'; Line="[$(TS)] o $($mod.Name) not installed (optional)"; Color='#F5A623'; RowId=$mod.Name; Icon='o'; Status='Not installed'; StatusColor='#B45B00'; Detail='Optional - export falls back to CSV' }
+ Q @{ Type='optional'; Item=$mod.Name }
+ }
+ }
+
+ Q @{ Type='log'; Line="[$(TS)] ---------------------------------"; Color='#333D4D' }
+ Q @{ Type='log'; Line="[$(TS)] Local checks complete. Checking for module updates online..."; Color='#9DB8D8' }
+
+ # 6. Online update check (slow - runs after local checks)
+ $allModNames = ($RequiredModules + $OptionalModules).Name
+ foreach ($modName in $allModNames) {
+ $inst = Get-Module -ListAvailable -Name $modName -ErrorAction SilentlyContinue |
+ Sort-Object Version -Descending | Select-Object -First 1
+ if (-not $inst) { continue }
+ try {
+ Q @{ Type='log'; Line="[$(TS)] Checking online: $modName..."; Color='#9DB8D8' }
+ $gallery = Find-Module -Name $modName -ErrorAction SilentlyContinue
+ if ($gallery -and [version]$gallery.Version -gt [version]$inst.Version) {
+ Q @{ Type='rowlog'; Line="[$(TS)] ^ Update: $modName $($inst.Version) -> $($gallery.Version)"; Color='#9DB8D8'; RowId=$modName; Icon='^'; Status="Update $($gallery.Version)"; StatusColor='#1A5FA8'; Detail="Installed: v$($inst.Version) -> Available: v$($gallery.Version)" }
+ Q @{ Type='update'; Item=$modName }
+ } else {
+ Q @{ Type='log'; Line="[$(TS)] $([char]0x2713) $modName up to date"; Color='#5DC98B' }
+ }
+ } catch {
+ Q @{ Type='log'; Line="[$(TS)] [!] Could not check online for $modName"; Color='#F5A623' }
+ }
+ }
+
+ Q @{ Type='log'; Line="[$(TS)] ---------------------------------"; Color='#333D4D' }
+ Q @{ Type='done' }
+ })
+
+ $bgHandle = $ps.BeginInvoke()
+
+ # ── DispatcherTimer - drains queue on UI thread ──────────────────────────
+ $timer = [System.Windows.Threading.DispatcherTimer]::new()
+ $timer.Interval = [TimeSpan]::FromMilliseconds(120)
+
+ $timer.Add_Tick({
+ try {
+ $msg = $null
+ while ($queue.TryDequeue([ref]$msg)) {
+ switch ($msg['Type']) {
+ 'log' {
+ Append-LogLine -Line $msg['Line'] -Color $msg['Color']
+ }
+ 'checking' {
+ $rid = $msg['RowId']
+ if ($rowRegistry.ContainsKey($rid)) {
+ $row = $rowRegistry[$rid]
+ # Activate: pulsing hourglass, teal colour, row highlight
+ $row['Icon'].Text = [string][char]0x231B
+ $row['Icon'].FontFamily = [System.Windows.Media.FontFamily]::new('Segoe UI Symbol, Segoe UI Emoji, Segoe UI')
+ $row['Icon'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#0A9396'))
+ $row['Status'].Text = 'Checking...'
+ $row['Status'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#0A9396'))
+ $row['Label'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#1A1F2E'))
+ $row['Detail'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#5A6480'))
+ $row['Outer'].Background = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#F0FAF0'))
+ # Start opacity pulse animation
+ $row['Icon'].BeginAnimation([System.Windows.UIElement]::OpacityProperty, $row['PulseAnimation'])
+ }
+ }
+ 'row' {
+ Apply-RowUpdate -Msg $msg
+ }
+ 'rowlog' {
+ # Atomic: render log line THEN row update in same dispatcher callback
+ Append-LogLine -Line $msg['Line'] -Color $msg['Color']
+ Apply-RowUpdate -Msg $msg
+ }
+ 'mandatory' {
+ $script:MissingMandatory.Add($msg['Item'])
+ }
+ 'optional' {
+ $script:MissingOptional.Add($msg['Item'])
+ }
+ 'update' {
+ $script:UpdateAvailable.Add($msg['Item'])
+ }
+ 'done' {
+ $timer.Stop()
+ $ps.EndInvoke($bgHandle)
+ $rs.Close(); $ps.Dispose()
+
+ $hasMand = $script:MissingMandatory.Count -gt 0
+ $hasOpt = $script:MissingOptional.Count -gt 0
+ $hasUpd = $script:UpdateAvailable.Count -gt 0
+
+ # Summary
+ $parts = @()
+ if ($hasMand) { $parts += "$($script:MissingMandatory.Count) required missing" }
+ if ($hasOpt) { $parts += "$($script:MissingOptional.Count) optional missing" }
+ if ($hasUpd) { $parts += "$($script:UpdateAvailable.Count) update(s) available" }
+ if ($parts.Count -eq 0) {
+ $txtSummary.Text = "$([char]0x2713) All checks passed."
+ Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] $([char]0x2713) All checks passed - ready to launch." '#5DC98B'
+ } else {
+ $txtSummary.Text = $parts -join ' | '
+ if ($hasMand) { Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] $([char]0x2717) Mandatory items missing." '#F06C6C' }
+ }
+
+ # Install button
+ $installable = [System.Collections.Generic.List[string]]::new()
+ $allModNames = ($requiredModules + $optionalModules).Name
+ foreach ($item in ($script:MissingMandatory + $script:MissingOptional + $script:UpdateAvailable)) {
+ if ($allModNames -contains $item) { $installable.Add($item) }
+ }
+ if ($installable.Count -gt 0) {
+ $btnInstall.Visibility = 'Visible'
+ if ($hasMand -and $hasUpd) { $btnInstall.Content = 'Install Missing + Update' }
+ elseif ($hasMand) { $btnInstall.Content = 'Install Missing' }
+ elseif ($hasUpd) { $btnInstall.Content = 'Update Modules' }
+ else { $btnInstall.Content = 'Install Optional' }
+ }
+
+ if (-not $hasMand) {
+ $btnContinue.IsEnabled = $true
+ $txtSummary.Text = "✓ All checks passed - click Continue to proceed"
+ }
+ }
+ }
+ }
+ } catch {
+ Write-ErrorLog "PrereqTimer error: $($_.Exception.Message)"
+ }
+ })
+
+ $timer.Start()
+
+ # ── Native WPF confirm dialog (replaces MessageBox) ──────────────────────
+ function Show-PrereqConfirm {
+ param([string]$Title, [string]$Message, [bool]$IsWarning = $false)
+ # Title and Message set in code - never interpolated into XML (avoids
+ # XML-special chars: &, <, >, newlines breaking the document parse)
+ [xml]$dlgXml = @'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+'@
+ $dlgReader = [System.Xml.XmlNodeReader]::new($dlgXml)
+ $dlg = [Windows.Markup.XamlReader]::Load($dlgReader)
+ $dlg.Owner = $prereqWin
+ $dlg.Title = $Title
+ $dlg.FindName('TxtMsg').Text = $Message
+
+ # Colour the Proceed button - evaluate colour before passing to method
+ $yesBtn = $dlg.FindName('BtnYes')
+ $btnColor = if ($IsWarning) { '#C0392B' } else { '#007A00' }
+ $yesBtn.Background = [System.Windows.Media.SolidColorBrush]::new(
+ [System.Windows.Media.ColorConverter]::ConvertFromString($btnColor))
+
+ $script:PrereqDlgResult = $false
+ $dlg.FindName('BtnNo').Add_Click({ $script:PrereqDlgResult = $false; $dlg.Close() })
+ $yesBtn.Add_Click({ $script:PrereqDlgResult = $true; $dlg.Close() })
+ $dlg.ShowDialog() | Out-Null
+ return $script:PrereqDlgResult
+ }
+
+ # ── Install button ────────────────────────────────────────────────────────
+ $btnInstall.Add_Click({
+ $allModNames = ($requiredModules + $optionalModules).Name
+ $toInstall = [System.Collections.Generic.List[string]]::new()
+ foreach ($item in ($script:MissingMandatory + $script:MissingOptional + $script:UpdateAvailable)) {
+ if ($allModNames -contains $item -and -not $toInstall.Contains($item)) {
+ $toInstall.Add($item)
+ }
+ }
+ if ($toInstall.Count -eq 0) {
+ $null = Show-PrereqConfirm -Title "Nothing to Install" `
+ -Message "No installable modules found.`nPS version, OS and execution policy issues must be resolved manually."
+ return
+ }
+
+ $listTxt = ($toInstall | ForEach-Object { " - $_" }) -join "`n"
+
+ # Install using CurrentUser scope - no admin rights required
+ $confirmMsg = "The following modules will be installed or updated:`n`n$listTxt`n`n"
+ $confirmMsg += "Modules will be installed in CurrentUser scope (no admin required)."
+ if (-not (Show-PrereqConfirm -Title "Install Modules" -Message $confirmMsg)) { return }
+
+ $btnInstall.IsEnabled = $false
+ $btnContinue.IsEnabled = $false
+ $btnInstall.Content = "[wait] Installing..."
+ Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] -- Installing modules (elevated session) --" '#C8DEF5'
+
+ # Use a runspace so Install-Module output streams to our queue live
+ # All vars promoted to script scope - closure capture is unreliable in WPF handlers
+ $script:InstallQueue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new()
+ $script:InstallRs = [runspacefactory]::CreateRunspace()
+ $script:InstallRs.ApartmentState = 'MTA'
+ $script:InstallRs.ThreadOptions = 'ReuseThread'
+ $script:InstallRs.Open()
+ $script:InstallRs.SessionStateProxy.SetVariable('InstallQueue', $script:InstallQueue)
+ $script:InstallRs.SessionStateProxy.SetVariable('ModulesToInstall', $toInstall)
+
+ $script:InstallPs = [powershell]::Create()
+ $script:InstallPs.Runspace = $script:InstallRs
+ $null = $script:InstallPs.AddScript({
+ function IQ { param([hashtable]$M) $InstallQueue.Enqueue($M) }
+ function TS { (Get-Date).ToString('HH:mm:ss') }
+ $allOk = $true
+ foreach ($modName in $ModulesToInstall) {
+ IQ @{ Type='log'; Line="[$(TS)] Installing $modName ..."; Color='#C8DEF5' }
+ try {
+ # Redirect verbose/progress into variable to avoid console output
+ $null = Install-Module $modName -Scope CurrentUser -Force -AllowClobber `
+ -ErrorAction Stop -Verbose 4>&1 | ForEach-Object {
+ IQ @{ Type='log'; Line="[$(TS)] $_"; Color='#9DB8D8' }
+ }
+ IQ @{ Type='log'; Line="[$(TS)] $([char]0x2713) $modName - done."; Color='#5DC98B' }
+ } catch {
+ IQ @{ Type='log'; Line="[$(TS)] $([char]0x2717) $modName - failed: $($_.Exception.Message)"; Color='#F06C6C' }
+ $allOk = $false
+ }
+ }
+ IQ @{ Type='done'; AllOk=$allOk }
+ })
+ $script:InstallHandle = $script:InstallPs.BeginInvoke()
+
+ # Drain install queue via a separate timer
+ $script:InstallTimer = [System.Windows.Threading.DispatcherTimer]::new()
+ $script:InstallTimer.Interval = [TimeSpan]::FromMilliseconds(150)
+ $script:InstallTimer.Add_Tick({
+ $m = $null
+ while ($script:InstallQueue.TryDequeue([ref]$m)) {
+ if ($m['Type'] -eq 'log') {
+ Append-LogLine $m['Line'] $m['Color']
+ } elseif ($m['Type'] -eq 'done') {
+ $script:InstallTimer.Stop()
+ $script:InstallPs.EndInvoke($script:InstallHandle)
+ $script:InstallRs.Close(); $script:InstallPs.Dispose()
+
+ if ($m['AllOk']) {
+ Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] $([char]0x2713) All modules installed successfully." '#5DC98B'
+ Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] Note: A PowerShell session restart may be needed" '#F5A623'
+ Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] for newly installed modules to load correctly." '#F5A623'
+ } else {
+ Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] [!] Some modules failed - see log above." '#F5A623'
+ }
+
+ # Show native restart prompt
+ $restartMsg = "Modules installed successfully.`n`n"
+ $restartMsg += "PowerShell modules load at session start - to ensure the`n"
+ $restartMsg += "tool uses the updated versions, it needs to relaunch in a`n"
+ $restartMsg += "fresh session.`n`nRelaunch now?"
+
+ $btnInstall.Content = "Install Missing"
+ $btnInstall.IsEnabled = $true
+
+ if (Show-PrereqConfirm -Title "Relaunch Required" -Message $restartMsg) {
+ Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] Relaunching tool in new session..." '#C8DEF5'
+ $scriptPath = $PSCommandPath
+ Start-Process pwsh -ArgumentList "-STA -File `"$scriptPath`""
+ $prereqWin.Close()
+ [System.Environment]::Exit(0)
+ } else {
+ # User declined relaunch - re-run checks so they see current state
+ $script:RerunPrereq = $true
+ $script:PrereqResult = $false
+ $prereqWin.Close()
+ }
+ }
+ }
+ })
+ $script:InstallTimer.Start()
+ return
+ })
+
+ # ── Continue / Close ──────────────────────────────────────────────────────
+ $btnContinue.Add_Click({
+ $script:PrereqResult = $true
+ # Show feedback before window closes - main window takes ~20s to build
+ $btnContinue.IsEnabled = $false
+ $btnContinue.Content = 'Loading...'
+ $btnInstall.IsEnabled = $false
+ if ($txtSummary) { $txtSummary.Text = 'Building main window, please wait...' }
+ $prereqWin.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Render, [Action]{})
+ $prereqWin.Close()
+ })
+
+ $prereqWin.Add_Closed({
+ $timer.Stop()
+ if (-not $script:PrereqResult) { exit 0 }
+ })
+
+ $prereqWin.ShowDialog() | Out-Null
+ return $script:PrereqResult
+}
+
+
+
+# ── Error log + trap defined BEFORE prereq so the trap can log errors ──
+# ── Diagnostic: log any unhandled errors to a temp file ──────────────────────
+# Visible error log so we can diagnose failures when the console is hidden.
+# Remove once stable.
+$_errDir = if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogFolder) -and
+ (Test-Path $DevConfig.LogFolder)) { $DevConfig.LogFolder } else { $env:TEMP }
+$script:ErrorLogPath = Join-Path $_errDir ("MDMODA_Error_{0}.log" -f $script:SessionStamp)
+try { Remove-Item $script:ErrorLogPath -Force -ErrorAction SilentlyContinue } catch {}
+Write-Host "[STARTUP] Error log: $script:ErrorLogPath"
+function Write-ErrorLog {
+ param([string]$Msg)
+ try {
+ $ts = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
+ Add-Content -Path $script:ErrorLogPath -Value "[$ts] $Msg" -Encoding UTF8
+ } catch {}
+}
+Write-ErrorLog "Prereq passed. Building main window..."
+Write-ErrorLog "PS Version : $($PSVersionTable.PSVersion)"
+Write-ErrorLog "OS Version : $([System.Environment]::OSVersion.VersionString)"
+Write-ErrorLog "User : $($env:USERNAME)@$($env:USERDOMAIN)"
+Write-ErrorLog "LogFolder : $($DevConfig.LogFolder)"
+Write-ErrorLog "Error log : $script:ErrorLogPath"
+Write-ErrorLog "Transcript : $(if ($script:TranscriptStarted) { $tsPath } else { 'NOT STARTED' })"
+$Error.Clear()
+Write-EarlyLog "Checkpoint 1: Starting XAML definition..."
+Write-ErrorLog "Checkpoint 1: Starting XAML definition..."
+
+# ── Catch-all trap - logs any terminating error to the error log ─────────────
+trap {
+ $errMsg = "TRAP at line $($_.InvocationInfo.ScriptLineNumber): $($_.Exception.Message)"
+ $errStack = $_.ScriptStackTrace
+ Write-ErrorLog $errMsg
+ Write-ErrorLog "Stack: $errStack"
+ try {
+ Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
+ [System.Windows.Forms.MessageBox]::Show(
+ "$errMsg`n`nSee: $script:ErrorLogPath",
+ "MDM-ODA - Error",
+ [System.Windows.Forms.MessageBoxButtons]::OK,
+ [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null
+ } catch {}
+ break
+}
+
+# Run the prereq check - loop supports in-session reinstall when already admin
+$script:RerunPrereq = $false
+do {
+ $script:RerunPrereq = $false
+ if (-not (Start-PrereqCheck)) { exit 0 }
+} while ($script:RerunPrereq)
+
+#endregion PREREQ CHECK
+
+
+
+
+# ============================================================
+#region XAML - UI Definition
+# ============================================================
+
+# ── Compute window size from available screen working area ──
+# Uses SystemParameters (WPF logical pixels already DPI-adjusted) so the
+# window fits on any screen size / scaling factor without clipping.
+$_screen = [System.Windows.SystemParameters]
+$_workW = [int]$_screen::WorkArea.Width
+$_workH = [int]$_screen::WorkArea.Height
+# Target 92% of working area, clamped to config defaults as ceiling
+$_winW = [int][Math]::Min([Math]::Floor($_workW * 0.92), $DevConfig.WindowWidth)
+$_winH = [int][Math]::Min([Math]::Floor($_workH * 0.92), $DevConfig.WindowHeight)
+# Never go below the minimum sizes
+$_winW = [Math]::Max($_winW, $DevConfig.MinWidth)
+$_winH = [Math]::Max($_winH, $DevConfig.MinHeight)
+
+[xml]$Xaml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0,0,0,1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0,0,0,1
+
+
+
+
+
+
+
+
+
+
+ 0,0,0,1
+
+
+
+
+
+
+ 0,0,0,1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0,1,0,0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+#endregion XAML
+Write-ErrorLog "Checkpoint 2: XAML defined OK."
+Write-EarlyLog "Checkpoint 2: XAML defined OK."
+
+
+# ============================================================
+#region UI HELPER FUNCTIONS
+# ============================================================
+
+function Get-Timestamp { (Get-Date).ToString("HH:mm:ss") }
+
+function Write-VerboseLog {
+ param(
+ [string]$Message,
+ [ValidateSet('Info','Success','Warning','Error','Action')]
+ [string]$Level = 'Info'
+ )
+
+ $color = switch ($Level) {
+ 'Info' { $DevConfig.LogColorInfo }
+ 'Success' { $DevConfig.LogColorSuccess }
+ 'Warning' { $DevConfig.LogColorWarning }
+ 'Error' { $DevConfig.LogColorError }
+ 'Action' { $DevConfig.LogColorAction }
+ }
+
+ $prefix = switch ($Level) {
+ 'Info' { ' ' }
+ 'Success' { "$([char]0x2713) " }
+ 'Warning' { '[!] ' }
+ 'Error' { "$([char]0x2717) " }
+ 'Action' { '> ' }
+ }
+
+ $ts = Get-Timestamp
+ $line = "[$ts] $prefix$Message"
+
+ # Write to file
+ if ($script:LogFile) {
+ try { Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue }
+ catch {}
+ }
+
+ # Write to RichTextBox on UI thread
+ $script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
+ try {
+ $para = [System.Windows.Documents.Paragraph]::new()
+ $run = [System.Windows.Documents.Run]::new($line)
+ $run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
+ [System.Windows.Media.ColorConverter]::ConvertFromString($color)
+ )
+ $para.Inlines.Add($run)
+ $para.Margin = [System.Windows.Thickness]::new(0,0,0,0)
+ $script:RtbLog.Document.Blocks.Add($para)
+ $script:RtbLog.ScrollToEnd()
+ } catch {}
+ })
+}
+
+function Show-Panel {
+ param([string]$Name)
+ $panels = @('PanelSearch','PanelListMembers','PanelObjectMembership','PanelFindGroupsByOwners','PanelWelcome','PanelCreate','PanelMembership','PanelExport',
+ 'PanelDynamic','PanelRename','PanelOwner','PanelUserDevices','PanelFindCommon','PanelFindDistinct','PanelGetDeviceInfo','PanelGetDiscoveredApps','PanelGetPolicyAssignments','PanelCompareGroups','PanelRemoveGroups','PanelGetGroupOwner','PanelUserDirectReports','PanelMissingBitlocker','PanelPolicyExport','PanelPolicyImport','PanelDupReview','PanelPolicyCleanup','PanelRunRemediation','PanelBulkSync','PanelBulkAutopilot','PanelGetAutopilotStatus')
+ foreach ($p in $panels) {
+ $el = $script:Window.FindName($p)
+ if ($el) { $el.Visibility = if ($p -eq $Name) { 'Visible' } else { 'Collapsed' } }
+ }
+ # Show Clear Inputs button on any panel except Welcome
+ $clearBtn = $script:Window.FindName('BtnClearInputs')
+ if ($clearBtn) {
+ $clearBtn.Visibility = if ($Name -ne 'PanelWelcome') { 'Visible' } else { 'Collapsed' }
+ }
+}
+
+function Show-Notification {
+ param([string]$Message, [string]$BgColor = '#FFF3CD', [string]$FgColor = '#7A4800')
+ $script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
+ $script:NotifStrip.Background = [System.Windows.Media.SolidColorBrush]::new(
+ [System.Windows.Media.ColorConverter]::ConvertFromString($BgColor))
+ $script:TxtNotif.Foreground = [System.Windows.Media.SolidColorBrush]::new(
+ [System.Windows.Media.ColorConverter]::ConvertFromString($FgColor))
+ $script:TxtNotif.Text = $Message
+ $script:NotifStrip.Visibility = 'Visible'
+ })
+}
+
+function Hide-Notification {
+ $script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
+ $script:NotifStrip.Visibility = 'Collapsed'
+ })
+}
+
+function Update-StatusBar {
+ param([string]$Text)
+ $script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
+ $script:TxtStatusBar.Text = $Text
+ })
+}
+
+function Show-ConfirmationDialog {
+ param(
+ [string]$Title,
+ [string]$OperationLabel,
+ [string]$TargetGroup,
+ [string]$TargetGroupId,
+ [object[]]$ValidObjects,
+ [object[]]$InvalidEntries,
+ [string]$ExtraInfo = ""
+ )
+
+ $validCount = $ValidObjects.Count
+ $invalidCount = $InvalidEntries.Count
+
+ # Build summary text
+ $sb = [System.Text.StringBuilder]::new()
+ $null = $sb.AppendLine("OPERATION : $OperationLabel")
+ $null = $sb.AppendLine("GROUP : $TargetGroup")
+ $null = $sb.AppendLine("GROUP ID : $TargetGroupId")
+ if ($ExtraInfo) { $null = $sb.AppendLine($ExtraInfo) }
+ $null = $sb.AppendLine("")
+ $null = $sb.AppendLine("Valid objects : $validCount")
+ if ($invalidCount -gt 0) {
+ $null = $sb.AppendLine("Invalid entries: $invalidCount (will be SKIPPED)")
+ $null = $sb.AppendLine("")
+ $null = $sb.AppendLine("-- INVALID (not found in Entra) --")
+ foreach ($inv in $InvalidEntries) { $null = $sb.AppendLine(" $([char]0x2717) $inv") }
+ }
+ $null = $sb.AppendLine("")
+ $null = $sb.AppendLine("-- VALID (will be processed) --")
+ foreach ($obj in $ValidObjects) {
+ $dispLabel = if ($obj.Type -eq 'User') { $obj.Original } else { $obj.DisplayName }
+ $null = $sb.AppendLine(" $([char]0x2713) $dispLabel [$($obj.Type)] ($($obj.Id))")
+ }
+
+ [xml]$dlgXaml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ $dlgReader = [System.Xml.XmlNodeReader]::new($dlgXaml)
+ $dlg = [Windows.Markup.XamlReader]::Load($dlgReader)
+ $dlg.Owner = $script:Window
+
+ $dlg.FindName('SummaryBox').Text = $sb.ToString()
+
+ $script:DlgResult = $false
+ $dlg.FindName('BtnDlgCancel').Add_Click({ $script:DlgResult = $false; $dlg.Close() })
+ $dlg.FindName('BtnDlgConfirm').Add_Click({ $script:DlgResult = $true; $dlg.Close() })
+
+ $dlg.ShowDialog() | Out-Null
+ return $script:DlgResult
+}
+
+#endregion
+Write-ErrorLog "Checkpoint 3: UI helpers defined OK."
+
+
+# ============================================================
+#region GRAPH HELPER FUNCTIONS
+# ============================================================
+
+function Invoke-GraphGet {
+ param([string]$Uri)
+ $results = [System.Collections.Generic.List[object]]::new()
+ $next = $Uri
+ do {
+ $resp = Invoke-MgGraphRequest -Method GET -Uri $next
+ if ($resp['value']) { $results.AddRange([object[]]$resp['value']) }
+ $next = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
+ } while ($next)
+ return ,$results
+}
+
+# OData filter encoding is inlined at each call site (no separate function)
+
+
+function Resolve-GroupByNameOrId {
+ param([string]$GroupEntry)
+ $trimmed = $GroupEntry.Trim()
+ if ([string]::IsNullOrWhiteSpace($trimmed)) { return $null }
+
+ # GUID pattern - look up by ID
+ if ($trimmed -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
+ try {
+ $g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$trimmed`?`$select=id,displayName,groupTypes,membershipRule,mailEnabled,securityEnabled"
+ return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; GroupTypes = $g['groupTypes']; MembershipRule = $g['membershipRule'] }
+ } catch { return $null }
+ }
+
+ # Strategy 1: $filter with OData eq (handles most names)
+ try {
+ $safe_trimmed = $trimmed -replace "'","''"
+ $safe_trimmed = $safe_trimmed -replace '%','%25'
+ $safe_trimmed = $safe_trimmed -replace '\+','%2B'
+ $safe_trimmed = $safe_trimmed -replace '#','%23'
+ $safe_trimmed = $safe_trimmed -replace '&','%26'
+ $resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_trimmed'`&`$select=id,displayName,groupTypes,membershipRule,mailEnabled,securityEnabled`&`$top=10"
+ if ($resp['value'] -and $resp['value'].Count -gt 0) {
+ $g = $resp['value'][0]
+ return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; GroupTypes = $g['groupTypes']; MembershipRule = $g['membershipRule'] }
+ }
+ } catch {}
+
+ # Strategy 2: $search with ConsistencyLevel:eventual (handles / and other special chars)
+ # $search uses tokenized matching so we do a client-side exact match afterwards
+ try {
+ $searchEncoded = [Uri]::EscapeDataString($trimmed)
+ $searchUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $searchEncoded + '%22&$select=id,displayName,groupTypes,membershipRule,mailEnabled,securityEnabled&$top=50&$count=true'
+ $searchHeaders = @{ 'ConsistencyLevel' = 'eventual' }
+ Write-VerboseLog ('Search fallback for: ' + $trimmed) -Level Info
+ $resp2 = Invoke-MgGraphRequest -Method GET -Uri $searchUri -Headers $searchHeaders
+ Write-VerboseLog ('Search returned ' + $resp2['value'].Count + ' candidates') -Level Info
+ if ($resp2['value']) {
+ foreach ($candidate in $resp2['value']) {
+ if ($candidate['displayName'].Trim() -eq $trimmed.Trim()) {
+ return [PSCustomObject]@{ Id = $candidate['id']; DisplayName = $candidate['displayName']; GroupTypes = $candidate['groupTypes']; MembershipRule = $candidate['membershipRule'] }
+ }
+ }
+ Write-VerboseLog ('No exact match among ' + $resp2['value'].Count + ' candidates') -Level Warning
+ }
+ } catch {
+ Write-VerboseLog ('Search fallback error: ' + $_.Exception.Message) -Level Error
+ }
+ return $null
+}
+
+function Search-Groups {
+ param([string]$Query)
+ if ([string]::IsNullOrWhiteSpace($Query)) { return @() }
+ $enc = ($Query.Trim()) -replace "'","''"
+ $enc = $enc -replace '%','%25'
+ $enc = $enc -replace '\+','%2B'
+ $enc = $enc -replace '#','%23'
+ $enc = $enc -replace '&','%26'
+ try {
+ $resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=startswith(displayName,'$enc')`&`$select=id,displayName,groupTypes`&`$top=15"
+ return $resp['value']
+ } catch { return @() }
+}
+
+function Resolve-MemberEntry {
+ param([string]$Entry)
+
+ # Returns [PSCustomObject]@{ Id; DisplayName; Type; Found }
+ # Type = 'User' | 'Group' | 'Device' | 'Unknown'
+
+ $trimmed = $Entry.Trim()
+ if ([string]::IsNullOrWhiteSpace($trimmed)) { return $null }
+
+ $isGuid = $trimmed -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
+ $isUpn = $trimmed -match '^[^@\s]+@[^@\s]+\.[^@\s]+$'
+
+ # ---- User (UPN only) ----
+ if ($isUpn) {
+ try {
+ $u = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($trimmed))?`$select=id,displayName,userPrincipalName"
+ return [PSCustomObject]@{ Id = $u['id']; DisplayName = $u['displayName']; Type = 'User'; Found = $true; Original = $trimmed }
+ } catch {
+ return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'User'; Found = $false; Original = $trimmed }
+ }
+ }
+
+ # ---- GUID - try group, then device ----
+ if ($isGuid) {
+ try {
+ $g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$trimmed`?`$select=id,displayName"
+ return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; Type = 'Group'; Found = $true; Original = $trimmed }
+ } catch {}
+ try {
+ $d = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices/$trimmed`?`$select=id,displayName"
+ return [PSCustomObject]@{ Id = $d['id']; DisplayName = $d['displayName']; Type = 'Device'; Found = $true; Original = $trimmed }
+ } catch {}
+ return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'Unknown'; Found = $false; Original = $trimmed }
+ }
+
+ # ---- Display name - try group, then device ----
+ $safeEntry = $trimmed -replace "'","''"
+ $safeEntry = $safeEntry -replace '%','%25'
+ $safeEntry = $safeEntry -replace '\+','%2B'
+ $safeEntry = $safeEntry -replace '#','%23'
+ $safeEntry = $safeEntry -replace '&','%26'
+ try {
+ $resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safeEntry'`&`$select=id,displayName`&`$top=1"
+ if ($resp['value'] -and $resp['value'].Count -gt 0) {
+ $g = $resp['value'][0]
+ return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; Type = 'Group'; Found = $true; Original = $trimmed }
+ }
+ } catch {}
+ # $search fallback for groups with special chars like /
+ try {
+ $sEnc = [Uri]::EscapeDataString($trimmed)
+ $sUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $sEnc + '%22&$select=id,displayName&$top=50&$count=true'
+ $sHdr = @{ 'ConsistencyLevel' = 'eventual' }
+ $sResp = Invoke-MgGraphRequest -Method GET -Uri $sUri -Headers $sHdr
+ if ($sResp['value']) {
+ foreach ($sc in $sResp['value']) {
+ if ($sc['displayName'].Trim() -eq $trimmed.Trim()) {
+ return [PSCustomObject]@{ Id = $sc['id']; DisplayName = $sc['displayName']; Type = 'Group'; Found = $true; Original = $trimmed }
+ }
+ }
+ }
+ } catch {}
+ try {
+ $resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safeEntry'`&`$select=id,displayName`&`$top=1"
+ if ($resp['value'] -and $resp['value'].Count -gt 0) {
+ $d = $resp['value'][0]
+ return [PSCustomObject]@{ Id = $d['id']; DisplayName = $d['displayName']; Type = 'Device'; Found = $true; Original = $trimmed }
+ }
+ } catch {}
+
+ return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'Unknown'; Found = $false; Original = $trimmed }
+}
+
+function Validate-InputList {
+ param([string[]]$Entries)
+ # Use plain arrays - Generic.List serialises to Object[] across runspace
+ # boundaries which causes AddRange type errors.
+ $valid = @()
+ $invalid = @()
+
+ foreach ($entry in $Entries) {
+ $e = $entry.Trim()
+ if ([string]::IsNullOrWhiteSpace($e)) { continue }
+ Write-VerboseLog "Resolving: $e" -Level Info
+ $result = Resolve-MemberEntry -Entry $e
+ if ($null -eq $result) { continue }
+ if ($result.Found) {
+ Write-VerboseLog " $([char]0x2713) $($result.DisplayName) [$($result.Type)] ($($result.Id))" -Level Success
+ $valid += $result
+ } else {
+ Write-VerboseLog " $([char]0x2717) Not found: $e" -Level Warning
+ $invalid += $e
+ }
+ }
+ return @{ Valid = $valid; Invalid = $invalid }
+}
+
+function Get-GroupMemberCount {
+ param([string]$GroupId)
+ # Requires ConsistencyLevel:eventual AND $count=true in URL
+ # Silently returns $null on any error (e.g. missing permission) - never blocks UI
+ try {
+ $resp = Invoke-MgGraphRequest -Method GET `
+ -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/`$count?`$count=true" `
+ -Headers @{ ConsistencyLevel = 'eventual' }
+ return [int]$resp
+ } catch { return $null }
+}
+
+function Get-GroupMembers {
+ param([string]$GroupId)
+ # Note: @odata.type must NOT be in $select - Graph returns it automatically.
+ # Including it causes a BadRequest error.
+ return Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,displayName,userPrincipalName`&`$top=999"
+}
+
+function Add-MembersToGroup {
+ param(
+ [string]$GroupId,
+ [object[]]$Members
+ )
+ $ok = 0; $fail = 0; $skipped = 0
+ foreach ($m in $Members) {
+ if ($null -ne $Shared -and $Shared['StopRequested']) {
+ Write-VerboseLog "Stop requested - halting after $ok added." -Level Warning
+ break
+ }
+ $body = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($m.Id)" }
+ Write-VerboseLog "Adding $($m.DisplayName) [$($m.Type)]..." -Level Action
+ try {
+ $null = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/`$ref" -Body ($body | ConvertTo-Json) -ContentType "application/json"
+ Write-VerboseLog "Added: $($m.DisplayName)" -Level Success
+ $ok++
+ } catch {
+ $errMsg = $_.Exception.Message
+ $errDetail = $_.ErrorDetails.Message
+ if ($errMsg -match 'already exist' -or $errDetail -match 'already exist') {
+ Write-VerboseLog "Skipping $($m.DisplayName) - already a member of this group" -Level Warning
+ $skipped++
+ } else {
+ Write-VerboseLog "Failed to add $($m.DisplayName): $errMsg" -Level Error
+ $fail++
+ }
+ }
+ }
+ return @{ Ok = $ok; Fail = $fail; Skipped = $skipped }
+}
+
+function Remove-MembersFromGroup {
+ param(
+ [string]$GroupId,
+ [object[]]$Members
+ )
+ $ok = 0; $fail = 0
+ foreach ($m in $Members) {
+ if ($null -ne $Shared -and $Shared['StopRequested']) {
+ Write-VerboseLog "Stop requested - halting after $ok removed." -Level Warning
+ break
+ }
+ Write-VerboseLog "Removing $($m.DisplayName) [$($m.Type)]..." -Level Action
+ try {
+ $null = Invoke-MgGraphRequest -Method DELETE -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/$($m.Id)/`$ref"
+ Write-VerboseLog "Removed: $($m.DisplayName)" -Level Success
+ $ok++
+ } catch {
+ Write-VerboseLog "Failed to remove $($m.DisplayName): $($_.Exception.Message)" -Level Error
+ $fail++
+ }
+ }
+ return @{ Ok = $ok; Fail = $fail }
+}
+
+function Export-GroupMembersToXlsx {
+ param([string]$GroupId, [string]$GroupName, [string]$OutputPath)
+
+ Write-VerboseLog "Fetching members for group: $GroupName" -Level Action
+ $members = Get-GroupMembers -GroupId $GroupId
+ Write-VerboseLog "Retrieved $($members.Count) members" -Level Info
+
+ # Build rows
+ $rows = foreach ($m in $members) {
+ $odataType = if ($m.ContainsKey('@odata.type')) { $m['@odata.type'] } else { '' }
+ $type = switch ($odataType) {
+ '#microsoft.graph.user' { 'User' }
+ '#microsoft.graph.group' { 'Group' }
+ '#microsoft.graph.device' { 'Device' }
+ default { if ($odataType) { $odataType } else { 'Unknown' } }
+ }
+ [PSCustomObject]@{
+ DisplayName = $m['displayName']
+ UserPrincipalName = if ($m['userPrincipalName']) { $m['userPrincipalName'] } else { '-' }
+ ObjectId = $m['id']
+ ObjectType = $type
+ GroupName = $GroupName
+ GroupId = $GroupId
+ ExportedAt = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
+ }
+ }
+
+ # Ensure folder exists
+ $folder = Split-Path $OutputPath -Parent
+ if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
+
+ # Try ImportExcel if available, else CSV fallback
+ if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
+ Import-Module ImportExcel -ErrorAction SilentlyContinue
+ $rows | Export-Excel -Path $OutputPath -WorksheetName "Members" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium2
+ Write-VerboseLog "Exported $($rows.Count) members to XLSX: $OutputPath" -Level Success
+ } else {
+ # Fallback: save as CSV with .xlsx extension (user informed)
+ $rows | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
+ Write-VerboseLog "ImportExcel module not found - exported as CSV to: $OutputPath" -Level Warning
+ Write-VerboseLog "Install ImportExcel: Install-Module ImportExcel -Scope CurrentUser" -Level Info
+ }
+ return $rows.Count
+}
+
+#endregion
+Write-ErrorLog "Checkpoint 4: Graph helpers defined OK."
+
+
+# ============================================================
+#region BUILD & WIRE UI
+# ============================================================
+
+Write-EarlyLog "Loading XAML..."
+Write-ErrorLog "Loading XAML..."
+try {
+ $reader = [System.Xml.XmlNodeReader]::new($Xaml)
+ $script:Window = [Windows.Markup.XamlReader]::Load($reader)
+ Write-ErrorLog "XAML loaded OK."
+ Write-EarlyLog "XAML loaded OK."
+} catch {
+ Write-ErrorLog "XAML load failed: $($_.Exception.Message)"
+ Write-EarlyLog "XAML load FAILED: $($_.Exception.Message)"
+ try {
+ Add-Type -AssemblyName System.Windows.Forms
+ [System.Windows.Forms.MessageBox]::Show("XAML load failed:`n`n$($_.Exception.Message)","Error") | Out-Null
+ } catch {}
+ exit 1
+}
+
+# Pre-initialise all script-scope state variables (required by Set-StrictMode -Version Latest)
+$script:SelectedGroup = $null
+$script:ExportSelectedGroup = $null
+$script:DynSelectedGroup = $null
+$script:RenSelectedGroup = $null
+$script:OwnerGroupsTxt = ''
+$script:OwnerGroupsValidated = $null
+$script:CurrentMemberOp = 'Add'
+$script:DlgResult = $false
+$script:SensitivityLabels = @() # sensitivity label fetch removed (API permission not available)
+$script:DynGroupState = $null # populated by BtnDynValidate Work block; checked in Done block
+$script:SearchKeyword = '' # keyword for BtnEntraSearch
+$script:SearchTypes = @{} # type filter hashtable
+$script:SearchResults = $null # List[PSCustomObject] from BtnEntraSearch
+$script:SearchExactMatch = $false # exact-match-only filter for BtnEntraSearch
+$script:LMAllResults = $null # List[PSCustomObject] from BtnLMQuery (all, pre-filter)
+$script:LMGroupInput = '' # raw text from TxtLMGroupInput
+$script:LMProgressTimer = $null # DispatcherTimer for progress bar updates
+$script:CGAllResults = $null # List[PSCustomObject] from BtnCGCommon / BtnCGDistinct
+$script:CGGroupInput = '' # raw text from TxtCGGroupInput
+$script:CGProgressTimer = $null # DispatcherTimer for CG progress bar
+$script:CreateSensLabelId = ''
+$script:AdminUnits = @() # Administrative Units fetched from Graph
+$script:CreateSelectedAU = $null # Selected AU for group creation (hashtable with Id, DisplayName)
+$script:CreateSensLabelName = ''
+$script:PickerCallbacks = @{}
+$script:LogPaneVisible = $true
+$script:AuthTimer = $null
+$script:AuthPs = $null
+$script:AuthRs = $null
+$script:AuthHandle = $null
+$script:AuthDlgResult = $false
+$script:AuthDlgTenant = ''
+$script:AuthDlgClient = ''
+$script:BgPs = $null
+$script:BgRs = $null
+$script:BgHandle = $null
+$script:BgTimer = $null
+$script:BgDone = $null
+$script:BgBtn = $null
+$script:SharedBg = $null
+$script:PEXParams = $null
+$script:PEXResult = $null
+$script:PEXOutFolder = $null
+$script:PIMParams = $null
+$script:PIMConfirmed = $null
+$script:PIMPrepResult = $null
+$script:PIMExecResult = $null
+$script:PIMDialogResult = $null
+$script:DRParams = $null
+$script:DRResult = $null
+$script:PCParams = $null
+$script:PCResolved = $null
+$script:PCResolveResult = $null
+$script:PCDeleteResult = $null
+$script:RRParams = $null
+$script:RRResolve = $null
+$script:RRExec = $null
+$script:BSParams = $null
+$script:BSResolve = $null
+$script:BSExec = $null
+$script:BAPParams = $null
+$script:BAPPrep = $null
+$script:BAPExec = $null
+$script:BAPStage = $null
+$script:GASParams = $null
+$script:GASResult = $null
+$script:GASAllData = $null
+$script:BgStopped = $false
+$script:PimRoles = @()
+$script:PimQueue = $null
+$script:PimRs = $null
+$script:PimPs = $null
+$script:PimHandle = $null
+$script:PimTimer = $null
+$script:PimAutoTimer = $null
+$script:BladeExpanded = $false
+$script:CreateParams = $null
+$script:CreateValidated = $null
+$script:CreateNewGroupId = $null
+$script:MemberParams = $null
+$script:MemberValidated = $null
+$script:MemberExecResult = $null
+$script:ExportOutPath = ''
+$script:ExportCount = 0
+$script:DynRule = ''
+$script:RenNewName = ''
+$script:RenExistingId = $null
+$script:OwnerUpnsTxt = ''
+$script:OwnerValidated = $null
+$script:OwnerExecResult = $null
+$script:UDSelectedGroup = $null
+$script:UDParams = $null
+$script:UDValidated = $null
+$script:UDExecResult = $null
+$script:FCParams = $null
+$script:FCResult = $null
+$script:FDParams = $null
+$script:FDResult = $null
+$script:GPAParams = $null # GPA: query params passed to background work block
+$script:GPAResult = $null # GPA: result rows from background query
+$script:RGGroupList = $null # RG: raw group input text
+$script:RGValidated = $null # RG: validated group objects for preview
+$script:RGExecResult = $null # RG: per-group deletion results
+$script:AuthCancelled = $false
+$script:AuthStartTime = [datetime]::UtcNow
+$script:AuthTimeoutSec = 120
+$script:BladeDevExpanded = $false
+$script:GDIParams = $null
+$script:FGONoOwnerMode = $false
+$script:GdiQueue = $null
+$script:GdiStop = $null
+$script:OmStop = $null
+$script:DaStop = $null
+$script:GdiRs = $null
+$script:OmQueue = $null
+$script:OmRs = $null
+$script:OmPs = $null
+$script:OmHandle = $null
+$script:OmTimer = $null
+$script:OMParams = $null
+$script:DaQueue = $null
+$script:DaRs = $null
+$script:DaPs = $null
+$script:DaHandle = $null
+$script:DaTimer = $null
+$script:SearchKeyword = ''
+$script:SearchEntries = @()
+$script:SearchExactMatch = $false
+$script:SearchGetManager = $false
+$script:SearchTypes = $null
+$script:SearchResults = $null
+$script:SearchAllData = $null
+$script:LMAllData = $null
+$script:OMAllData = $null
+$script:FCAllData = $null
+$script:FDAllData = $null
+# ── MBK (Missing BitLocker Keys) state - Patches 1.5 / 2 / 3 ─────────────────
+$script:MBKAllData = $null # List[PSCustomObject] of report rows
+$script:mbkRS = $null # background runspace
+$script:mbkAsync = $null # IAsyncResult handle
+$script:mbkQueue = $null # ConcurrentQueue between work block and timer
+$script:mbkState = $null # Synchronized hashtable for Phase/Total/Processed
+$script:mbkActiveBtn = $null # which Run button launched the current job
+$script:mbkLastMode = $null # 'MissingOnly' | 'OlderThanDays' (for completion text)
+$script:mbkLastDays = $null # threshold remembered for completion text
+$script:mbkPendingMode = $null # set by BtnMBKRunOlder to dispatch into BtnMBKRun
+$script:mbkPendingDays = $null
+$script:GDIAllData = $null
+$script:DAAllData = $null
+$script:GPAAllData = $null
+$script:CGAllData = $null
+$script:DAParams = $null
+$script:GdiPs = $null
+$script:GdiHandle = $null
+$script:GdiTimer = $null
+$script:FGOStop = $null
+$script:FGOQueue = $null
+$script:FGORs = $null
+$script:FGOPs = $null
+$script:FGOHandle = $null
+$script:FGOTimer = $null
+$script:FGOAllData = $null
+$script:GGOInputTxt = ''
+$script:GGOResults = $null
+$script:UDRInputTxt = ''
+$script:UDRResults = $null
+$script:GGOAllData = $null
+$script:UDRAllData = $null
+
+# Cache named elements
+$script:TxtStatusBar = $script:Window.FindName('TxtStatusBar')
+$script:NotifStrip = $script:Window.FindName('NotifStrip')
+$script:TxtNotif = $script:Window.FindName('TxtNotif')
+$script:PimStrip = $script:Window.FindName('PimStrip')
+$script:SpPimRoles = $script:Window.FindName('SpPimRoles')
+$script:BgImage = $script:Window.FindName('BgImage')
+$script:LogoImg = $script:Window.FindName('LogoImg')
+$script:RtbLog = $script:Window.FindName('RtbLog')
+$script:LogPane = $script:Window.FindName('LogPane')
+
+
+# ── Settings panel toggle ─────────────────────────────────────────────────────
+
+
+# ── Log pane min/max height enforcement via GridSplitter drag ─────────────────
+$script:LogPaneMinH = 44
+$script:LogPaneMaxH = 260
+$script:LogPane.Add_SizeChanged({
+ $h = $script:LogPane.ActualHeight
+ if ($h -lt $script:LogPaneMinH -and $script:LogPaneVisible) {
+ $script:LogPane.Height = $script:LogPaneMinH
+ } elseif ($h -gt $script:LogPaneMaxH) {
+ $script:LogPane.Height = $script:LogPaneMaxH
+ }
+})
+
+
+# ── Helper: load a bitmap from a local path or web URL ──
+# BitmapFrame.Create(Uri) handles both file:// and http(s):// without extra modules.
+# Local path takes priority over URL.
+# ÄÄ SVG -> RasterBitmap (no external libraries required) ÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄ
+# Renders SVG via a hidden WPF WebBrowser (IE/Trident engine), captures with
+# RenderTargetBitmap, then closes the window. Handles simple/flat logo SVGs.
+function Convert-SvgToBitmap {
+ param([string]$SvgContent, [int]$Width = 120, [int]$Height = 40)
+ try {
+ # Add Win32 interop helpers (once per session)
+ if (-not ([System.Management.Automation.PSTypeName]'SvgCapture').Type) {
+ Add-Type -TypeDefinition @"
+using System;
+using System.Runtime.InteropServices;
+public class SvgCapture {
+ [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr hdc, int flags);
+ [DllImport("user32.dll")] public static extern IntPtr GetDC(IntPtr hwnd);
+ [DllImport("user32.dll")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
+ [DllImport("gdi32.dll")] public static extern IntPtr CreateCompatibleDC(IntPtr hdc);
+ [DllImport("gdi32.dll")] public static extern IntPtr CreateCompatibleBitmap(IntPtr hdc, int w, int h);
+ [DllImport("gdi32.dll")] public static extern IntPtr SelectObject(IntPtr hdc, IntPtr obj);
+ [DllImport("gdi32.dll")] public static extern bool DeleteDC(IntPtr hdc);
+ [DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr obj);
+}
+"@
+ }
+
+ $svgB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($SvgContent))
+ $html = "
"
+
+ $wb = [System.Windows.Controls.WebBrowser]::new()
+ $wb.Width = $Width
+ $wb.Height = $Height
+ $null = $wb.Add_LoadCompleted({ $script:_SvgLoadDone = $true })
+
+ $hostWin = [System.Windows.Window]::new()
+ $hostWin.WindowStyle = 'None'
+ $hostWin.ShowInTaskbar = $false
+ $hostWin.AllowsTransparency = $false
+ $hostWin.Left = -32000; $hostWin.Top = -32000
+ $hostWin.Width = $Width; $hostWin.Height = $Height
+ $hostWin.Content = $wb
+ $script:_SvgLoadDone = $false
+ $null = $hostWin.Show()
+ $null = $wb.NavigateToString($html)
+
+ # Wait up to 4 s for LoadCompleted, pumping dispatcher each cycle
+ $deadline = (Get-Date).AddSeconds(4)
+ while (-not $script:_SvgLoadDone -and (Get-Date) -lt $deadline) {
+ $null = [System.Windows.Application]::Current.Dispatcher.Invoke(
+ [Action]{}, [System.Windows.Threading.DispatcherPriority]::ApplicationIdle)
+ Start-Sleep -Milliseconds 80
+ }
+ # Extra render pump
+ $null = [System.Windows.Application]::Current.Dispatcher.Invoke(
+ [Action]{}, [System.Windows.Threading.DispatcherPriority]::Render)
+ Start-Sleep -Milliseconds 100
+
+ # Get the HWND of the host window (not the WebBrowser — window HWND captures its children too)
+ $helper = [System.Windows.Interop.WindowInteropHelper]::new($hostWin)
+ $hwnd = $helper.Handle
+
+ # Capture via PrintWindow (works on HWND-hosted controls; RenderTargetBitmap does NOT)
+ $screenDC = [SvgCapture]::GetDC([IntPtr]::Zero)
+ $memDC = [SvgCapture]::CreateCompatibleDC($screenDC)
+ $hBmp = [SvgCapture]::CreateCompatibleBitmap($screenDC, $Width, $Height)
+ $oldObj = [SvgCapture]::SelectObject($memDC, $hBmp)
+ # PW_RENDERFULLCONTENT = 2 (forces re-render, needed for HwndHost controls)
+ $null = [SvgCapture]::PrintWindow($hwnd, $memDC, 2)
+
+ # Convert GDI HBITMAP -> WPF BitmapSource
+ $bmpSrc = [System.Windows.Interop.Imaging]::CreateBitmapSourceFromHBitmap(
+ $hBmp, [IntPtr]::Zero,
+ [System.Windows.Int32Rect]::Empty,
+ [System.Windows.Media.Imaging.BitmapSizeOptions]::FromEmptyOptions())
+ $null = $bmpSrc.Freeze()
+
+ # GDI cleanup
+ $null = [SvgCapture]::SelectObject($memDC, $oldObj)
+ $null = [SvgCapture]::DeleteDC($memDC)
+ $null = [SvgCapture]::DeleteObject($hBmp)
+ $null = [SvgCapture]::ReleaseDC([IntPtr]::Zero, $screenDC)
+ $null = $hostWin.Close()
+
+ $null = Write-VerboseLog "SVG logo captured at ${Width}x${Height}." -Level Success
+ return $bmpSrc
+ } catch {
+ $null = Write-VerboseLog "SVG render failed: $($_.Exception.Message)" -Level Warning
+ return $null
+ }
+}
+
+function Load-BitmapSource {
+ param([string]$LocalPath, [string]$Url, [string]$Base64,
+ [int]$SvgW = 120, [int]$SvgH = 40)
+
+ # Robust SVG detector: handles BOM, preamble, leading whitespace
+ function Test-IsSvg { param([string]$s)
+ if (-not $s) { return $false }
+ $sample = $s.Substring(0, [Math]::Min(600, $s.Length))
+ return $sample -match '(?is)