# -- param must be first statement for PS5 compatibility --
param([switch]$SkipUpgrade)
# ============================================================
# Entra Group Manager - 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 ("EGM_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:EGM_LogFolder = $OrcConfig.LogPath
$env:EGM_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:EGM_LogFolder) { $env:EGM_LogFolder } else { "" }
$TranscriptPath = if ($env:EGM_Transcript) { $env:EGM_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.66
# 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.66"
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 ("EGM_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' }
[PSCustomObject]@{ Name = 'Microsoft.Graph.Groups'; Label = 'Graph . Groups' }
[PSCustomObject]@{ Name = 'Microsoft.Graph.Users'; Label = 'Graph . Users' }
[PSCustomObject]@{ Name = 'Microsoft.Graph.Identity.DirectoryManagement'; Label = 'Graph . Directory Management' }
)
$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 ("EGM_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",
"Entra Group Manager - Error",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null
} catch {}
break
}
# Run the prereq check - loop supports in-session reinstall when already admin
$script:RerunPrereq = $false
do {
$script:RerunPrereq = $false
if (-not (Start-PrereqCheck)) { exit 0 }
} while ($script:RerunPrereq)
#endregion PREREQ CHECK
# ============================================================
#region XAML - UI Definition
# ============================================================
# ── Compute window size from available screen working area ──
# Uses SystemParameters (WPF logical pixels already DPI-adjusted) so the
# window fits on any screen size / scaling factor without clipping.
$_screen = [System.Windows.SystemParameters]
$_workW = [int]$_screen::WorkArea.Width
$_workH = [int]$_screen::WorkArea.Height
# Target 92% of working area, clamped to config defaults as ceiling
$_winW = [int][Math]::Min([Math]::Floor($_workW * 0.92), $DevConfig.WindowWidth)
$_winH = [int][Math]::Min([Math]::Floor($_workH * 0.92), $DevConfig.WindowHeight)
# Never go below the minimum sizes
$_winW = [Math]::Max($_winW, $DevConfig.MinWidth)
$_winH = [Math]::Max($_winH, $DevConfig.MinHeight)
[xml]$Xaml = @"
0,0,0,1
0,0,0,1
0,0,0,1
0,0,0,1
0,1,0,0
"@
#endregion XAML
Write-ErrorLog "Checkpoint 2: XAML defined OK."
Write-EarlyLog "Checkpoint 2: XAML defined OK."
# ============================================================
#region UI HELPER FUNCTIONS
# ============================================================
function Get-Timestamp { (Get-Date).ToString("HH:mm:ss") }
function Write-VerboseLog {
param(
[string]$Message,
[ValidateSet('Info','Success','Warning','Error','Action')]
[string]$Level = 'Info'
)
$color = switch ($Level) {
'Info' { $DevConfig.LogColorInfo }
'Success' { $DevConfig.LogColorSuccess }
'Warning' { $DevConfig.LogColorWarning }
'Error' { $DevConfig.LogColorError }
'Action' { $DevConfig.LogColorAction }
}
$prefix = switch ($Level) {
'Info' { ' ' }
'Success' { "$([char]0x2713) " }
'Warning' { '[!] ' }
'Error' { "$([char]0x2717) " }
'Action' { '> ' }
}
$ts = Get-Timestamp
$line = "[$ts] $prefix$Message"
# Write to file
if ($script:LogFile) {
try { Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue }
catch {}
}
# Write to RichTextBox on UI thread
$script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
try {
$para = [System.Windows.Documents.Paragraph]::new()
$run = [System.Windows.Documents.Run]::new($line)
$run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($color)
)
$para.Inlines.Add($run)
$para.Margin = [System.Windows.Thickness]::new(0,0,0,0)
$script:RtbLog.Document.Blocks.Add($para)
$script:RtbLog.ScrollToEnd()
} catch {}
})
}
function Show-Panel {
param([string]$Name)
$panels = @('PanelSearch','PanelListMembers','PanelObjectMembership','PanelFindGroupsByOwners','PanelWelcome','PanelCreate','PanelMembership','PanelExport',
'PanelDynamic','PanelRename','PanelOwner','PanelUserDevices','PanelFindCommon','PanelFindDistinct','PanelGetDeviceInfo','PanelGetDiscoveredApps','PanelGetPolicyAssignments','PanelCompareGroups','PanelRemoveGroups')
foreach ($p in $panels) {
$el = $script:Window.FindName($p)
if ($el) { $el.Visibility = if ($p -eq $Name) { 'Visible' } else { 'Collapsed' } }
}
# Show Clear Inputs button on any panel except Welcome
$clearBtn = $script:Window.FindName('BtnClearInputs')
if ($clearBtn) {
$clearBtn.Visibility = if ($Name -ne 'PanelWelcome') { 'Visible' } else { 'Collapsed' }
}
}
function Show-Notification {
param([string]$Message, [string]$BgColor = '#FFF3CD', [string]$FgColor = '#7A4800')
$script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
$script:NotifStrip.Background = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($BgColor))
$script:TxtNotif.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($FgColor))
$script:TxtNotif.Text = $Message
$script:NotifStrip.Visibility = 'Visible'
})
}
function Hide-Notification {
$script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
$script:NotifStrip.Visibility = 'Collapsed'
})
}
function Update-StatusBar {
param([string]$Text)
$script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
$script:TxtStatusBar.Text = $Text
})
}
function Show-ConfirmationDialog {
param(
[string]$Title,
[string]$OperationLabel,
[string]$TargetGroup,
[string]$TargetGroupId,
[object[]]$ValidObjects,
[object[]]$InvalidEntries,
[string]$ExtraInfo = ""
)
$validCount = $ValidObjects.Count
$invalidCount = $InvalidEntries.Count
# Build summary text
$sb = [System.Text.StringBuilder]::new()
$null = $sb.AppendLine("OPERATION : $OperationLabel")
$null = $sb.AppendLine("GROUP : $TargetGroup")
$null = $sb.AppendLine("GROUP ID : $TargetGroupId")
if ($ExtraInfo) { $null = $sb.AppendLine($ExtraInfo) }
$null = $sb.AppendLine("")
$null = $sb.AppendLine("Valid objects : $validCount")
if ($invalidCount -gt 0) {
$null = $sb.AppendLine("Invalid entries: $invalidCount (will be SKIPPED)")
$null = $sb.AppendLine("")
$null = $sb.AppendLine("-- INVALID (not found in Entra) --")
foreach ($inv in $InvalidEntries) { $null = $sb.AppendLine(" $([char]0x2717) $inv") }
}
$null = $sb.AppendLine("")
$null = $sb.AppendLine("-- VALID (will be processed) --")
foreach ($obj in $ValidObjects) {
$dispLabel = if ($obj.Type -eq 'User') { $obj.Original } else { $obj.DisplayName }
$null = $sb.AppendLine(" $([char]0x2713) $dispLabel [$($obj.Type)] ($($obj.Id))")
}
[xml]$dlgXaml = @"
"@
$dlgReader = [System.Xml.XmlNodeReader]::new($dlgXaml)
$dlg = [Windows.Markup.XamlReader]::Load($dlgReader)
$dlg.Owner = $script:Window
$dlg.FindName('SummaryBox').Text = $sb.ToString()
$script:DlgResult = $false
$dlg.FindName('BtnDlgCancel').Add_Click({ $script:DlgResult = $false; $dlg.Close() })
$dlg.FindName('BtnDlgConfirm').Add_Click({ $script:DlgResult = $true; $dlg.Close() })
$dlg.ShowDialog() | Out-Null
return $script:DlgResult
}
#endregion
Write-ErrorLog "Checkpoint 3: UI helpers defined OK."
# ============================================================
#region GRAPH HELPER FUNCTIONS
# ============================================================
function Invoke-GraphGet {
param([string]$Uri)
$results = [System.Collections.Generic.List[object]]::new()
$next = $Uri
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $next
if ($resp['value']) { $results.AddRange([object[]]$resp['value']) }
$next = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
} while ($next)
return ,$results
}
function Resolve-GroupByNameOrId {
param([string]$GroupEntry)
$trimmed = $GroupEntry.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) { return $null }
# GUID pattern → look up by ID
if ($trimmed -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
try {
$g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$trimmed`?`$select=id,displayName,groupTypes,membershipRule,mailEnabled,securityEnabled"
return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; GroupTypes = $g['groupTypes']; MembershipRule = $g['membershipRule'] }
} catch { return $null }
}
# Name search
$enc = [Uri]::EscapeDataString($trimmed)
try {
$safe_trimmed = $trimmed -replace "'","''" # PS5-safe: extracted to avoid nested double-quotes in string
$resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_trimmed'`&`$select=id,displayName,groupTypes,membershipRule,mailEnabled,securityEnabled`&`$top=10"
if ($resp['value'] -and $resp['value'].Count -gt 0) {
$g = $resp['value'][0]
return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; GroupTypes = $g['groupTypes']; MembershipRule = $g['membershipRule'] }
}
} catch {}
return $null
}
function Search-Groups {
param([string]$Query)
if ([string]::IsNullOrWhiteSpace($Query)) { return @() }
$enc = $Query.Trim() -replace "'","''"
try {
$resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=startswith(displayName,'$enc')`&`$select=id,displayName,groupTypes`&`$top=15"
return $resp['value']
} catch { return @() }
}
function Resolve-MemberEntry {
param([string]$Entry)
# Returns [PSCustomObject]@{ Id; DisplayName; Type; Found }
# Type = 'User' | 'Group' | 'Device' | 'Unknown'
$trimmed = $Entry.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) { return $null }
$isGuid = $trimmed -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
$isUpn = $trimmed -match '^[^@\s]+@[^@\s]+\.[^@\s]+$'
# ---- User (UPN only) ----
if ($isUpn) {
try {
$u = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($trimmed))?`$select=id,displayName,userPrincipalName"
return [PSCustomObject]@{ Id = $u['id']; DisplayName = $u['displayName']; Type = 'User'; Found = $true; Original = $trimmed }
} catch {
return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'User'; Found = $false; Original = $trimmed }
}
}
# ---- GUID - try group, then device ----
if ($isGuid) {
try {
$g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$trimmed`?`$select=id,displayName"
return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; Type = 'Group'; Found = $true; Original = $trimmed }
} catch {}
try {
$d = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices/$trimmed`?`$select=id,displayName"
return [PSCustomObject]@{ Id = $d['id']; DisplayName = $d['displayName']; Type = 'Device'; Found = $true; Original = $trimmed }
} catch {}
return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'Unknown'; Found = $false; Original = $trimmed }
}
# ---- Display name - try group, then device ----
$safeEntry = $trimmed -replace "'","''"
try {
$resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safeEntry'`&`$select=id,displayName`&`$top=1"
if ($resp['value'] -and $resp['value'].Count -gt 0) {
$g = $resp['value'][0]
return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; Type = 'Group'; Found = $true; Original = $trimmed }
}
} catch {}
try {
$resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safeEntry'`&`$select=id,displayName`&`$top=1"
if ($resp['value'] -and $resp['value'].Count -gt 0) {
$d = $resp['value'][0]
return [PSCustomObject]@{ Id = $d['id']; DisplayName = $d['displayName']; Type = 'Device'; Found = $true; Original = $trimmed }
}
} catch {}
return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'Unknown'; Found = $false; Original = $trimmed }
}
function Validate-InputList {
param([string[]]$Entries)
# Use plain arrays - Generic.List serialises to Object[] across runspace
# boundaries which causes AddRange type errors.
$valid = @()
$invalid = @()
foreach ($entry in $Entries) {
$e = $entry.Trim()
if ([string]::IsNullOrWhiteSpace($e)) { continue }
Write-VerboseLog "Resolving: $e" -Level Info
$result = Resolve-MemberEntry -Entry $e
if ($null -eq $result) { continue }
if ($result.Found) {
Write-VerboseLog " $([char]0x2713) $($result.DisplayName) [$($result.Type)] ($($result.Id))" -Level Success
$valid += $result
} else {
Write-VerboseLog " $([char]0x2717) Not found: $e" -Level Warning
$invalid += $e
}
}
return @{ Valid = $valid; Invalid = $invalid }
}
function Get-GroupMemberCount {
param([string]$GroupId)
# Requires ConsistencyLevel:eventual AND $count=true in URL
# Silently returns $null on any error (e.g. missing permission) - never blocks UI
try {
$resp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/`$count?`$count=true" `
-Headers @{ ConsistencyLevel = 'eventual' }
return [int]$resp
} catch { return $null }
}
function Get-GroupMembers {
param([string]$GroupId)
# Note: @odata.type must NOT be in $select - Graph returns it automatically.
# Including it causes a BadRequest error.
return Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,displayName,userPrincipalName`&`$top=999"
}
function Add-MembersToGroup {
param(
[string]$GroupId,
[object[]]$Members
)
$ok = 0; $fail = 0; $skipped = 0
foreach ($m in $Members) {
if ($null -ne $Shared -and $Shared['StopRequested']) {
Write-VerboseLog "Stop requested - halting after $ok added." -Level Warning
break
}
$body = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($m.Id)" }
Write-VerboseLog "Adding $($m.DisplayName) [$($m.Type)]..." -Level Action
try {
$null = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/`$ref" -Body ($body | ConvertTo-Json) -ContentType "application/json"
Write-VerboseLog "Added: $($m.DisplayName)" -Level Success
$ok++
} catch {
$errMsg = $_.Exception.Message
$errDetail = $_.ErrorDetails.Message
if ($errMsg -match 'already exist' -or $errDetail -match 'already exist') {
Write-VerboseLog "Skipping $($m.DisplayName) - already a member of this group" -Level Warning
$skipped++
} else {
Write-VerboseLog "Failed to add $($m.DisplayName): $errMsg" -Level Error
$fail++
}
}
}
return @{ Ok = $ok; Fail = $fail; Skipped = $skipped }
}
function Remove-MembersFromGroup {
param(
[string]$GroupId,
[object[]]$Members
)
$ok = 0; $fail = 0
foreach ($m in $Members) {
if ($null -ne $Shared -and $Shared['StopRequested']) {
Write-VerboseLog "Stop requested - halting after $ok removed." -Level Warning
break
}
Write-VerboseLog "Removing $($m.DisplayName) [$($m.Type)]..." -Level Action
try {
$null = Invoke-MgGraphRequest -Method DELETE -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/$($m.Id)/`$ref"
Write-VerboseLog "Removed: $($m.DisplayName)" -Level Success
$ok++
} catch {
Write-VerboseLog "Failed to remove $($m.DisplayName): $($_.Exception.Message)" -Level Error
$fail++
}
}
return @{ Ok = $ok; Fail = $fail }
}
function Export-GroupMembersToXlsx {
param([string]$GroupId, [string]$GroupName, [string]$OutputPath)
Write-VerboseLog "Fetching members for group: $GroupName" -Level Action
$members = Get-GroupMembers -GroupId $GroupId
Write-VerboseLog "Retrieved $($members.Count) members" -Level Info
# Build rows
$rows = foreach ($m in $members) {
$odataType = if ($m.ContainsKey('@odata.type')) { $m['@odata.type'] } else { '' }
$type = switch ($odataType) {
'#microsoft.graph.user' { 'User' }
'#microsoft.graph.group' { 'Group' }
'#microsoft.graph.device' { 'Device' }
default { if ($odataType) { $odataType } else { 'Unknown' } }
}
[PSCustomObject]@{
DisplayName = $m['displayName']
UserPrincipalName = if ($m['userPrincipalName']) { $m['userPrincipalName'] } else { '-' }
ObjectId = $m['id']
ObjectType = $type
GroupName = $GroupName
GroupId = $GroupId
ExportedAt = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
}
}
# Ensure folder exists
$folder = Split-Path $OutputPath -Parent
if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
# Try ImportExcel if available, else CSV fallback
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
Import-Module ImportExcel -ErrorAction SilentlyContinue
$rows | Export-Excel -Path $OutputPath -WorksheetName "Members" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium2
Write-VerboseLog "Exported $($rows.Count) members to XLSX: $OutputPath" -Level Success
} else {
# Fallback: save as CSV with .xlsx extension (user informed)
$rows | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
Write-VerboseLog "ImportExcel module not found - exported as CSV to: $OutputPath" -Level Warning
Write-VerboseLog "Install ImportExcel: Install-Module ImportExcel -Scope CurrentUser" -Level Info
}
return $rows.Count
}
#endregion
Write-ErrorLog "Checkpoint 4: Graph helpers defined OK."
# ============================================================
#region BUILD & WIRE UI
# ============================================================
Write-EarlyLog "Loading XAML..."
Write-ErrorLog "Loading XAML..."
try {
$reader = [System.Xml.XmlNodeReader]::new($Xaml)
$script:Window = [Windows.Markup.XamlReader]::Load($reader)
Write-ErrorLog "XAML loaded OK."
Write-EarlyLog "XAML loaded OK."
} catch {
Write-ErrorLog "XAML load failed: $($_.Exception.Message)"
Write-EarlyLog "XAML load FAILED: $($_.Exception.Message)"
try {
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.MessageBox]::Show("XAML load failed:`n`n$($_.Exception.Message)","Error") | Out-Null
} catch {}
exit 1
}
# Pre-initialise all script-scope state variables (required by Set-StrictMode -Version Latest)
$script:SelectedGroup = $null
$script:ExportSelectedGroup = $null
$script:DynSelectedGroup = $null
$script:RenSelectedGroup = $null
$script:OwnerGroupsTxt = ''
$script:OwnerGroupsValidated = $null
$script:CurrentMemberOp = 'Add'
$script:DlgResult = $false
$script:SensitivityLabels = @() # sensitivity label fetch removed (API permission not available)
$script:DynGroupState = $null # populated by BtnDynValidate Work block; checked in Done block
$script:SearchKeyword = '' # keyword for BtnEntraSearch
$script:SearchTypes = @{} # type filter hashtable
$script:SearchResults = $null # List[PSCustomObject] from BtnEntraSearch
$script:SearchExactMatch = $false # exact-match-only filter for BtnEntraSearch
$script:LMAllResults = $null # List[PSCustomObject] from BtnLMQuery (all, pre-filter)
$script:LMGroupInput = '' # raw text from TxtLMGroupInput
$script:LMProgressTimer = $null # DispatcherTimer for progress bar updates
$script:CGAllResults = $null # List[PSCustomObject] from BtnCGCommon / BtnCGDistinct
$script:CGGroupInput = '' # raw text from TxtCGGroupInput
$script:CGProgressTimer = $null # DispatcherTimer for CG progress bar
$script:CreateSensLabelId = ''
$script:CreateSensLabelName = ''
$script:PickerCallbacks = @{}
$script:LogPaneVisible = $true
$script:AuthTimer = $null
$script:AuthPs = $null
$script:AuthRs = $null
$script:AuthHandle = $null
$script:AuthDlgResult = $false
$script:AuthDlgTenant = ''
$script:AuthDlgClient = ''
$script:BgPs = $null
$script:BgRs = $null
$script:BgHandle = $null
$script:BgTimer = $null
$script:BgDone = $null
$script:BgBtn = $null
$script:SharedBg = $null
$script:BgStopped = $false
$script:PimRoles = @()
$script:PimQueue = $null
$script:PimRs = $null
$script:PimPs = $null
$script:PimHandle = $null
$script:PimTimer = $null
$script:PimAutoTimer = $null
$script:BladeExpanded = $false
$script:CreateParams = $null
$script:CreateValidated = $null
$script:CreateNewGroupId = $null
$script:MemberParams = $null
$script:MemberValidated = $null
$script:MemberExecResult = $null
$script:ExportOutPath = ''
$script:ExportCount = 0
$script:DynRule = ''
$script:RenNewName = ''
$script:RenExistingId = $null
$script:OwnerUpnsTxt = ''
$script:OwnerValidated = $null
$script:OwnerExecResult = $null
$script:UDSelectedGroup = $null
$script:UDParams = $null
$script:UDValidated = $null
$script:UDExecResult = $null
$script:FCParams = $null
$script:FCResult = $null
$script:FDParams = $null
$script:FDResult = $null
$script:GPAParams = $null # GPA: query params passed to background work block
$script:GPAResult = $null # GPA: result rows from background query
$script:RGGroupList = $null # RG: raw group input text
$script:RGValidated = $null # RG: validated group objects for preview
$script:RGExecResult = $null # RG: per-group deletion results
$script:AuthCancelled = $false
$script:AuthStartTime = [datetime]::UtcNow
$script:AuthTimeoutSec = 120
$script:BladeDevExpanded = $false
$script:GDIParams = $null
$script:GdiQueue = $null
$script:GdiStop = $null
$script:OmStop = $null
$script:DaStop = $null
$script:GdiRs = $null
$script:OmQueue = $null
$script:OmRs = $null
$script:OmPs = $null
$script:OmHandle = $null
$script:OmTimer = $null
$script:OMParams = $null
$script:DaQueue = $null
$script:DaRs = $null
$script:DaPs = $null
$script:DaHandle = $null
$script:DaTimer = $null
$script:SearchAllData = $null
$script:LMAllData = $null
$script:OMAllData = $null
$script:FCAllData = $null
$script:FDAllData = $null
$script:GDIAllData = $null
$script:DAAllData = $null
$script:GPAAllData = $null
$script:CGAllData = $null
$script:DAParams = $null
$script:GdiPs = $null
$script:GdiHandle = $null
$script:GdiTimer = $null
# Cache named elements
$script:TxtStatusBar = $script:Window.FindName('TxtStatusBar')
$script:NotifStrip = $script:Window.FindName('NotifStrip')
$script:TxtNotif = $script:Window.FindName('TxtNotif')
$script:PimStrip = $script:Window.FindName('PimStrip')
$script:SpPimRoles = $script:Window.FindName('SpPimRoles')
$script:BgImage = $script:Window.FindName('BgImage')
$script:LogoImg = $script:Window.FindName('LogoImg')
$script:RtbLog = $script:Window.FindName('RtbLog')
$script:LogPane = $script:Window.FindName('LogPane')
# ── Settings panel toggle ─────────────────────────────────────────────────────
# ── Log pane min/max height enforcement via GridSplitter drag ─────────────────
$script:LogPaneMinH = 44
$script:LogPaneMaxH = 260
$script:LogPane.Add_SizeChanged({
$h = $script:LogPane.ActualHeight
if ($h -lt $script:LogPaneMinH -and $script:LogPaneVisible) {
$script:LogPane.Height = $script:LogPaneMinH
} elseif ($h -gt $script:LogPaneMaxH) {
$script:LogPane.Height = $script:LogPaneMaxH
}
})
# ── Helper: load a bitmap from a local path or web URL ──
# BitmapFrame.Create(Uri) handles both file:// and http(s):// without extra modules.
# Local path takes priority over URL.
# ÄÄ SVG -> RasterBitmap (no external libraries required) ÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄ
# Renders SVG via a hidden WPF WebBrowser (IE/Trident engine), captures with
# RenderTargetBitmap, then closes the window. Handles simple/flat logo SVGs.
function Convert-SvgToBitmap {
param([string]$SvgContent, [int]$Width = 120, [int]$Height = 40)
try {
# Add Win32 interop helpers (once per session)
if (-not ([System.Management.Automation.PSTypeName]'SvgCapture').Type) {
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class SvgCapture {
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr hdc, int flags);
[DllImport("user32.dll")] public static extern IntPtr GetDC(IntPtr hwnd);
[DllImport("user32.dll")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
[DllImport("gdi32.dll")] public static extern IntPtr CreateCompatibleDC(IntPtr hdc);
[DllImport("gdi32.dll")] public static extern IntPtr CreateCompatibleBitmap(IntPtr hdc, int w, int h);
[DllImport("gdi32.dll")] public static extern IntPtr SelectObject(IntPtr hdc, IntPtr obj);
[DllImport("gdi32.dll")] public static extern bool DeleteDC(IntPtr hdc);
[DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr obj);
}
"@
}
$svgB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($SvgContent))
$html = "
"
$wb = [System.Windows.Controls.WebBrowser]::new()
$wb.Width = $Width
$wb.Height = $Height
$null = $wb.Add_LoadCompleted({ $script:_SvgLoadDone = $true })
$hostWin = [System.Windows.Window]::new()
$hostWin.WindowStyle = 'None'
$hostWin.ShowInTaskbar = $false
$hostWin.AllowsTransparency = $false
$hostWin.Left = -32000; $hostWin.Top = -32000
$hostWin.Width = $Width; $hostWin.Height = $Height
$hostWin.Content = $wb
$script:_SvgLoadDone = $false
$null = $hostWin.Show()
$null = $wb.NavigateToString($html)
# Wait up to 4 s for LoadCompleted, pumping dispatcher each cycle
$deadline = (Get-Date).AddSeconds(4)
while (-not $script:_SvgLoadDone -and (Get-Date) -lt $deadline) {
$null = [System.Windows.Application]::Current.Dispatcher.Invoke(
[Action]{}, [System.Windows.Threading.DispatcherPriority]::ApplicationIdle)
Start-Sleep -Milliseconds 80
}
# Extra render pump
$null = [System.Windows.Application]::Current.Dispatcher.Invoke(
[Action]{}, [System.Windows.Threading.DispatcherPriority]::Render)
Start-Sleep -Milliseconds 100
# Get the HWND of the host window (not the WebBrowser — window HWND captures its children too)
$helper = [System.Windows.Interop.WindowInteropHelper]::new($hostWin)
$hwnd = $helper.Handle
# Capture via PrintWindow (works on HWND-hosted controls; RenderTargetBitmap does NOT)
$screenDC = [SvgCapture]::GetDC([IntPtr]::Zero)
$memDC = [SvgCapture]::CreateCompatibleDC($screenDC)
$hBmp = [SvgCapture]::CreateCompatibleBitmap($screenDC, $Width, $Height)
$oldObj = [SvgCapture]::SelectObject($memDC, $hBmp)
# PW_RENDERFULLCONTENT = 2 (forces re-render, needed for HwndHost controls)
$null = [SvgCapture]::PrintWindow($hwnd, $memDC, 2)
# Convert GDI HBITMAP -> WPF BitmapSource
$bmpSrc = [System.Windows.Interop.Imaging]::CreateBitmapSourceFromHBitmap(
$hBmp, [IntPtr]::Zero,
[System.Windows.Int32Rect]::Empty,
[System.Windows.Media.Imaging.BitmapSizeOptions]::FromEmptyOptions())
$null = $bmpSrc.Freeze()
# GDI cleanup
$null = [SvgCapture]::SelectObject($memDC, $oldObj)
$null = [SvgCapture]::DeleteDC($memDC)
$null = [SvgCapture]::DeleteObject($hBmp)
$null = [SvgCapture]::ReleaseDC([IntPtr]::Zero, $screenDC)
$null = $hostWin.Close()
$null = Write-VerboseLog "SVG logo captured at ${Width}x${Height}." -Level Success
return $bmpSrc
} catch {
$null = Write-VerboseLog "SVG render failed: $($_.Exception.Message)" -Level Warning
return $null
}
}
function Load-BitmapSource {
param([string]$LocalPath, [string]$Url, [string]$Base64,
[int]$SvgW = 120, [int]$SvgH = 40)
# Robust SVG detector: handles BOM, preamble, leading whitespace
function Test-IsSvg { param([string]$s)
if (-not $s) { return $false }
$sample = $s.Substring(0, [Math]::Min(600, $s.Length))
return $sample -match '(?is)