Private/OSDAppUIFormatting.ps1

function Get-OSDAppUIBuiltInMetadata {
    [CmdletBinding()]
    param()

    # Resolve metadata from the *loaded module*, not a mutable script-scoped
    # variable. The latter can differ in WinPE PowerShell 5.1 after dot-sourcing
    # private functions via the module loader.
    $moduleInfo = $MyInvocation.MyCommand.Module
    $moduleBase = if ($moduleInfo -and $moduleInfo.ModuleBase) {
        [string]$moduleInfo.ModuleBase
    }
    else {
        [string]$script:moduleRoot
    }
    if ([string]::IsNullOrWhiteSpace($moduleBase)) {
        Write-Warning 'OSDApps built-in metadata: cannot resolve the module location.'
        return
    }

    $metadataPath = Join-Path $moduleBase 'metadata\builtins.json'
    if (-not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) {
        Write-Warning "OSDApps built-in metadata not found: $metadataPath"
        return
    }
    try {
        $metadata = Get-Content -LiteralPath $metadataPath -Raw -Encoding UTF8 |
            ConvertFrom-Json -ErrorAction Stop
        foreach ($app in @($metadata.Applications)) {
            if ($app -and $app.Id) { $app }
        }
    }
    catch {
        Write-Warning "OSDApps built-in metadata could not be read from '$metadataPath': $($_.Exception.Message)"
    }
}

function Get-OSDAppUIDisplayInfo {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Application,
        [object[]]$CatalogApps = @(),
        [object[]]$BuiltInMetadata = @()
    )

    $id = [string]$Application.Id
    $display = [string]$Application.DisplayName
    $vendor = [string]$Application.Vendor

    # PowerShell 5.1 can unwrap a single pipeline result to a scalar object.
    # Do not depend on .Count and [0] on those results: a scalar PSCustomObject
    # is not an array. Resolve candidates as objects with a plain foreach.
    $catalogMatch = $null
    foreach ($candidate in $CatalogApps) {
        if ($null -ne $candidate -and
            [string]::Equals([string]$candidate.Id, $id, [StringComparison]::OrdinalIgnoreCase)) {
            $catalogMatch = $candidate
            break
        }
    }

    if ($null -ne $catalogMatch) {
        if (-not $display -or $display -eq $id) {
            $candidateName = [string]$catalogMatch.DisplayName
            if ($candidateName) { $display = $candidateName }
        }
        if (-not $vendor) { $vendor = [string]$catalogMatch.Vendor }
    }

    if ([string]$Application.Source -eq 'BuiltIn') {
        foreach ($candidate in $BuiltInMetadata) {
            if ($null -eq $candidate -or
                -not [string]::Equals([string]$candidate.Id, $id, [StringComparison]::OrdinalIgnoreCase)) {
                continue
            }
            if (-not $display -or $display -eq $id) {
                $candidateName = [string]$candidate.DisplayName
                if ($candidateName) { $display = $candidateName }
            }
            if (-not $vendor) { $vendor = [string]$candidate.Vendor }
            break
        }
    }

    if (-not $display) { $display = $id }
    [pscustomobject]@{
        DisplayName = $display
        Vendor = if ($vendor) { $vendor } else { '-' }
    }
}

function Get-OSDAppUIDisplayVersion {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Application)

    # "Current" is a vendor acquisition policy, not a concrete version.
    if ([string]$Application.Source -eq 'BuiltIn') { return 'Current' }
    $version = [string]$Application.Version
    if (-not [string]::IsNullOrWhiteSpace($version)) { return $version }
    return '-'
}

function Get-OSDAppUICachedVersion {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Application,
        [object[]]$CacheEntries = @()
    )

    $id = [string]$Application.Id
    $ids = if ($id -eq 'MicrosoftTeams') { @('MicrosoftTeams','Teams') } else { @($id) }
    $matches = @($CacheEntries | Where-Object { [string]$_.Id -in $ids -and $_.Valid -and -not [string]::IsNullOrWhiteSpace([string]$_.Version) })

    # Never show the version from another architecture or channel as though
    # it belongs to the staged application.
    $architecture = [string]$Application.Architecture
    if ($architecture -and $architecture -notin @('Auto','Any')) {
        $matches = @($matches | Where-Object { [string]$_.Architecture -eq $architecture })
    }
    $channel = [string]$Application.Channel
    if ($channel) {
        $matches = @($matches | Where-Object { -not $_.Channel -or [string]$_.Channel -eq $channel })
    }
    if ($matches.Count -eq 0) { return '-' }
    if ($matches.Count -gt 1 -and $architecture -in @('','Auto','Any')) {
        # Multiple architecture variants without an explicit target are ambiguous.
        $variants = @($matches.Architecture | Sort-Object -Unique)
        if ($variants.Count -gt 1) { return '-' }
    }
    $version = [string]$matches[0].Version
    if ($version -eq 'Current') { return 'Not recorded' }
    return $version
}

