diff --git a/MDM-ODA V0.7.ps1 b/MDM-ODA V0.7.ps1 new file mode 100644 index 0000000..d466166 --- /dev/null +++ b/MDM-ODA V0.7.ps1 @@ -0,0 +1,15372 @@ +# -- 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. + # Filter stubs out by requiring file size > 50 KB (real pwsh.exe is ~200 KB). + function Test-RealPwsh { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return $false } + if (-not (Test-Path $Path -ErrorAction SilentlyContinue)) { return $false } + $item = Get-Item $Path -ErrorAction SilentlyContinue + if (-not $item) { return $false } + if ($item.Length -lt 51200) { + Write-Host " Skipping stub at: $Path (size $($item.Length) bytes)" -ForegroundColor DarkGray + return $false + } + return $true + } + + # 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\PowerShell\7\pwsh.exe')) # winget user-scope + $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) } + } + } + $probePaths.Add((Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\pwsh.exe')) # Store (checked LAST; stubs are filtered by size) + + $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.7 + # 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.7" + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +