# -- 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.8 # 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.8" 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