Public/Show-IntuneLapsGui.ps1

function Show-IntuneLapsGui {
    <#
    .SYNOPSIS
        Launches the IntuneLaps WPF graphical user interface.
    .DESCRIPTION
        Opens a Windows Presentation Foundation (WPF) window that allows the user
        to interactively sign in, search for Intune-managed devices, and retrieve
        LAPS credentials. The GUI respects the Windows dark/light mode setting.
 
        If already connected via Connect-IntuneLaps, the existing session is reused.
        Otherwise, clicking "Sign In" within the GUI will trigger authentication.
    .EXAMPLE
        Show-IntuneLapsGui
    #>

    [CmdletBinding()]
    param()

    begin {
        $ErrorActionPreference = 'Stop'

        # WPF is Windows-only. Fail gracefully on macOS/Linux with a clear message.
        if (-not $IsWindows -and $PSVersionTable.PSVersion.Major -ge 6) {
            Write-Warning 'Show-IntuneLapsGui is only supported on Windows (WPF not available on macOS/Linux).'
            Write-Host 'Use the CLI functions instead:'
            Write-Host ' Connect-IntuneLaps'
            Write-Host ' Find-IntuneLapsDevice -DeviceName <name>'
            Write-Host ' Get-IntuneLapsCredential -DeviceId <id> [-IncludePassword]'
            return
        }

        # WPF requires STA (Single Threaded Apartment) mode.
        # PowerShell 5.1 runs STA by default; PowerShell 7 (pwsh) runs MTA by default.
        # If we are in MTA, we must run the GUI on a new STA thread.
        [System.Threading.ApartmentState]$CurrentState = [System.Threading.Thread]::CurrentThread.GetApartmentState()
        if ($CurrentState -ne [System.Threading.ApartmentState]::STA) {
            Write-Verbose 'Running in MTA (PowerShell 7). Launching WPF GUI on a dedicated STA thread...'

            # Capture the manifest path before entering the thread — $PSCommandPath is $null inside a Thread scriptblock
            [string]$ManifestPath = Join-Path -Path $PSScriptRoot -ChildPath '..\IntuneLaps.psd1'
            $StaThread = [System.Threading.Thread]::new({
                Import-Module $ManifestPath -Force -ErrorAction Stop
                Show-IntuneLapsGui
            }.GetNewClosure())
            $StaThread.SetApartmentState([System.Threading.ApartmentState]::STA)
            $StaThread.IsBackground = $true
            $StaThread.Start()
            $StaThread.Join()   # Block the calling thread until the GUI closes
            return
        }

        # Load required WPF assemblies (STA thread confirmed)
        Add-Type -AssemblyName PresentationFramework
        Add-Type -AssemblyName PresentationCore
        Add-Type -AssemblyName WindowsBase
        Add-Type -AssemblyName System.Xaml
    }

    process {
        # ─── Read XAML ────────────────────────────────────────────────────────────
        [string]$XamlPath = Join-Path -Path $PSScriptRoot -ChildPath '..\Resources\MainWindow.xaml'
        if (-not (Test-Path -Path $XamlPath)) {
            throw "MainWindow.xaml not found at: $XamlPath"
        }

        [xml]$Xaml = Get-Content -Path $XamlPath -Raw -Encoding UTF8
        $Reader    = [System.Xml.XmlNodeReader]::new($Xaml)
        $Window    = [System.Windows.Markup.XamlReader]::Load($Reader)

        # ─── Detect Windows dark/light mode ───────────────────────────────────────
        function Get-WindowsIsDarkMode {
            try {
                [int]$Value = Get-ItemPropertyValue `
                    -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' `
                    -Name 'AppsUseLightTheme' `
                    -ErrorAction SilentlyContinue
                return ($Value -eq 0)  # 0 = dark, 1 = light
            }
            catch { return $true }  # default to dark
        }

        # Apply Windows theme-aware colours
        function Set-WindowTheme {
            param([bool]$IsDark)

            $Resources = $Window.Resources
            if ($IsDark) {
                $Resources['BackgroundBrush']  = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0x1E, 0x1E, 0x2E)
                $Resources['SurfaceBrush']     = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0x2A, 0x2A, 0x3E)
                $Resources['SurfaceAltBrush']  = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0x31, 0x31, 0x47)
                $Resources['ForegroundBrush']  = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0xF0, 0xF0, 0xF0)
                $Resources['MutedBrush']       = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0x9A, 0x9A, 0xB0)
                $Resources['InputBrush']       = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0x25, 0x25, 0x38)
                $Resources['BorderBrush']      = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0x3C, 0x3C, 0x5A)
                $Window.Background             = $Resources['BackgroundBrush']
            }
            else {
                # Light mode
                $Resources['BackgroundBrush']  = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0xF5, 0xF5, 0xF5)
                $Resources['SurfaceBrush']     = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0xFF, 0xFF, 0xFF)
                $Resources['SurfaceAltBrush']  = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0xEA, 0xEA, 0xF2)
                $Resources['ForegroundBrush']  = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0x1E, 0x1E, 0x1E)
                $Resources['MutedBrush']       = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0x60, 0x60, 0x70)
                $Resources['InputBrush']       = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0xFF, 0xFF, 0xFF)
                $Resources['BorderBrush']      = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(0xCC, 0xCC, 0xDD)
                $Window.Background             = $Resources['BackgroundBrush']
            }
        }

        Set-WindowTheme -IsDark (Get-WindowsIsDarkMode)

        # ─── Get named controls ────────────────────────────────────────────────────
        $TxtSearch         = $Window.FindName('TxtSearch')
        $BtnSearch         = $Window.FindName('BtnSearch')
        $BtnConnect        = $Window.FindName('BtnConnect')
        $BtnDisconnect     = $Window.FindName('BtnDisconnect')
        $GridDevices       = $Window.FindName('GridDevices')
        $LblSelectedDevice = $Window.FindName('LblSelectedDevice')
        $BtnGetCredentials = $Window.FindName('BtnGetCredentials')
        $TxtUsername       = $Window.FindName('TxtUsername')
        $BtnCopyUsername   = $Window.FindName('BtnCopyUsername')
        $PwdPassword       = $Window.FindName('PwdPassword')
        $TxtPassword       = $Window.FindName('TxtPassword')
        $BtnTogglePassword = $Window.FindName('BtnTogglePassword')
        $BtnCopyPassword   = $Window.FindName('BtnCopyPassword')
        $LblStatus         = $Window.FindName('LblStatus')
        $LblPermissionLevel= $Window.FindName('LblPermissionLevel')
        $PrgLoading        = $Window.FindName('PrgLoading')
        $PnlLoading        = $Window.FindName('PnlLoading')
        $TxtLoadingStatus  = $Window.FindName('TxtLoadingStatus')

        # ─── Internal state ────────────────────────────────────────────────────────
        [bool]$script:PasswordVisible  = $false
        [string]$script:PlainPassword  = $null
        [System.Windows.Threading.DispatcherTimer]$script:ClipTimer = $null

        # ─── Helper: update status bar ────────────────────────────────────────────
        function Update-Status {
            param([string]$Text, [string]$Level = '')
            $LblStatus.Text = $Text
            if ($Level -eq 'Full') {
                $LblPermissionLevel.Text       = '[OK] Full access (username + password)'
                $LblPermissionLevel.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.Color]::FromRgb(0x10, 0x7C, 0x10))
            }
            elseif ($Level -eq 'Metadata') {
                $LblPermissionLevel.Text       = '[!] Metadata only (no password access)'
                $LblPermissionLevel.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.Color]::FromRgb(0xCA, 0x75, 0x00))
            }
            else {
                $LblPermissionLevel.Text = ''
            }
        }

        # ─── Helper: clipboard auto-clear timer ───────────────────────────────────
        function Start-ClipboardClearTimer {
            param([int]$Seconds = 30)
            if ($script:ClipTimer) {
                $script:ClipTimer.Stop()
                $script:ClipTimer = $null
            }
            $script:ClipTimer = [System.Windows.Threading.DispatcherTimer]::new()
            $script:ClipTimer.Interval = [TimeSpan]::FromSeconds($Seconds)
            $script:ClipTimer.Add_Tick({
                [System.Windows.Clipboard]::Clear()
                $script:ClipTimer.Stop()
                $script:ClipTimer = $null
                Update-Status "Clipboard cleared automatically after $Seconds seconds."
            })
            $script:ClipTimer.Start()
        }

        # ─── Helper: Get all device names that have a LAPS record ───────────────
        function Get-LapsActiveDeviceNames {
            # Returns a case-insensitive HashSet of deviceNames with LAPS records,
            # or $null if the call fails (e.g. insufficient permissions).
            $LapsNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            try {
                [string]$Uri = "https://graph.microsoft.com/v1.0/directory/deviceLocalCredentials?`$select=id,deviceName"
                do {
                    $Response = Invoke-MgGraphRequestWithRetry -Parameters @{ Method = 'GET'; Uri = $Uri }
                    if ($Response.value) {
                        foreach ($Entry in $Response.value) {
                            if (-not [string]::IsNullOrEmpty($Entry.deviceName)) {
                                $null = $LapsNames.Add($Entry.deviceName)
                            }
                        }
                    }
                    $Uri = $Response.'@odata.nextLink'
                } while ($Uri)
                return $LapsNames
            }
            catch {
                Write-Verbose "Could not retrieve LAPS device list: $_"
                return $null
            }
        }

        # ─── Helper: force WPF to process pending render/layout work ─────────────
        function Invoke-DispatcherFlush {
            $Window.Dispatcher.Invoke(
                [Action]{},
                [System.Windows.Threading.DispatcherPriority]::Background
            )
        }

        # ─── Helper: Search Devices ───────────────────────────────────────────────
        function Invoke-DeviceSearch {
            [string]$Query = $TxtSearch.Text.Trim()

            $GridDevices.ItemsSource     = $null
            $BtnGetCredentials.IsEnabled = $false
            $LblSelectedDevice.Text      = 'Searching...'
            $PnlLoading.Visibility       = [System.Windows.Visibility]::Visible
            $TxtLoadingStatus.Text       = 'Loading devices...'
            $PrgLoading.Visibility       = [System.Windows.Visibility]::Visible

            if ($Query) {
                Update-Status "Searching for devices matching '$Query'..."
            } else {
                Update-Status 'Fetching all managed devices...'
            }
            Invoke-DispatcherFlush

            try {
                # ── Inline pagination (mirrors Find-IntuneLapsDevice) so we can
                # update the overlay after every page of results.
                [string]$SelectFields = 'id,azureADDeviceId,deviceName,operatingSystem,osVersion,lastSyncDateTime,managementState'
                if ($Query) {
                    [string]$NextUri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=startsWith(deviceName,'$Query')&`$select=$SelectFields"
                } else {
                    [string]$NextUri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$select=$SelectFields"
                }

                $RawDevices = [System.Collections.Generic.List[object]]::new()
                do {
                    $Response = Invoke-MgGraphRequestWithRetry -Parameters @{ Method = 'GET'; Uri = $NextUri }
                    if ($Response.value) {
                        foreach ($Device in $Response.value) { $RawDevices.Add($Device) }
                    }
                    $NextUri = $Response.'@odata.nextLink'

                    $TxtLoadingStatus.Text = "Loading devices... ($($RawDevices.Count) loaded)"
                    Invoke-DispatcherFlush
                } while ($NextUri)

                if ($RawDevices.Count -eq 0) {
                    $PnlLoading.Visibility = [System.Windows.Visibility]::Collapsed
                    $PrgLoading.Visibility = [System.Windows.Visibility]::Collapsed
                    if ($Query) {
                        Update-Status "No devices found for '$Query'."
                    } else {
                        Update-Status 'No Intune-managed devices found in the tenant.'
                    }
                    $LblSelectedDevice.Text = 'No results'
                    return
                }

                # ── Enrich with LAPS Active status (single bulk call)
                [int]$TotalDevices     = $RawDevices.Count
                $TxtLoadingStatus.Text = "Checking LAPS status for $TotalDevices device(s)..."
                Invoke-DispatcherFlush

                $LapsActiveNames = Get-LapsActiveDeviceNames

                $EnrichedDevices = @(foreach ($Dev in $RawDevices) {
                    [PSCustomObject]@{
                        DeviceId         = $Dev.azureADDeviceId
                        DeviceName       = $Dev.deviceName
                        OperatingSystem  = $Dev.operatingSystem
                        OsVersion        = $Dev.osVersion
                        ManagementState  = $Dev.managementState
                        LastSyncDateTime = $Dev.lastSyncDateTime
                        LapsActive       = if ($null -eq $LapsActiveNames) { '?' }
                                           elseif ($LapsActiveNames.Contains($Dev.deviceName)) { 'Yes' }
                                           else { 'No' }
                    }
                })

                $GridDevices.ItemsSource = $EnrichedDevices
                $PnlLoading.Visibility   = [System.Windows.Visibility]::Collapsed
                $PrgLoading.Visibility   = [System.Windows.Visibility]::Collapsed
                Update-Status "$TotalDevices device(s) found."
                $LblSelectedDevice.Text  = '- select a device'
            }
            catch {
                $PnlLoading.Visibility = [System.Windows.Visibility]::Collapsed
                $PrgLoading.Visibility = [System.Windows.Visibility]::Collapsed
                Update-Status "Search failed: $_"
                $LblSelectedDevice.Text = '- select a device'
            }
        }

        # ─── EVENT: Sign In ───────────────────────────────────────────────────────
        $BtnConnect.Add_Click({
            Update-Status 'Signing in to Microsoft Graph...'
            try {
                $ConnectResult = Connect-IntuneLaps
                [string]$Level = $ConnectResult.PermissionLevel

                Update-Status "Signed in as: $($ConnectResult.Account)" -Level $Level
                $BtnSearch.IsEnabled = $true
                $BtnDisconnect.IsEnabled = $true
                Invoke-DeviceSearch
            }
            catch {
                Update-Status "Sign in failed: $_"
            }
        })

        # ─── EVENT: Sign Out ──────────────────────────────────────────────────────
        $BtnDisconnect.Add_Click({
            Update-Status 'Signing out...'
            try {
                Disconnect-IntuneLaps -ErrorAction SilentlyContinue
                Update-Status 'Not connected - click Sign In to authenticate'
                $BtnSearch.IsEnabled = $false
                $BtnDisconnect.IsEnabled = $false
                $GridDevices.ItemsSource = $null
                $LblSelectedDevice.Text = '- select a device'
                $BtnGetCredentials.IsEnabled = $false
                $TxtUsername.Text = ''
                $PwdPassword.Password = ''
                $TxtPassword.Text = ''
                $script:PlainPassword = $null
            }
            catch {
                Update-Status "Sign out failed: $_"
            }
        })

        # ─── EVENT: Search ────────────────────────────────────────────────────────
        $BtnSearch.Add_Click({
            Invoke-DeviceSearch
        })

        # Support pressing Enter in search box
        $TxtSearch.Add_KeyDown({
            param($s, $e)
            if ($e.Key -eq [System.Windows.Input.Key]::Return) {
                $BtnSearch.RaiseEvent([System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Button]::ClickEvent))
            }
        })

        # ─── EVENT: Device row selected ───────────────────────────────────────────
        $GridDevices.Add_SelectionChanged({
            if ($null -ne $GridDevices.SelectedItem) {
                [string]$Name = $GridDevices.SelectedItem.DeviceName
                $LblSelectedDevice.Text = "- $Name"
                $BtnGetCredentials.IsEnabled = $true
                # Clear previous credentials
                $TxtUsername.Text     = ''
                $PwdPassword.Password = ''
                $TxtPassword.Text     = ''
                $script:PlainPassword = $null
                $BtnCopyUsername.IsEnabled  = $false
                $BtnCopyPassword.IsEnabled  = $false
                $BtnTogglePassword.IsEnabled = $false
            }
        })

        # ─── EVENT: Load Credentials ──────────────────────────────────────────────
        $BtnGetCredentials.Add_Click({
            if ($null -eq $GridDevices.SelectedItem) { return }

            [string]$DeviceId = $GridDevices.SelectedItem.DeviceId
            [string]$Level    = Test-LapsPermission
            [bool]$CanReadPassword = ($Level -eq 'Full')

            Update-Status 'Retrieving LAPS credentials...'

            try {
                $Cred = Get-IntuneLapsCredential -DeviceId $DeviceId -IncludePassword:$CanReadPassword

                if ($null -eq $Cred) {
                    Update-Status 'No LAPS record found for this device. Ensure LAPS is configured and the device has checked in recently.'
                    return
                }

                $TxtUsername.Text = $Cred.AccountName
                $BtnCopyUsername.IsEnabled = (-not [string]::IsNullOrEmpty($Cred.AccountName))

                if ($CanReadPassword -and $Cred.PasswordRetrieved) {
                    $script:PlainPassword = $Cred.Password
                    $PwdPassword.Password = $Cred.Password
                    $TxtPassword.Text     = $Cred.Password
                    $BtnCopyPassword.IsEnabled   = $true
                    $BtnTogglePassword.IsEnabled = $true
                    Update-Status "Credentials loaded for: $($Cred.DeviceName)" -Level $Level
                }
                else {
                    $PwdPassword.Password = ''
                    $TxtPassword.Text     = ''
                    $script:PlainPassword = $null
                    $BtnCopyPassword.IsEnabled   = $false
                    $BtnTogglePassword.IsEnabled = $false

                    if (-not $CanReadPassword) {
                        Update-Status "Username loaded. Password unavailable - your account lacks 'Cloud Device Administrator' or 'Intune Administrator' role." -Level $Level
                    }
                    else {
                        Update-Status "Credentials loaded (no password record found)." -Level $Level
                    }
                }
            }
            catch {
                Update-Status "Failed to load credentials: $_"
            }
        })

        # ─── EVENT: Toggle password visibility ────────────────────────────────────
        $BtnTogglePassword.Add_Click({
            $script:PasswordVisible = -not $script:PasswordVisible

            if ($script:PasswordVisible) {
                $PwdPassword.Visibility = [System.Windows.Visibility]::Collapsed
                $TxtPassword.Visibility = [System.Windows.Visibility]::Visible
                $Window.FindName('TxtToggleIcon').Text = '[Hide]'
            }
            else {
                $TxtPassword.Visibility = [System.Windows.Visibility]::Collapsed
                $PwdPassword.Visibility = [System.Windows.Visibility]::Visible
                $Window.FindName('TxtToggleIcon').Text = '[Show]'
            }
        })

        # ─── EVENT: Copy Username ─────────────────────────────────────────────────
        $BtnCopyUsername.Add_Click({
            if (-not [string]::IsNullOrEmpty($TxtUsername.Text)) {
                [System.Windows.Clipboard]::SetText($TxtUsername.Text)
                Update-Status 'Username copied to clipboard.'
            }
        })

        # ─── EVENT: Copy Password ─────────────────────────────────────────────────
        $BtnCopyPassword.Add_Click({
            if (-not [string]::IsNullOrEmpty($script:PlainPassword)) {
                [System.Windows.Clipboard]::SetText($script:PlainPassword)
                Update-Status 'Password copied to clipboard - will be cleared in 30 seconds.'
                Start-ClipboardClearTimer -Seconds 30
            }
        })

        # ─── Check existing Graph session on open ─────────────────────────────────
        $Window.Add_Loaded({
            try {
                $Ctx = Get-MgContext -ErrorAction SilentlyContinue
                if ($Ctx) {
                    [string]$Level = Test-LapsPermission
                    Update-Status "Already signed in as: $($Ctx.Account)" -Level $Level
                    $BtnSearch.IsEnabled = $true
                    $BtnDisconnect.IsEnabled = $true
                    Invoke-DeviceSearch
                }
                else {
                    Update-Status 'Not connected - click Sign In to authenticate.'
                    $BtnSearch.IsEnabled = $false
                    $BtnDisconnect.IsEnabled = $false
                }
            }
            catch {
                Update-Status 'Not connected - click Sign In to authenticate.'
                $BtnSearch.IsEnabled = $false
                $BtnDisconnect.IsEnabled = $false
            }
        })

        # ─── Clear clipboard and disconnect on window close ──────────────────────
        $Window.Add_Closing({
            if ($script:ClipTimer) { $script:ClipTimer.Stop() }
            [System.Windows.Clipboard]::Clear()
            Disconnect-IntuneLaps -ErrorAction SilentlyContinue
        })

        # ─── Show the window ──────────────────────────────────────────────────────
        $null = $Window.ShowDialog()
    }
}