function Get-OSDAppUIAvailabilityLabel {
    [CmdletBinding()]
    param(
        [string]$Availability,
        [switch]$Offline
    )

    # The cache location and validity are independent of catalog connectivity.
    # The header shows whether online discovery was enabled, not each row.
    switch ($Availability) {
        'Cached'       { 'USB cached'; break }
        'CachedOnly'   { 'USB cached'; break }
        'InvalidCache' { 'USB cache invalid'; break }
        'Online'       { 'Online only'; break }
        'Available'    { 'Not on USB'; break }
        'Unavailable'  { 'Not available'; break }
        default        { if ($Availability) { $Availability } else { '-' } }
    }
}

function ConvertFrom-OSDAppUILogLine {
    [CmdletBinding()]
    param([AllowEmptyString()][string]$Line)

    $time = ''
    $level = 'Info'
    $component = ''
    $message = $Line
    $pattern = '^<!\[LOG\[(?<message>.*?)\]LOG\]!><(?<attributes>.*)>$'
    $match = [regex]::Match([string]$Line,$pattern)
    if ($match.Success) {
        $message = $match.Groups['message'].Value
        $attrs = @{}
        foreach ($m in [regex]::Matches($match.Groups['attributes'].Value,'(?<key>[A-Za-z]+)="(?<value>[^"]*)"')) {
            $attrs[$m.Groups['key'].Value.ToLowerInvariant()] = $m.Groups['value'].Value
        }
        if ($attrs['date'] -and $attrs['time']) {
            $time = "$($attrs['date']) $($attrs['time'])"
        }
        elseif ($attrs['time']) { $time = $attrs['time'] }
        $component = [string]$attrs['component']
        $level = switch ([string]$attrs['type']) {
            '3' { 'Error'; break }
            '2' { 'Warning'; break }
            default { 'Info' }
        }
    }
    $eventName = ''
    if ($message -match '^\[(?<event>[^\]]+)\]\s*') {
        $eventName = $Matches['event']
        $message = $message.Substring($Matches[0].Length)
        # Keep the event name even when a separate CMTrace component exists.
        if (-not $component) { $component = $eventName }
    }
    [pscustomobject]@{
        Timestamp = $time
        Level = $level
        Component = $component
        Event = $eventName
        Message = $message
        Raw = $Line
    }
}

function Get-OSDAppUILogRows {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [ValidateRange(1,2000)][int]$Tail = 500
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "Log file not found: $Path"
    }
    foreach ($line in @(Get-Content -LiteralPath $Path -Tail $Tail -ErrorAction Stop)) {
        ConvertFrom-OSDAppUILogLine -Line ([string]$line)
    }
}


function Get-OSDAppUISignatureLabel {
    [CmdletBinding()]
    param(
        [string]$Status,
        [string]$Source
    )

    # Repository ZIPs are validated using their manifest SHA-256 and are not
    # part of the built-in Authenticode signature-check scope.
    if ($Source -eq 'Repository') { return 'N/A' }

    switch ($Status) {
        'Valid'                { 'Signature valid'; break }
        'NotSigned'            { 'Not signed'; break }
        'HashMismatch'         { 'Hash mismatch'; break }
        'PublisherMismatch'    { 'Wrong publisher'; break }
        'NotTrusted'           { 'Not trusted'; break }
        'UnknownError'         { 'Trust unknown'; break }
        'Check failed'         { 'Check failed'; break }
        'File missing'         { 'File missing'; break }
        'Unavailable in WinPE' { 'WinPE: not checked'; break }
        'Not assessed'         { 'Not assessed'; break }
        default                { 'Not checked' }
    }
}