Private/Kinds/Browser.ps1

# The Browser Kind: how Edge, Chrome and Firefox last closed for the User of the Run, the crash reports each holds, the extensions somebody installed in them, how large each profile is, the policies set for them, and the WebView2 runtime.
#
# "The browser keeps closing" is a ticket a Report said nothing about: Edge was known as an
# application, Chrome and Firefox only where a Check Definition named one of their
# extensions. Browsers keep no log a Run could read. What they leave is state, and this
# Kind reads these pieces of it:
#
# - Edge and Chrome: for every profile, how the browser last closed, as the profile's
# preferences recorded it - normally, crashed, or ended with the session.
# - Edge, Chrome and Firefox: for every profile, the extensions in it - name, version,
# on or off, and how each came there. Of the preferences only these entries are kept;
# of an extension nothing it stores. Its name may be looked up in its own files.
# - Edge, Chrome and Firefox: how large every profile is and how much of it is cache,
# measured as the User Profile Check measures, within a time limit. A size, never a
# name of what is in it.
# - The policies of the three, for the machine and for the User, from the registry and
# Firefox's policy file; and the proxy they force, set beside the User's proxy and the
# WinHTTP proxy the Network Check read. Those two are handed over as observed data and
# not read again; where the Network Check did not run, the Finding says so.
# - The crashes of the browsers and of WebView2 (msedgewebview2.exe) the Stability Check
# read from the event log, handed over the same way: a crash at the last exit beside
# crashes logged of the same browser is a WARN. And whether the WebView2 runtime that
# new Outlook, Teams and the web add-ins of Office are drawn in is installed.
# - Edge, Chrome and Firefox: the crash reports the browser holds, counted and dated by
# their files. A report is never opened, copied or hashed: what is in it is the
# browser's memory (ADR-0006).
#
# Firefox records how it last closed in no file a Run can read; its profiles are listed,
# and of their last exit the Report says nothing rather than "normal".
#
# Which profiles there are is the list the BrowserExtension Kind reads too (BrowserProfile.ps1),
# so the two never disagree. Only the profiles of the account the Run runs as; nothing of
# another User. Readable without admin rights.
#
# Observed 2026-10-14 on a workstation: Edge open, and its profile in use recorded as
# crashed - a Chromium browser writes "crashed" when it opens a profile and "normal" only
# when it closes it. Read while the browser runs, every open profile looks crashed. The
# Gatherer says whether the browser is running, and the Judge does not call that a crash.

function ConvertFrom-BrowserPreferenceText {
    # A preferences document as an object, or the problem with it: 'Missing', 'Empty' or
    # 'Damaged'. Pure.
    [CmdletBinding()]
    param([AllowNull()]$Text)

    if ($null -eq $Text) { return 'Missing' }
    if (-not "$Text".Trim()) { return 'Empty' }
    $parsed = $null
    try { $parsed = $Text | ConvertFrom-Json -ErrorAction Stop } catch { return 'Damaged' }
    # A document of a profile is one object. A list, a number or a word is none.
    if ($null -eq $parsed -or $parsed -is [string] -or $parsed -is [ValueType] -or $parsed -is [array]) { return 'Damaged' }
    $parsed
}

function Get-BrowserPreferenceExit {
    # The last exit one document records, or $null where it records none. Pure.
    [CmdletBinding()]
    param([AllowNull()]$Document)

    $profile0 = Get-DataProperty $Document 'profile'
    if ($null -eq $profile0) { return $null }
    $type = "$(Get-DataProperty $profile0 'exit_type')".Trim()
    if ($type) {
        switch ($type) {
            'Normal'       { return 'Normal' }
            'Crashed'      { return 'Crashed' }
            'SessionEnded' { return 'SessionEnded' }
            # A word a newer browser brings is none this module can put into words.
            default        { return 'Unknown' }
        }
    }
    # The older way: whether it closed cleanly, and nothing else.
    $clean = Get-DataProperty $profile0 'exited_cleanly'
    if ($clean -is [bool]) { if ($clean) { return 'Normal' } else { return 'Crashed' } }
    $null
}

function ConvertFrom-BrowserPreferenceExtension {
    <#
    .SYNOPSIS
        The extensions two preference documents name, each entry joined from both. Pure.
    .DESCRIPTION
        A browser may write the entry of one extension half into each document: where it
        came from into the secure preferences, why it is off into the other. Joined field
        by field, and where both have a field the secure preferences have the last word:
        they are where a newer browser writes what it protects.
 
        An entry that names no place it came from ("location") is no installed extension:
        it is what is left of a removed one, a permission granted, a flag of the store.
 
        The location is handed on as the browser wrote it; what it means is the Judge's
        (Get-BrowserExtensionOrigin). The name may be a placeholder of the extension's own,
        "__MSG_appName__", or missing where a newer browser keeps no manifest in the
        preferences: the Gatherer looks it up in the extension's files.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Preference, [AllowNull()]$SecurePreference)

    $plain  = Get-BrowserPreferenceSetting -Document $Preference
    $secure = Get-BrowserPreferenceSetting -Document $SecurePreference
    foreach ($id in @(@($plain.Keys) + @($secure.Keys) | Select-Object -Unique | Sort-Object)) {
        $entry = @{}
        foreach ($side in $plain, $secure) { if ($side.ContainsKey($id)) { foreach ($k in $side[$id].Keys) { $entry[$k] = $side[$id][$k] } } }
        $location = ConvertTo-Number $entry['location']
        if ($null -eq $location) { continue }

        $manifest = $entry['manifest']
        $version  = "$(Get-DataProperty $manifest 'version')".Trim()
        if (-not $version) {
            # "<id>\2.4.1_0" or "2.4.1_0": the folder the version lies in.
            $last = @("$($entry['path'])" -split '[\\/]')[-1]
            $text = ($last -split '_')[0]
            if ($text -match '^\d+(\.\d+)*$') { $version = $text }
        }
        [pscustomobject]@{
            Id        = $id
            Name      = "$(Get-DataProperty $manifest 'name')".Trim()
            Version   = $version
            Enabled   = Test-BrowserExtensionOn -Entry ([pscustomobject]$entry)
            Location  = [int]$location
            ByDefault = [bool]($entry['was_installed_by_default'] -eq $true)
            Source    = ''
        }
    }
}

function ConvertFrom-BrowserPreference {
    <#
    .SYNOPSIS
        What a Chromium profile's preferences say of it: how the browser last closed, and
        what kept them from saying. Pure.
    .DESCRIPTION
        Edge and Chrome keep two documents in a profile: "Preferences", and "Secure
        Preferences", where newer browsers keep what they protect against tampering. The
        last exit is in the first; the second is read where the first does not hold it.
 
        LastExit is Normal, Crashed, SessionEnded (the browser was closed by a sign-out or
        a shutdown) or Unknown. Problem is empty where the preferences could be read, and
        otherwise says why not: Missing, Empty or Damaged. Secure preferences that are
        missing or damaged are no problem: an older browser keeps none.
 
        Handed the text of the documents, never a path: the Gatherer reads the files.
    .PARAMETER Preference
        The text of "Preferences"; $null where the file is not there.
    .PARAMETER SecurePreference
        The text of "Secure Preferences"; $null where the file is not there.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    # Not [string]: that makes '' of a $null, and a file that is not there is not one that
    # is empty.
    param([AllowNull()]$Preference, [AllowNull()]$SecurePreference)

    $document = ConvertFrom-BrowserPreferenceText -Text $Preference
    if ($document -is [string]) { return [pscustomobject]@{ LastExit = 'Unknown'; Problem = $document; Extensions = @() } }

    $secure = ConvertFrom-BrowserPreferenceText -Text $SecurePreference
    $exit = Get-BrowserPreferenceExit -Document $document
    if ($null -eq $exit -and $secure -isnot [string]) { $exit = Get-BrowserPreferenceExit -Document $secure }
    if ($null -eq $exit) { $exit = 'Unknown' }
    [pscustomobject]@{
        LastExit   = $exit
        Problem    = ''
        Extensions = @(ConvertFrom-BrowserPreferenceExtension -Preference $document -SecurePreference $secure)
    }
}

function Read-BrowserPreferenceFile {
    # The text of one document of a profile, $null where the file is not there. Read while
    # the browser has it open: it keeps it open as long as it runs.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
    Read-AppLogFile -Path $Path
}

function Read-BrowserJsonFile {
    # A JSON file of a browser or an extension as an object, $null where it is not there or
    # is no JSON. For the files a name is looked up in, where nothing is lost without one.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
    try { Read-AppLogFile -Path $Path | ConvertFrom-Json -ErrorAction Stop } catch { $null }
}

function Resolve-BrowserExtensionName {
    <#
    .SYNOPSIS
        The name of a Chromium extension as the User sees it in the browser, where the
        preferences hold none or a placeholder like "__MSG_appName__".
    .DESCRIPTION
        An extension that is translated names a message in its manifest, and the message is
        in its own folder: _locales\<language>\messages.json. Read in German first, then in
        the language the extension says is its own, then in English. Where none of that
        answers, the name stays empty and the Report names the extension by its id.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][string]$ProfileFolder, [Parameter(Mandatory)]$Extension)

    $name = "$($Extension.Name)"
    if ($name -and $name -notmatch '^__MSG_.+__$') { return $name }
    $root = Join-Path $ProfileFolder ('Extensions\{0}' -f $Extension.Id)
    if (-not (Test-Path -LiteralPath $root -PathType Container)) { return '' }
    $folders = @(Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue)
    $folder  = @($folders | Where-Object { ($_.Name -split '_')[0] -eq $Extension.Version })[0]
    if (-not $folder) { $folder = @($folders | Sort-Object Name -Descending)[0] }
    if (-not $folder) { return '' }

    $manifest = Read-BrowserJsonFile -Path (Join-Path $folder.FullName 'manifest.json')
    if (-not $name) { $name = "$(Get-DataProperty $manifest 'name')".Trim() }
    if ($name -notmatch '^__MSG_(.+)__$') { return $name }
    $key = $Matches[1]
    foreach ($language in @('de', "$(Get-DataProperty $manifest 'default_locale')", 'en', 'en_US') | Where-Object { $_ } | Select-Object -Unique) {
        $messages = Read-BrowserJsonFile -Path (Join-Path $folder.FullName ('_locales\{0}\messages.json' -f $language))
        if ($null -eq $messages) { continue }
        # A message's key is matched without regard to case, as the browser matches it.
        $entry = @($messages.PSObject.Properties | Where-Object { $_.Name -eq $key })[0]
        if ($entry) { $said = "$(Get-DataProperty $entry.Value 'message')".Trim(); if ($said) { return $said } }
    }
    ''
}

function Get-BrowserFirefoxExtension {
    <#
    .SYNOPSIS
        The extensions and themes of one Firefox profile, as its extensions.json lists them,
        and why that list could not be read.
    .DESCRIPTION
        Read as the BrowserExtension Kind reads it (Test-BrowserFirefoxExtensionOn). What
        kind of add-on it is, where it lies and how it was installed are handed on as Firefox
        wrote them; what they mean, and which count, is the Judge's. A profile without the
        list has never had anything installed and is no problem.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][string]$Folder)

    $file = Join-Path $Folder 'extensions.json'
    if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { return [pscustomobject]@{ Problem = ''; Reason = ''; Extensions = @() } }
    try { $text = Read-AppLogFile -Path $file }
    catch { return [pscustomobject]@{ Problem = 'ListUnreadable'; Reason = $_.Exception.Message; Extensions = @() } }
    try { $list = $text | ConvertFrom-Json -ErrorAction Stop }
    catch { return [pscustomobject]@{ Problem = 'ListDamaged'; Reason = ''; Extensions = @() } }

    $extensions = @(@(Get-DataProperty $list 'addons') | Where-Object { $_ } | ForEach-Object {
        [pscustomobject]@{
            Id        = "$($_.id)"
            Name      = "$(Get-DataProperty (Get-DataProperty $_ 'defaultLocale') 'name')".Trim()
            Version   = "$($_.version)"
            Enabled   = Test-BrowserFirefoxExtensionOn -Addon $_
            Location  = "$($_.location)"
            ByDefault = $false
            Source    = "$(Get-DataProperty (Get-DataProperty $_ 'installTelemetryInfo') 'source')"
            Type      = "$($_.type)"
        }
    })
    [pscustomobject]@{ Problem = ''; Reason = ''; Extensions = $extensions }
}

function Get-BrowserProfileReading {
    <#
    .SYNOPSIS
        One profile as the Judge reads it: its name, how it last closed, its extensions,
        and why that could not be read.
    .DESCRIPTION
        Only the record leaves this function, never the documents: the preferences hold far
        more than a last exit and the extensions, and none of the rest is any of a Report's
        business.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$Found)

    # Firefox keeps no last exit a Run can read: its profile is listed with its extensions.
    if ($Found.Engine -ne 'Chromium') {
        $listed = Get-BrowserFirefoxExtension -Folder $Found.Folder
        return [pscustomobject]@{ Profile = $Found.Profile; LastExit = 'Unknown'; Problem = $listed.Problem; Reason = $listed.Reason; Extensions = @($listed.Extensions) }
    }
    try {
        $preference = Read-BrowserPreferenceFile -Path (Join-Path $Found.Folder 'Preferences')
        $secure     = $null
        try { $secure = Read-BrowserPreferenceFile -Path (Join-Path $Found.Folder 'Secure Preferences') } catch { $secure = $null }
        $record = ConvertFrom-BrowserPreference -Preference $preference -SecurePreference $secure
    }
    catch {
        return [pscustomobject]@{ Profile = $Found.Profile; LastExit = 'Unknown'; Problem = 'Unreadable'; Reason = $_.Exception.Message; Extensions = @() }
    }
    $extensions = @($record.Extensions | ForEach-Object {
        $one = $_
        try { $one.Name = Resolve-BrowserExtensionName -ProfileFolder $Found.Folder -Extension $one } catch { $one.Name = '' }
        $one
    })
    [pscustomobject]@{ Profile = $Found.Profile; LastExit = $record.LastExit; Problem = $record.Problem; Reason = ''; Extensions = $extensions }
}

function Get-BrowserCrashReport {
    <#
    .SYNOPSIS
        When each crash report a browser holds was written: the times of the files, and
        nothing of what is in them.
    .DESCRIPTION
        Edge and Chrome keep their reports in "Crashpad\reports" of their "User Data"
        folder, one .dmp each; whether one was sent is written into a file of Crashpad's
        own that is not read, so the reports a browser holds are counted, sent or not.
        Firefox keeps the reports it has not sent in "Crash Reports\pending".
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][string]$Folder)

    if (-not (Test-Path -LiteralPath $Folder -PathType Container)) {
        return [pscustomobject]@{ Read = $true; Reason = ''; Times = @() }
    }
    try {
        $times = @(Get-ChildItem -LiteralPath $Folder -Filter '*.dmp' -File -ErrorAction Stop | ForEach-Object { $_.LastWriteTime })
        [pscustomobject]@{ Read = $true; Reason = ''; Times = $times }
    }
    catch { [pscustomobject]@{ Read = $false; Reason = $_.Exception.Message; Times = @() } }
}

# The folders of a Chromium profile its caches are kept in. They lie in the profile, and
# what they hold counts in its size as well.
$script:BrowserChromiumCache = @('Cache', 'Code Cache', 'GPUCache', 'Service Worker\CacheStorage', 'Service Worker\ScriptCache')

function Measure-BrowserProfile {
    <#
    .SYNOPSIS
        The size of every profile and of its cache, with how each measurement ended, all
        of them together within the time given.
    .DESCRIPTION
        Measured by the User Profile Check's own measurement (Measure-UserProfileFolder),
        with its time limit and its "cut short": a profile of many gigabytes must not hold
        up the Run, and what was counted before the time ran out is said to be at least.
 
        A Chromium profile keeps its cache inside itself; the measurement counts what lies
        in a cache folder once, as the cache's, and the profile's row is the rest. Firefox
        keeps its cache beside the profile, under the local application data, in a folder
        of the same name. Decides nothing: a row per part of each profile, Part and State
        and Bytes, in the order of the profiles handed in.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Found, [int]$Seconds = 30)

    $profiles = @($Found | Where-Object { $_ })
    if (-not $profiles.Count) { return }
    $local   = [Environment]::GetFolderPath('LocalApplicationData')
    $folders = New-Object System.Collections.Generic.List[psobject]
    for ($i = 0; $i -lt $profiles.Count; $i++) {
        $one = $profiles[$i]
        $folders.Add([pscustomobject]@{ Id = "$i|Profile"; Path = $one.Folder })
        if ($one.Engine -eq 'Firefox') {
            $folders.Add([pscustomobject]@{ Id = "$i|Cache"; Path = (Join-Path $local ('Mozilla\Firefox\Profiles\{0}' -f (Split-Path -Path $one.Folder -Leaf))) })
        }
        else {
            foreach ($cache in $script:BrowserChromiumCache) { $folders.Add([pscustomobject]@{ Id = "$i|Cache"; Path = (Join-Path $one.Folder $cache) }) }
        }
    }

    $sizes = @{}
    foreach ($row in @(Measure-UserProfileFolder -Folder $folders.ToArray() -ProfilePath '' -Seconds $Seconds)) {
        $index, $part = "$($row.Id)" -split '\|', 2
        if (-not $sizes.ContainsKey($index)) { $sizes[$index] = New-Object System.Collections.Generic.List[psobject] }
        $sizes[$index].Add([pscustomobject]@{ Part = $part; State = (Get-UserProfileMeasureStateName -State $row.State); Bytes = [long]$row.Bytes })
    }
    for ($i = 0; $i -lt $profiles.Count; $i++) {
        [pscustomobject]@{ Folder = $profiles[$i].Folder; Size = @($(if ($sizes.ContainsKey("$i")) { $sizes["$i"].ToArray() })) }
    }
}

# Where each browser reads its policies, under HKLM for the machine and HKCU for the User.
# Readable without admin rights: a policy is meant to be read by the program it binds.
$script:BrowserPolicyKey = @{
    Chrome  = 'SOFTWARE\Policies\Google\Chrome'
    Edge    = 'SOFTWARE\Policies\Microsoft\Edge'
    Firefox = 'SOFTWARE\Policies\Mozilla\Firefox'
}

function Get-BrowserPolicyRegistry {
    <#
    .SYNOPSIS
        Every policy value under one key and its subkeys, a row each: the browser, the
        scope, the name, and the value as the registry holds it.
    .DESCRIPTION
        A list policy is a subkey whose values are named 1, 2, 3: it is one row, its
        name the subkey's and its value the list. Any other subkey's values are rows named
        by their path below the key, "Proxy\Mode", as Firefox writes its nested policies.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$Browser, [Parameter(Mandatory)][string]$Scope,
        [Parameter(Mandatory)][string]$Path, [AllowEmptyString()][string]$Prefix = ''
    )

    if (-not (Test-Path -LiteralPath $Path)) { return }
    $key   = Get-Item -LiteralPath $Path -ErrorAction Stop
    $names = @($key.GetValueNames() | Where-Object { $_ })
    if ($Prefix -and $names.Count -and -not @($names | Where-Object { $_ -notmatch '^\d+$' }).Count) {
        $list = @($names | Sort-Object { [int]$_ } | ForEach-Object { "$($key.GetValue($_))" })
        [pscustomobject]@{ Browser = $Browser; Scope = $Scope; Name = $Prefix; Value = $list }
    }
    else {
        foreach ($name in $names) {
            $value = $key.GetValue($name)
            if ($value -is [array]) { $value = @($value | ForEach-Object { "$_" }) } else { $value = "$value" }
            $named = $name
            if ($Prefix) { $named = '{0}\{1}' -f $Prefix, $name }
            [pscustomobject]@{ Browser = $Browser; Scope = $Scope; Name = $named; Value = $value }
        }
    }
    foreach ($sub in @($key.GetSubKeyNames())) {
        $below = $sub
        if ($Prefix) { $below = '{0}\{1}' -f $Prefix, $sub }
        Get-BrowserPolicyRegistry -Browser $Browser -Scope $Scope -Path (Join-Path $Path $sub) -Prefix $below
    }
}

function Get-BrowserFirefoxPolicyFile {
    <#
    .SYNOPSIS
        The policies of Firefox's own policy file, distribution\policies.json beside the
        program: a row each, a nested one as its JSON.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    $program = Get-BrowserExecutable -Browser 'Firefox'
    if (-not $program) { return }
    $file = Join-Path (Split-Path -Path $program -Parent) 'distribution\policies.json'
    if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { return }
    $document = Read-AppLogFile -Path $file | ConvertFrom-Json -ErrorAction Stop
    foreach ($one in @((Get-DataProperty $document 'policies').PSObject.Properties)) {
        $value = $one.Value
        if ($null -ne $value -and $value -isnot [string] -and $value -isnot [ValueType]) { $value = $value | ConvertTo-Json -Compress -Depth 10 }
        [pscustomobject]@{ Browser = 'Firefox'; Scope = 'Machine'; Name = $one.Name; Value = "$value" }
    }
}

function Get-BrowserPolicy {
    <#
    .SYNOPSIS
        Every browser policy that is set, for the machine and for the User, and whether
        they could be read. Decides nothing, including what a policy means.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    try {
        $rows = @(foreach ($browser in 'Chrome', 'Edge', 'Firefox') {
            foreach ($scope in @(@{ Name = 'Machine'; Hive = 'HKLM:' }, @{ Name = 'User'; Hive = 'HKCU:' })) {
                Get-BrowserPolicyRegistry -Browser $browser -Scope $scope.Name -Path (Join-Path $scope.Hive $script:BrowserPolicyKey[$browser])
            }
            if ($browser -eq 'Firefox') { Get-BrowserFirefoxPolicyFile }
        })
        [pscustomobject]@{ Read = $true; Reason = ''; Policies = $rows }
    }
    catch { [pscustomobject]@{ Read = $false; Reason = $_.Exception.Message; Policies = @() } }
}

# The client id under which Edge's updater registers the WebView2 runtime it keeps, the
# "Evergreen" runtime: its version is the value "pv". 0.0.0.0 or nothing is none.
$script:BrowserWebView2Client = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'

function Get-BrowserWebView2 {
    <#
    .SYNOPSIS
        The version of the WebView2 runtime installed for the machine and for the User,
        each as the updater registered it; '' where it registered none.
    .DESCRIPTION
        Machine-wide under the 32-bit view of the registry on a 64-bit Windows and under
        the plain one on a 32-bit Windows; for the User under HKCU.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    $read = { param($Path) "$((Get-ItemProperty -LiteralPath $Path -ErrorAction SilentlyContinue).pv)".Trim() }
    $machine = & $read ('HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{0}' -f $script:BrowserWebView2Client)
    if (-not $machine) { $machine = & $read ('HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{0}' -f $script:BrowserWebView2Client) }
    [pscustomobject]@{ Machine = $machine; User = (& $read ('HKCU:\Software\Microsoft\EdgeUpdate\Clients\{0}' -f $script:BrowserWebView2Client)) }
}

# What Get-BrowserData hands its Judge. See Private/DataShape.ps1.
$script:BrowserDataShape = @{
    GatheredAt         = 'Answered'
    MeasureSeconds     = 'Value'
    Browsers           = 'List'
    Policies           = 'List'
    PoliciesRead       = 'Value'
    PoliciesReason     = 'Value'
    StabilityObserved  = 'Answered'
    ObservedCrashes    = 'List'
    CrashWindowDays    = 'Value'
    WebView2           = 'Value'
    NetworkObserved    = 'Answered'
    UserProxy          = 'Value'
    WinHttpProxy64Text = 'Value'
    WinHttpProxy32Text = 'Value'
}

function Get-BrowserData {
    <#
    .SYNOPSIS
        Every browser of the three: whether it is installed and running, its profiles of
        the User of the Run, the crash reports it holds; and the policies of all three.
    .DESCRIPTION
        MeasureSeconds (30) is how long the profiles of all browsers are measured for,
        together. A limit of this module, so that a Run is not held up by a profile of
        many gigabytes; a profile not counted in that time is said to be "at least".
    .PARAMETER Observed
        What the Checks before this one gathered, keyed by Kind. The proxy of the User and
        of WinHTTP come from the Network Check, the crashes of the event log from the
        Stability Check, copied as they are.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [hashtable]$Parameters = @{},
        [AllowNull()][hashtable]$Observed = @{}
    )

    $seconds = ConvertTo-Number (Get-Parameter $Parameters 'MeasureSeconds' 30)
    if ($null -eq $seconds -or $seconds -le 0) { $seconds = 30 }

    # The Session the Run is in: a browser counts as running where it runs in this one.
    $session  = (Get-Process -Id $PID).SessionId

    $places = @(Get-BrowserProfileRoot)
    $policies = Get-BrowserPolicy
    $network  = Get-ObservedKindData -Observed $Observed -Kind 'Network'
    $stability = Get-ObservedKindData -Observed $Observed -Kind 'Stability'
    $found  = @(Get-BrowserProfile)
    $sizes  = @{}
    foreach ($measured in @(Measure-BrowserProfile -Found $found -Seconds ([int]$seconds))) { $sizes[$measured.Folder] = $measured.Size }

    $browsers = foreach ($place in $places) {
        $program = $script:BrowserProgram[$place.Browser] -replace '\.exe$', ''
        $reports = Join-Path $place.Root 'Crashpad\reports'
        if ($place.Engine -eq 'Firefox') { $reports = Join-Path (Split-Path -Path $place.Root -Parent) 'Crash Reports\pending' }
        [pscustomobject]@{
            Browser      = $place.Browser
            Engine       = $place.Engine
            # Installed where Windows knows where its program is: every installer of the
            # three registers it, so that "Run" finds it.
            Installed    = [bool](Get-BrowserExecutable -Browser $place.Browser)
            Running      = [bool]@(Get-Process -Name $program -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -eq $session }).Count
            Profiles     = @($found | Where-Object { $_.Browser -eq $place.Browser } | ForEach-Object {
                $reading = Get-BrowserProfileReading -Found $_
                $reading | Add-Member -NotePropertyName Size -NotePropertyValue @($sizes[$_.Folder]) -PassThru
            })
            CrashReports = Get-BrowserCrashReport -Folder $reports
        }
    }

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.Browser'
        GatheredAt     = Get-Date
        MeasureSeconds = $seconds
        Browsers       = @($browsers)
        Policies       = @($policies.Policies)
        PoliciesRead   = $policies.Read
        PoliciesReason = $policies.Reason
        # The crashes the Stability Check read from the event log, copied unfiltered: which
        # are a browser's or WebView2's is the Judge's to say. Absent where it did not run.
        StabilityObserved  = $null -ne $stability
        ObservedCrashes    = Get-DataCollection $stability 'Crashes'
        CrashWindowDays    = Get-DataProperty $stability 'Days'
        WebView2           = Get-BrowserWebView2
    } | Add-NetworkProxyObserved -Network $network
}

function Get-BrowserExit {
    <#
    .SYNOPSIS
        How a profile last closed, as the Judge takes it: Normal, Crashed, SessionEnded,
        Open, Unknown, Unread, or '' for a browser that keeps no last exit. Pure.
    .DESCRIPTION
        Open is a crash recorded while the browser runs in the Run's Session: it writes
        "crashed" when it opens a profile and "normal" when it closes it, so the profile
        that is open says crashed and is not. Which profile is open, it does not say.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Browser, [AllowNull()]$Row)

    if ("$(Get-DataProperty $Browser 'Engine')" -ne 'Chromium') { return '' }
    if ("$(Get-DataProperty $Row 'Problem')") { return 'Unread' }
    $exit = "$(Get-DataProperty $Row 'LastExit')"
    if ($exit -eq 'Crashed' -and [bool](Get-DataProperty $Browser 'Running')) { return 'Open' }
    if ($exit -in 'Normal', 'Crashed', 'SessionEnded') { return $exit }
    'Unknown'
}

function Get-BrowserExitText {
    # How a profile last closed, in words; '' for a browser that keeps no last exit. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Browser, [AllowNull()]$Row)

    switch (Get-BrowserExit -Browser $Browser -Row $Row) {
        ''             { return '' }
        'Unread'       { return (Get-Text 'Value.Cell.NotRead') }
        'Open'         { return (Get-Text 'Value.Browser.Exit.Open') }
        'Normal'       { return (Get-Text 'Value.Browser.Exit.Normal') }
        'Crashed'      { return (Get-Text 'Value.Browser.Exit.Crashed') }
        'SessionEnded' { return (Get-Text 'Value.Browser.Exit.SessionEnded') }
        default        { return (Get-Text 'Value.Browser.Exit.Unknown') }
    }
}

function Get-BrowserProblemText {
    # Why a profile could not be read, in words. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Row)

    switch ("$(Get-DataProperty $Row 'Problem')") {
        ''        { return '' }
        'Missing' { return (Get-Text 'Value.Browser.Problem.Missing') }
        'Empty'   { return (Get-Text 'Value.Browser.Problem.Empty') }
        'Damaged' { return (Get-Text 'Value.Browser.Problem.Damaged') }
        'ListDamaged'    { return (Get-Text 'Value.Browser.Problem.ListDamaged') }
        'ListUnreadable' { return (Get-Text 'Value.Browser.Problem.ListUnreadable' "$(Get-DataProperty $Row 'Reason')") }
        default   { return (Get-Text 'Value.Browser.Problem.Unreadable' "$(Get-DataProperty $Row 'Reason')") }
    }
}

function New-BrowserClosedFinding {
    <#
    .SYNOPSIS
        How each profile of a Chromium browser last closed.
    .DESCRIPTION
        A crash is said at INFO: one unclean exit is a forced shutdown or a power cut as
        often as a fault, and a browser that crashes again and again shows in the crash
        reports it holds. A profile that could not be read is said at INFO too, with why:
        a gap must not read like a clean result.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowNull()]$Browser)

    $name     = "$(Get-DataProperty $Browser 'Browser')"
    $profiles = @((Get-DataCollection $Browser 'Profiles'))
    $items = @($profiles | ForEach-Object {
        $said = Get-BrowserExitText -Browser $Browser -Row $_
        if ((Get-BrowserExit -Browser $Browser -Row $_) -eq 'Unread') { $said = Get-Text 'Value.Browser.Unread' (Get-BrowserProblemText -Row $_) }
        Get-Text 'Value.Browser.ProfileItem' "$(Get-DataProperty $_ 'Profile')" $said
    })
    $crashed = @($profiles | Where-Object { (Get-BrowserExit -Browser $Browser -Row $_) -eq 'Crashed' })
    $unread  = @($profiles | Where-Object { (Get-BrowserExit -Browser $Browser -Row $_) -eq 'Unread' })

    $check     = Get-Text 'Check.Browser.Closed' $name
    $value     = $items -join '; '
    $reference = @(New-Reference -Section 'Title.Browser.Profiles' -Argument $name)
    if (-not ($crashed.Count + $unread.Count)) {
        return New-Finding -Category Browser -Check $check -Severity OK -Value $value -Subject $name -Reference $reference
    }

    # A crash at the last exit beside crashes Windows logged of the same browser is no
    # power cut: a WARN, which leads to the Stability Check's table of the crashes and is
    # not judged a second time here.
    if ($crashed.Count) {
        $program = $script:BrowserProgram[$name]
        if (-not [bool](Get-DataProperty $Data 'StabilityObserved')) { $value = '{0} | {1}' -f $value, (Get-Text 'Value.Browser.CrashesNotCompared') }
        else {
            $logged = @(Select-BrowserLoggedCrash -Data $Data -Program $program)
            if ($logged.Count) {
                $value = '{0} | {1}' -f $value, (Get-Text 'Value.Browser.CrashesLogged' $logged.Count $program (Get-DataProperty $Data 'CrashWindowDays') $logged[0].Time)
                $reference += New-Reference -Section 'Title.Stability.ApplicationCrashesByApp'
                $hint = @(Get-Text 'Hint.Browser.CrashedAndLogged')
                if ($unread.Count) { $hint += Get-Text 'Hint.Browser.Unread' }
                return New-Finding -Category Browser -Check $check -Severity WARN -Value $value -Subject $name -Reference $reference -LastSeen $logged[0].Time `
                    -Meaning (Get-Text 'Meaning.Browser.CrashedAndLogged') -Hint ($hint -join ' | ')
            }
        }
    }
    $meaning = @(); $hint = @()
    if ($crashed.Count) { $meaning += Get-Text 'Meaning.Browser.Crashed'; $hint += Get-Text 'Hint.Browser.Crashed' }
    if ($unread.Count)  { $hint += Get-Text 'Hint.Browser.Unread' }
    New-Finding -Category Browser -Check $check -Severity INFO -Value $value -Subject $name -Reference $reference `
        -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ')
}

function New-BrowserCrashReportFinding {
    <#
    .SYNOPSIS
        The crash reports a browser holds: how many of the period, and the newest.
    .DESCRIPTION
        Each report is a crash, whether Windows logged it or not. One or a few are said at
        INFO; more than CrashReportWarnAbove in the period is a browser that crashes often,
        and a WARN. Reports older than the period are counted apart and are no matter.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Browser, [AllowNull()]$AsOf, [double]$Days, [double]$WarnAbove)

    $name   = "$(Get-DataProperty $Browser 'Browser')"
    $check  = Get-Text 'Check.Browser.CrashReports' $name
    $held   = Get-DataProperty $Browser 'CrashReports'
    if ($null -eq $held -or -not [bool](Get-DataProperty $held 'Read')) {
        return New-Finding -Category Browser -Check $check -Severity INFO -Subject $name `
            -Value (Get-Text 'Value.Browser.Unread' "$(Get-DataProperty $held 'Reason')") -Hint (Get-Text 'Hint.Browser.CrashReportsUnread')
    }

    $times = @((Get-DataCollection $held 'Times') | ForEach-Object { ConvertTo-DataTime $_ } | Where-Object { $_ } | Sort-Object -Descending)
    if (-not $times.Count) {
        return New-Finding -Category Browser -Check $check -Severity OK -Subject $name -Value (Get-Text 'Value.Browser.CrashReportsNone')
    }
    $since  = $null
    $asOfAt = ConvertTo-DataTime $AsOf
    if ($asOfAt) { $since = $asOfAt.AddDays(-$Days) }
    $recent = @($times | Where-Object { $null -eq $since -or $_ -ge $since })
    if (-not $recent.Count) {
        return New-Finding -Category Browser -Check $check -Severity OK -Subject $name `
            -Value (Get-Text 'Value.Browser.CrashReportsOlder' $times.Count $Days $times[0])
    }

    $value = Get-Text 'Value.Browser.CrashReports' $recent.Count $Days $recent[0]
    if ($recent.Count -gt $WarnAbove) {
        return New-Finding -Category Browser -Check $check -Severity WARN -Subject $name -Value $value -LastSeen $recent[0] `
            -Meaning (Get-Text 'Meaning.Browser.CrashReports') -Hint (Get-Text 'Hint.Browser.CrashReportsMany')
    }
    New-Finding -Category Browser -Check $check -Severity INFO -Subject $name -Value $value -LastSeen $recent[0] `
        -Meaning (Get-Text 'Meaning.Browser.CrashReports') -Hint (Get-Text 'Hint.Browser.CrashReports')
}

# Where a Chromium extension came from, by the number the browser writes as its "location".
# 5 and 10 are parts of the browser itself; 7 and 9 a policy put there; 4 is a folder
# loaded in developer mode and 8 one named on the command line; 2, 3 and 6 another program
# put there, by a file, by the registry or by a download it named. 1 is the store.
$script:BrowserChromiumOrigin = @{
    1 = 'Store'; 2 = 'Program'; 3 = 'Program'; 4 = 'Developer'; 5 = 'BuiltIn'; 6 = 'Program'
    7 = 'Policy'; 8 = 'Developer'; 9 = 'Policy'; 10 = 'BuiltIn'
}

# The same for Firefox, by the place it writes an extension came from.
$script:BrowserFirefoxOrigin = @{
    'app-profile' = 'Store'; 'temporary' = 'Developer'
    'app-builtin' = 'BuiltIn'; 'app-builtin-addons' = 'BuiltIn'; 'app-system-defaults' = 'BuiltIn'; 'app-system-addons' = 'BuiltIn'
    'app-global' = 'Program'; 'app-system-share' = 'Program'; 'app-system-local' = 'Program'; 'app-system-user' = 'Program'
    'winreg-app-user' = 'Program'; 'winreg-app-global' = 'Program'
}

function Get-BrowserExtensionOrigin {
    <#
    .SYNOPSIS
        How an extension came into the browser: Store, Policy, Developer, Program,
        BuiltIn, or Unknown for a place this module has no word for. Pure.
    .DESCRIPTION
        BuiltIn is what the browser brings itself, also what it installed by default from
        a list of its own (Chrome's "Google Docs Offline" comes as a download from another
        program, and was put there by Chrome). A Firefox extension a policy installed lies
        where the User's own lie, and says so in how it was installed.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Browser, [AllowNull()]$Extension)

    $location = Get-DataProperty $Extension 'Location'
    if ("$(Get-DataProperty $Browser 'Engine')" -eq 'Firefox') {
        $source = "$(Get-DataProperty $Extension 'Source')"
        if ($source -eq 'enterprise-policy') { return 'Policy' }
        # Mozilla's own: a system add-on it updated, or one its experiments (Nimbus) put into
        # the profile. Observed 2026-10-14: the new tab page, in the profile, from Nimbus.
        if ($source -eq 'system-addon' -or $source -like 'nimbus*') { return 'BuiltIn' }
        $origin = $script:BrowserFirefoxOrigin["$location"]
    }
    else {
        if ([bool](Get-DataProperty $Extension 'ByDefault')) { return 'BuiltIn' }
        $number = ConvertTo-Number $location
        $origin = $null
        if ($null -ne $number) { $origin = $script:BrowserChromiumOrigin[[int]$number] }
    }
    if ($origin) { return $origin }
    'Unknown'
}

function Get-BrowserOriginText {
    # How an extension came, in words. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Browser, [AllowNull()]$Extension)

    switch (Get-BrowserExtensionOrigin -Browser $Browser -Extension $Extension) {
        'Store'     { return (Get-Text 'Value.Browser.Origin.Store') }
        'Policy'    { return (Get-Text 'Value.Browser.Origin.Policy') }
        'Developer' { return (Get-Text 'Value.Browser.Origin.Developer') }
        'Program'   { return (Get-Text 'Value.Browser.Origin.Program') }
        'BuiltIn'   { return (Get-Text 'Value.Browser.Origin.BuiltIn') }
        default     { return (Get-Text 'Value.Browser.Origin.Unknown' "$(Get-DataProperty $Extension 'Location')") }
    }
}

function Get-BrowserChosenExtension {
    <#
    .SYNOPSIS
        The extensions of a profile that somebody chose: all but what the browser brings,
        and of Firefox's add-ons only extensions and themes. Pure.
    .DESCRIPTION
        Firefox lists its language packs and dictionaries among its add-ons. They change
        nothing a page does, and counted they would make a profile with a German dictionary
        a profile with an extension. An add-on that names no type is counted.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Browser, [AllowNull()]$Row)

    $firefox = "$(Get-DataProperty $Browser 'Engine')" -eq 'Firefox'
    @((Get-DataCollection $Row 'Extensions') | Where-Object {
        $type = "$(Get-DataProperty $_ 'Type')".Trim()
        (Get-BrowserExtensionOrigin -Browser $Browser -Extension $_) -ne 'BuiltIn' -and
            -not ($firefox -and $type -and $type -notin 'extension', 'theme')
    })
}

function Get-BrowserExtensionName {
    # What an extension is called in the Report: its name, or its id where it has none. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Extension)

    $name = "$(Get-DataProperty $Extension 'Name')".Trim()
    if ($name) { return $name }
    "$(Get-DataProperty $Extension 'Id')"
}

function New-BrowserExtensionFinding {
    <#
    .SYNOPSIS
        How many extensions somebody installed in each profile of a browser.
    .DESCRIPTION
        Built-in extensions are not counted: Edge brings a dozen and would always be "full".
        Any at all is INFO, so that "slow since last week" is held against the list; more
        than ExtensionWarnAbove in one profile is a WARN.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowNull()]$Browser, [double]$WarnAbove)

    $name     = "$(Get-DataProperty $Browser 'Browser')"
    $profiles = @((Get-DataCollection $Browser 'Profiles'))
    $most = 0
    $items = @($profiles | ForEach-Object {
        if ("$(Get-DataProperty $_ 'Problem')") { return (Get-Text 'Value.Browser.ProfileItem' "$(Get-DataProperty $_ 'Profile')" (Get-Text 'Value.Browser.Unread' (Get-BrowserProblemText -Row $_))) }
        $chosen = @(Get-BrowserChosenExtension -Browser $Browser -Row $_)
        if ($chosen.Count -gt $most) { $most = $chosen.Count }
        if (-not $chosen.Count) { return (Get-Text 'Value.Browser.ProfileItem' "$(Get-DataProperty $_ 'Profile')" (Get-Text 'Value.Browser.ExtensionsNone')) }
        $said = @("$($chosen.Count)")
        $off    = @($chosen | Where-Object { (Get-DataProperty $_ 'Enabled') -eq $false }).Count
        $policy = @($chosen | Where-Object { (Get-BrowserExtensionOrigin -Browser $Browser -Extension $_) -eq 'Policy' }).Count
        if ($off)    { $said += Get-Text 'Value.Browser.ExtensionsOff' $off }
        if ($policy) { $said += Get-Text 'Value.Browser.ExtensionsByPolicy' $policy }
        Get-Text 'Value.Browser.ProfileItem' "$(Get-DataProperty $_ 'Profile')" ($said -join ', ')
    })

    $check     = Get-Text 'Check.Browser.Extensions' $name
    $value     = $items -join '; '
    $reference = @()
    if ($most) { $reference = @(New-Reference -Section 'Title.Browser.Extensions' -Argument $name) }

    # What a policy forces and blocks, so that a missing extension is explained by the
    # policy that blocks it. Counted, and the policies themselves are in their Section.
    $ruled = Get-BrowserExtensionPolicyCount -Data $Data -Browser $name
    $parts = @()
    if ($ruled.Forced)     { $parts += Get-Text 'Value.Browser.ExtensionsForced' $ruled.Forced }
    if ($ruled.BlockedAll) { $parts += Get-Text 'Value.Browser.ExtensionsBlockedAll' }
    elseif ($ruled.Blocked) { $parts += Get-Text 'Value.Browser.ExtensionsBlocked' $ruled.Blocked }
    if ($parts.Count) {
        $value = '{0} | {1}' -f $value, (Get-Text 'Value.Browser.ExtensionPolicy' ($parts -join ', '))
        $reference += New-Reference -Section 'Title.Browser.Policies' -Argument $name
    }
    # A profile whose list could not be read is a gap, and never reads as a profile with
    # nothing in it: INFO at least, with its Hint beside the others.
    $unread = [bool]@($profiles | Where-Object { "$(Get-DataProperty $_ 'Problem')" }).Count
    $hint = @()
    if ($most -gt $WarnAbove) { $hint += Get-Text 'Hint.Browser.ExtensionsMany' }
    elseif ($most)            { $hint += Get-Text 'Hint.Browser.Extensions' }
    if ($unread)              { $hint += Get-Text 'Hint.Browser.Unread' }
    if ($most -gt $WarnAbove) {
        return New-Finding -Category Browser -Check $check -Severity WARN -Value $value -Subject $name -Reference $reference `
            -Meaning (Get-Text 'Meaning.Browser.ExtensionsMany') -Hint ($hint -join ' | ')
    }
    if ($most -or $unread) {
        return New-Finding -Category Browser -Check $check -Severity INFO -Value $value -Subject $name -Reference $reference -Hint ($hint -join ' | ')
    }
    New-Finding -Category Browser -Check $check -Severity OK -Value $value -Subject $name -Reference $reference
}

function New-BrowserUnusualExtensionFinding {
    <#
    .SYNOPSIS
        The extensions that came from no store and no policy: loaded in developer mode or
        put there by another program. Nothing where there are none.
    .DESCRIPTION
        A WARN: nobody checked them, and they are the first suspects for a browser that
        changed by itself - a search engine replaced, advertising on every page.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Browser)

    $name  = "$(Get-DataProperty $Browser 'Browser')"
    $items = @(foreach ($row in @((Get-DataCollection $Browser 'Profiles'))) {
        foreach ($one in @(Get-BrowserChosenExtension -Browser $Browser -Row $row)) {
            if ((Get-BrowserExtensionOrigin -Browser $Browser -Extension $one) -notin 'Developer', 'Program') { continue }
            Get-Text 'Value.Browser.UnusualItem' (Get-BrowserExtensionName -Extension $one) "$(Get-DataProperty $row 'Profile')" (Get-BrowserOriginText -Browser $Browser -Extension $one)
        }
    })
    if (-not $items.Count) { return }
    New-Finding -Category Browser -Check (Get-Text 'Check.Browser.ExtensionsUnusual' $name) -Severity WARN -Subject $name -Value ($items -join '; ') `
        -Meaning (Get-Text 'Meaning.Browser.ExtensionsUnusual') -Hint (Get-Text 'Hint.Browser.ExtensionsUnusual') `
        -Reference @(New-Reference -Section 'Title.Browser.Extensions' -Argument $name)
}

function Get-BrowserProfileSize {
    <#
    .SYNOPSIS
        A profile's size as the Judge reads it: the bytes of the profile with its cache,
        the bytes of the cache, and how the measurement went. $null where it was not
        measured at all. Pure.
    .DESCRIPTION
        State is Complete; CutShort where a part was not counted to its end before the time
        ran out, and the numbers are a lower bound; or Unreadable where nothing of it could
        be counted. A cache folder that is not there is a cache of nothing.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Row)

    $parts = @((Get-DataCollection $Row 'Size'))
    if (-not $parts.Count) { return $null }
    $counted = @($parts | Where-Object { "$(Get-DataProperty $_ 'State')" -in 'Complete', 'CutShort' })
    $sum = { param($List) [long](@($List | ForEach-Object { ConvertTo-Number (Get-DataProperty $_ 'Bytes') } | Where-Object { $null -ne $_ }) | Measure-Object -Sum).Sum }
    $profileParts = @($parts | Where-Object { "$(Get-DataProperty $_ 'Part')" -eq 'Profile' })
    $state = 'Complete'
    if (@($parts | Where-Object { "$(Get-DataProperty $_ 'State')" -in 'CutShort', 'NotMeasured' }).Count) { $state = 'CutShort' }
    if (-not @($profileParts | Where-Object { "$(Get-DataProperty $_ 'State')" -in 'Complete', 'CutShort' }).Count -and $state -ne 'CutShort') { $state = 'Unreadable' }
    [pscustomobject]@{
        State = $state
        Bytes = & $sum $counted
        Cache = & $sum @($counted | Where-Object { "$(Get-DataProperty $_ 'Part')" -eq 'Cache' })
    }
}

function Get-BrowserSizeText {
    # A size in a cell or a sentence: at least, where the measurement was cut short. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Bytes, [AllowEmptyString()][string]$State)

    Format-DataSize -Bytes $Bytes -AtLeast:($State -eq 'CutShort')
}

function New-BrowserSizeFinding {
    <#
    .SYNOPSIS
        How large each profile of a browser is, with its cache.
    .DESCRIPTION
        Above ProfileWarnAboveGB a WARN: a large profile slows the browser at every start
        and fills a roaming User Profile. The Hint names the cache apart, because emptying
        the cache is the obvious step and does nothing for a profile large by its own data.
        A measurement cut short or not possible is INFO: a gap is no small profile.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Browser, [double]$WarnAboveGB, [AllowNull()]$Seconds)

    $name     = "$(Get-DataProperty $Browser 'Browser')"
    $measured = @(foreach ($row in @((Get-DataCollection $Browser 'Profiles'))) {
        $size = Get-BrowserProfileSize -Row $row
        if ($size) { [pscustomobject]@{ Profile = "$(Get-DataProperty $row 'Profile')"; Size = $size } }
    })
    if (-not $measured.Count) { return }

    $items = @($measured | ForEach-Object {
        if ($_.Size.State -eq 'Unreadable') { return (Get-Text 'Value.Browser.ProfileItem' $_.Profile (Get-Text 'Value.Cell.Unreadable')) }
        $said = Get-Text 'Value.Browser.Size' (Get-BrowserSizeText -Bytes $_.Size.Bytes -State $_.Size.State) (Get-BrowserSizeText -Bytes $_.Size.Cache -State $_.Size.State)
        Get-Text 'Value.Browser.ProfileItem' $_.Profile $said
    })
    $cut = @($measured | Where-Object { $_.Size.State -eq 'CutShort' })
    if ($cut.Count) { $items += Get-Text 'Value.Browser.SizeCutShort' $Seconds }

    $check     = Get-Text 'Check.Browser.Size' $name
    $value     = $items -join '; '
    $reference = @(New-Reference -Section 'Title.Browser.Profiles' -Argument $name)
    $large     = @($measured | Where-Object { $_.Size.State -ne 'Unreadable' -and $_.Size.Bytes -gt ($WarnAboveGB * 1GB) } | Sort-Object { $_.Size.Bytes } -Descending)
    if ($large.Count) {
        return New-Finding -Category Browser -Check $check -Severity WARN -Value $value -Subject $name -Reference $reference `
            -Meaning (Get-Text 'Meaning.Browser.SizeLarge') -Hint (Get-Text 'Hint.Browser.SizeLarge' (Get-BrowserSizeText -Bytes $large[0].Size.Cache -State $large[0].Size.State))
    }
    if (@($measured | Where-Object { $_.Size.State -ne 'Complete' }).Count) {
        return New-Finding -Category Browser -Check $check -Severity INFO -Value $value -Subject $name -Reference $reference -Hint (Get-Text 'Hint.Browser.SizeCutShort')
    }
    New-Finding -Category Browser -Check $check -Severity OK -Value $value -Subject $name -Reference $reference
}

function Get-BrowserPolicyMeaning {
    # What a policy is for, in words, where Gutcheck knows it; '' where it does not. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Name)

    switch -Exact ($Name) {
        'ProxyMode'                  { return (Get-Text 'Value.Browser.Policy.ProxyMode') }
        'Proxy\Mode'                 { return (Get-Text 'Value.Browser.Policy.ProxyMode') }
        'ProxyServer'                { return (Get-Text 'Value.Browser.Policy.ProxyServer') }
        'Proxy\HTTPProxy'            { return (Get-Text 'Value.Browser.Policy.ProxyServer') }
        'ProxyPacUrl'                { return (Get-Text 'Value.Browser.Policy.ProxyPacUrl') }
        'Proxy\AutoConfigURL'        { return (Get-Text 'Value.Browser.Policy.ProxyPacUrl') }
        'ProxyBypassList'            { return (Get-Text 'Value.Browser.Policy.ProxyBypassList') }
        'ProxySettings'              { return (Get-Text 'Value.Browser.Policy.ProxySettings') }
        'Proxy'                      { return (Get-Text 'Value.Browser.Policy.ProxySettings') }
        'ExtensionInstallForcelist'  { return (Get-Text 'Value.Browser.Policy.ExtensionInstallForcelist') }
        'ExtensionInstallBlocklist'  { return (Get-Text 'Value.Browser.Policy.ExtensionInstallBlocklist') }
        'ExtensionInstallAllowlist'  { return (Get-Text 'Value.Browser.Policy.ExtensionInstallAllowlist') }
        'ExtensionSettings'          { return (Get-Text 'Value.Browser.Policy.ExtensionSettings') }
        'HomepageLocation'           { return (Get-Text 'Value.Browser.Policy.HomepageLocation') }
        'RestoreOnStartup'           { return (Get-Text 'Value.Browser.Policy.RestoreOnStartup') }
        'IncognitoModeAvailability'  { return (Get-Text 'Value.Browser.Policy.IncognitoModeAvailability') }
        'InPrivateModeAvailability'  { return (Get-Text 'Value.Browser.Policy.InPrivateModeAvailability') }
        'BrowserSignin'              { return (Get-Text 'Value.Browser.Policy.BrowserSignin') }
        'SyncDisabled'               { return (Get-Text 'Value.Browser.Policy.SyncDisabled') }
        'PasswordManagerEnabled'     { return (Get-Text 'Value.Browser.Policy.PasswordManagerEnabled') }
        'SmartScreenEnabled'         { return (Get-Text 'Value.Browser.Policy.SmartScreenEnabled') }
        'DeveloperToolsAvailability' { return (Get-Text 'Value.Browser.Policy.DeveloperToolsAvailability') }
        'DisableAppUpdate'           { return (Get-Text 'Value.Browser.Policy.DisableAppUpdate') }
    }
    ''
}

function Get-BrowserProxyMode {
    # A proxy mode as Chromium or Firefox writes it, as one of Direct, System, Detect,
    # Script, Server; '' for a word neither writes. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Value)

    switch -Exact ("$Value".Trim()) {
        'direct'         { return 'Direct' }
        'none'           { return 'Direct' }
        'system'         { return 'System' }
        'auto_detect'    { return 'Detect' }
        'autoDetect'     { return 'Detect' }
        'pac_script'     { return 'Script' }
        'autoConfig'     { return 'Script' }
        'fixed_servers'  { return 'Server' }
        'manual'         { return 'Server' }
    }
    ''
}

function Get-BrowserProxyModeText {
    # A proxy mode in words. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Mode)

    switch ($Mode) {
        'Direct' { return (Get-Text 'Value.Browser.ProxyMode.Direct') }
        'System' { return (Get-Text 'Value.Browser.ProxyMode.System') }
        'Detect' { return (Get-Text 'Value.Browser.ProxyMode.Detect') }
        'Script' { return (Get-Text 'Value.Browser.ProxyMode.Script') }
        'Server' { return (Get-Text 'Value.Browser.ProxyMode.Server') }
    }
    ''
}

function Get-BrowserPolicyValueText {
    <#
    .SYNOPSIS
        A policy's value as the Report shows it: a list one entry after the other, a value
        Gutcheck knows with what it means behind it, anything else as it is. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Policy)

    $name  = "$(Get-DataProperty $Policy 'Name')"
    $value = Get-DataProperty $Policy 'Value'
    if ($value -is [array]) { return (@($value | ForEach-Object { "$_" }) -join '; ') }
    $text = "$value".Trim()
    $meaning = ''
    switch -Exact ($name) {
        { $_ -in 'ProxyMode', 'Proxy\Mode' } { $meaning = Get-BrowserProxyModeText -Mode (Get-BrowserProxyMode -Value $text) }
        'RestoreOnStartup' {
            switch ($text) {
                '1' { $meaning = Get-Text 'Value.Browser.Policy.RestoreOnStartup.1' }
                '4' { $meaning = Get-Text 'Value.Browser.Policy.RestoreOnStartup.4' }
                '5' { $meaning = Get-Text 'Value.Browser.Policy.RestoreOnStartup.5' }
                '6' { $meaning = Get-Text 'Value.Browser.Policy.RestoreOnStartup.6' }
            }
        }
        { $_ -in 'IncognitoModeAvailability', 'InPrivateModeAvailability' } {
            switch ($text) {
                '0' { $meaning = Get-Text 'Value.Browser.Policy.Allowed' }
                '1' { $meaning = Get-Text 'Value.Browser.Policy.Blocked' }
                '2' { $meaning = Get-Text 'Value.Browser.Policy.Forced' }
            }
        }
        'BrowserSignin' {
            switch ($text) {
                '0' { $meaning = Get-Text 'Value.Browser.Policy.Blocked' }
                '1' { $meaning = Get-Text 'Value.Browser.Policy.Allowed' }
                '2' { $meaning = Get-Text 'Value.Browser.Policy.Forced' }
            }
        }
        'DeveloperToolsAvailability' {
            switch ($text) {
                '0' { $meaning = Get-Text 'Value.Browser.Policy.DeveloperTools.0' }
                '1' { $meaning = Get-Text 'Value.Browser.Policy.Allowed' }
                '2' { $meaning = Get-Text 'Value.Browser.Policy.Blocked' }
            }
        }
        { $_ -in 'SyncDisabled', 'PasswordManagerEnabled', 'SmartScreenEnabled', 'DisableAppUpdate' } {
            if ($text -in '1', 'true')  { $meaning = Get-Text 'Value.Browser.Policy.Yes' }
            if ($text -in '0', 'false') { $meaning = Get-Text 'Value.Browser.Policy.No' }
        }
    }
    Format-CodeWithMeaning -Code $text -Meaning $meaning
}

function Get-BrowserPolicyRow {
    # The policies of one browser, the machine's before the User's: the browser takes the
    # machine's where both set one. Pure.
    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowEmptyString()][string]$Browser)

    $mine = @((Get-DataCollection $Data 'Policies') | Where-Object { "$(Get-DataProperty $_ 'Browser')" -eq $Browser })
    @(@($mine | Where-Object { "$(Get-DataProperty $_ 'Scope')" -eq 'Machine' }) + @($mine | Where-Object { "$(Get-DataProperty $_ 'Scope')" -ne 'Machine' }))
}

function Get-BrowserPolicyValue {
    # The value of one policy of a browser as the browser takes it, $null where none is set. Pure.
    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowEmptyString()][string]$Browser, [Parameter(Mandatory)][string]$Name)

    $row = @(Get-BrowserPolicyRow -Data $Data -Browser $Browser | Where-Object { "$(Get-DataProperty $_ 'Name')" -eq $Name })[0]
    if ($null -eq $row) { return $null }
    Get-DataProperty $row 'Value'
}

function ConvertFrom-BrowserPolicyJson {
    # A policy that holds JSON as an object, $null where it holds none. Pure.
    [CmdletBinding()]
    param([AllowNull()]$Value)

    if ($null -eq $Value -or $Value -is [array] -or -not "$Value".Trim()) { return $null }
    try { "$Value" | ConvertFrom-Json -ErrorAction Stop } catch { $null }
}

function Get-BrowserPolicyProxy {
    <#
    .SYNOPSIS
        The proxy a policy makes a browser use: Mode (Direct, System, Detect, Script or
        Server), Server and Script. $null where no policy sets one. Pure.
    .DESCRIPTION
        Chromium takes "ProxySettings" before the single policies; Firefox has one policy,
        "Proxy", as JSON in its policy file and as a key with values in the registry.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [AllowNull()]$Browser)

    $name = "$(Get-DataProperty $Browser 'Browser')"
    if ("$(Get-DataProperty $Browser 'Engine')" -eq 'Firefox') {
        $json = ConvertFrom-BrowserPolicyJson (Get-BrowserPolicyValue -Data $Data -Browser $name -Name 'Proxy')
        $mode   = "$(Get-BrowserPolicyValue -Data $Data -Browser $name -Name 'Proxy\Mode')"
        $server = "$(Get-BrowserPolicyValue -Data $Data -Browser $name -Name 'Proxy\HTTPProxy')"
        $script = "$(Get-BrowserPolicyValue -Data $Data -Browser $name -Name 'Proxy\AutoConfigURL')"
        if ($json) { $mode = "$(Get-DataProperty $json 'Mode')"; $server = "$(Get-DataProperty $json 'HTTPProxy')"; $script = "$(Get-DataProperty $json 'AutoConfigURL')" }
    }
    else {
        $json = ConvertFrom-BrowserPolicyJson (Get-BrowserPolicyValue -Data $Data -Browser $name -Name 'ProxySettings')
        $mode   = "$(Get-BrowserPolicyValue -Data $Data -Browser $name -Name 'ProxyMode')"
        $server = "$(Get-BrowserPolicyValue -Data $Data -Browser $name -Name 'ProxyServer')"
        $script = "$(Get-BrowserPolicyValue -Data $Data -Browser $name -Name 'ProxyPacUrl')"
        if ($json) { $mode = "$(Get-DataProperty $json 'ProxyMode')"; $server = "$(Get-DataProperty $json 'ProxyServer')"; $script = "$(Get-DataProperty $json 'ProxyPacUrl')" }
    }
    $said = Get-BrowserProxyMode -Value $mode
    if (-not $said) { return $null }
    [pscustomobject]@{ Mode = $said; Server = $server.Trim(); Script = $script.Trim() }
}

function Get-BrowserPolicyProxyText {
    # The proxy a policy sets, in words. Pure.
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Proxy)

    switch ($Proxy.Mode) {
        'Server' { return (Get-Text 'Value.Browser.ProxyPolicy.Server' $Proxy.Server) }
        'Script' { return (Get-Text 'Value.Browser.ProxyPolicy.Script' $Proxy.Script) }
    }
    Get-BrowserProxyModeText -Mode $Proxy.Mode
}

function Test-BrowserProxySame {
    <#
    .SYNOPSIS
        Whether the proxy a policy sets is the one the User has in Windows. Pure.
    .DESCRIPTION
        "System" is the User's by definition. A server is the same where the User has it
        switched on; a script where the User has the same one. Direct and automatic
        detection are the same where the User has neither a server nor a script.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)]$Proxy, [AllowNull()]$UserProxy)

    $userServer = "$(Get-UserProxyServer $UserProxy)".Trim()
    $userScript = "$(Get-DataProperty $UserProxy 'AutoConfigURL')".Trim()
    switch ($Proxy.Mode) {
        'System' { return $true }
        'Server' { return [bool]($userServer -and $userServer -eq $Proxy.Server) }
        'Script' { return [bool]($userScript -and $userScript -eq $Proxy.Script) }
    }
    -not $userServer -and -not $userScript
}

function New-BrowserProxyFinding {
    <#
    .SYNOPSIS
        The proxy a policy forces on a browser beside the User's and WinHTTP's, where a
        policy sets one. Nothing where none does.
    .DESCRIPTION
        INFO where it differs from the User's: no fault, but the answer to "the internet
        works in one browser and not in the other program". Where the Network Check did
        not run, the User's proxy is not known here, and that is said; it is not read a
        second time.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowNull()]$Browser)

    $proxy = Get-BrowserPolicyProxy -Data $Data -Browser $Browser
    if (-not $proxy) { return }
    $name      = "$(Get-DataProperty $Browser 'Browser')"
    $check     = Get-Text 'Check.Browser.ProxyPolicy' $name
    $policy    = Get-BrowserPolicyProxyText -Proxy $proxy
    $reference = @(New-Reference -Section 'Title.Browser.Policies' -Argument $name)
    if (-not [bool](Get-DataProperty $Data 'NetworkObserved')) {
        return New-Finding -Category Browser -Check $check -Severity INFO -Subject $name -Reference $reference `
            -Value ('{0} | {1}' -f (Get-Text 'Value.Browser.ProxyOnly' $policy), (Get-Text 'Value.Browser.ProxyNotCompared')) -Hint (Get-Text 'Hint.Browser.ProxyNotCompared')
    }

    $user    = Get-DataProperty $Data 'UserProxy'
    $winHttp = @(Get-WinHttpProxyView -Data $Data)[0]
    $value   = Get-Text 'Value.Browser.ProxyCompare' $policy (Get-UserProxyDescription $user) (Get-WinHttpProxyDescription $winHttp.Parsed)
    $reference += New-Reference -Section 'Title.Network.ProxySettings'
    if ($null -eq $user -or (Test-BrowserProxySame -Proxy $proxy -UserProxy $user)) {
        if ($null -eq $user) {
            return New-Finding -Category Browser -Check $check -Severity INFO -Subject $name -Reference $reference -Value $value -Hint (Get-Text 'Hint.Browser.ProxyNotCompared')
        }
        return New-Finding -Category Browser -Check $check -Severity OK -Subject $name -Reference $reference -Value $value
    }
    New-Finding -Category Browser -Check $check -Severity INFO -Subject $name -Reference $reference -Value $value `
        -Meaning (Get-Text 'Meaning.Browser.ProxyDiffers') -Hint (Get-Text 'Hint.Browser.ProxyDiffers')
}

function Get-BrowserExtensionPolicyCount {
    <#
    .SYNOPSIS
        How many extensions a policy forces on a browser and how many it blocks, and
        whether it blocks all it does not name. Pure.
    .DESCRIPTION
        Chromium has a list of each, and "ExtensionSettings" beside them; Firefox has only
        "ExtensionSettings". An installation mode of force_installed or normal_installed is
        forced, blocked is blocked; "*" blocked blocks every one not named.
 
        Firefox's language packs and dictionaries are not counted: the extension list leaves
        them out, and a count that included them would say ten were forced beside a list of
        none. In a policy only their ids show what they are, by the names Mozilla gives them.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [AllowEmptyString()][string]$Browser)

    $forced = New-Object System.Collections.Generic.HashSet[string]
    $blocked = New-Object System.Collections.Generic.HashSet[string]
    $all = $false
    foreach ($row in @(Get-BrowserPolicyRow -Data $Data -Browser $Browser)) {
        $value = Get-DataProperty $row 'Value'
        switch -Exact ("$(Get-DataProperty $row 'Name')") {
            'ExtensionInstallForcelist' { foreach ($one in @($value)) { $id = ("$one" -split ';')[0].Trim(); if ($id) { $null = $forced.Add($id) } } }
            'ExtensionInstallBlocklist' { foreach ($one in @($value)) { $id = "$one".Trim(); if ($id -eq '*') { $all = $true } elseif ($id) { $null = $blocked.Add($id) } } }
            'ExtensionSettings' {
                $json = ConvertFrom-BrowserPolicyJson $value
                if (-not $json) { break }
                foreach ($one in $json.PSObject.Properties) {
                    if ($one.Name -match '^langpack-.*@firefox\.mozilla\.org$|@dictionaries\.addons\.mozilla\.org$') { continue }
                    $mode = "$(Get-DataProperty $one.Value 'installation_mode')"
                    if ($one.Name -eq '*') { if ($mode -eq 'blocked') { $all = $true }; continue }
                    if ($mode -in 'force_installed', 'normal_installed') { $null = $forced.Add($one.Name) }
                    elseif ($mode -eq 'blocked') { $null = $blocked.Add($one.Name) }
                }
            }
        }
    }
    [pscustomobject]@{ Forced = $forced.Count; Blocked = $blocked.Count; BlockedAll = $all }
}

function New-BrowserPolicyFinding {
    <#
    .SYNOPSIS
        How many policies are set for each browser, for the machine and for the User, or
        why they could not be read.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowNull()][AllowEmptyCollection()]$Browser)

    if ($null -eq (Get-DataProperty $Data 'PoliciesRead')) { return }
    $policies = @((Get-DataCollection $Data 'Policies'))
    $names = @(@($Browser | ForEach-Object { "$(Get-DataProperty $_ 'Browser')" }) + @($policies | ForEach-Object { "$(Get-DataProperty $_ 'Browser')" }) | Select-Object -Unique)
    # One for each browser, about it: it stands under the browser with the rest of it.
    foreach ($name in $names) {
        $check = Get-Text 'Check.Browser.Policies' $name
        if (-not [bool](Get-DataProperty $Data 'PoliciesRead')) {
            New-Finding -Category Browser -Check $check -Severity INFO -Subject $name `
                -Value (Get-Text 'Value.Browser.Unread' "$(Get-DataProperty $Data 'PoliciesReason')") -Hint (Get-Text 'Hint.Browser.PoliciesUnread')
            continue
        }
        $mine = @($policies | Where-Object { "$(Get-DataProperty $_ 'Browser')" -eq $name })
        if (-not $mine.Count) {
            New-Finding -Category Browser -Check $check -Severity OK -Subject $name -Value (Get-Text 'Value.Browser.PoliciesNone')
            continue
        }
        $machine = @($mine | Where-Object { "$(Get-DataProperty $_ 'Scope')" -eq 'Machine' }).Count
        New-Finding -Category Browser -Check $check -Severity OK -Subject $name `
            -Value (Get-Text 'Value.Browser.PoliciesOf' $machine ($mine.Count - $machine)) `
            -Reference @(New-Reference -Section 'Title.Browser.Policies' -Argument $name)
    }
}

function Select-BrowserLoggedCrash {
    # The crashes of one program among those the Stability Check read, the newest first,
    # each with its time as a time. Pure.
    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowEmptyString()][string]$Program)

    @((Get-DataCollection $Data 'ObservedCrashes') | Where-Object { "$(Get-DataProperty $_ 'App')".Trim() -eq $Program } | ForEach-Object {
        $time = ConvertTo-DataTime (Get-DataProperty $_ 'Time')
        if ($time) { [pscustomobject]@{ Time = $time } }
    } | Sort-Object Time -Descending)
}

function New-BrowserWebView2Finding {
    <#
    .SYNOPSIS
        Whether the WebView2 runtime is installed, and which version.
    .DESCRIPTION
        A WARN where it is missing: new Outlook, Teams and the web add-ins of Office are
        drawn in it, and without it their windows stay white. Nothing where the Gatherer
        did not look.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    $runtime = Get-DataProperty $Data 'WebView2'
    if ($null -eq $runtime) { return }
    $check   = Get-Text 'Check.Browser.WebView2'
    $usable  = { param($Version) $v = "$Version".Trim(); [bool]($v -and $v -ne '0.0.0.0') }
    $machine = "$(Get-DataProperty $runtime 'Machine')".Trim()
    $user    = "$(Get-DataProperty $runtime 'User')".Trim()
    if (& $usable $machine) { return New-Finding -Category Browser -Check $check -Severity OK -Subject 'WebView2' -Value (Get-Text 'Value.Browser.WebView2.Machine' $machine) }
    if (& $usable $user)    { return New-Finding -Category Browser -Check $check -Severity OK -Subject 'WebView2' -Value (Get-Text 'Value.Browser.WebView2.User' $user) }
    New-Finding -Category Browser -Check $check -Severity WARN -Subject 'WebView2' -Value (Get-Text 'Value.Browser.WebView2.Missing') `
        -Meaning (Get-Text 'Meaning.Browser.WebView2Missing') -Hint (Get-Text 'Hint.Browser.WebView2Missing')
}

function New-BrowserWebView2CrashFinding {
    <#
    .SYNOPSIS
        How often WebView2 (msedgewebview2.exe) crashed in the period, from the crashes the
        Stability Check read, with the newest.
    .DESCRIPTION
        Counted apart from Edge and Outlook, so that a white window is not laid at their
        door. A few are INFO, more than WebView2CrashWarnAbove a WARN; both refer to the
        Stability Check's table of the crashes, where the exception and the module are.
        Where the Stability Check did not run, that is said instead of "none".
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [double]$WarnAbove)

    $observed = Get-DataProperty $Data 'StabilityObserved'
    if ($null -eq $observed) { return }
    $check = Get-Text 'Check.Browser.WebView2Crashes'
    if (-not [bool]$observed) {
        return New-Finding -Category Browser -Check $check -Severity INFO -Subject 'WebView2' `
            -Value (Get-Text 'Value.Browser.WebView2CrashesNotCounted') -Hint (Get-Text 'Hint.Browser.WebView2CrashesNotCounted')
    }
    $days    = Get-DataProperty $Data 'CrashWindowDays'
    $crashes = @(Select-BrowserLoggedCrash -Data $Data -Program 'msedgewebview2.exe')
    if (-not $crashes.Count) {
        return New-Finding -Category Browser -Check $check -Severity OK -Subject 'WebView2' -Value (Get-Text 'Value.Browser.WebView2CrashesNone' $days)
    }
    $value     = Get-Text 'Value.Browser.WebView2Crashes' $crashes.Count $days $crashes[0].Time
    $reference = @(New-Reference -Section 'Title.Stability.ApplicationCrashesByApp')
    $severity  = 'INFO'
    $hint      = Get-Text 'Hint.Browser.WebView2Crashes'
    if ($crashes.Count -gt $WarnAbove) { $severity = 'WARN'; $hint = Get-Text 'Hint.Browser.WebView2CrashesMany' }
    New-Finding -Category Browser -Check $check -Severity $severity -Subject 'WebView2' -Value $value -LastSeen $crashes[0].Time `
        -Reference $reference -Meaning (Get-Text 'Meaning.Browser.WebView2Crashes') -Hint $hint
}

function ConvertTo-BrowserFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    # The period the Stability Check reads, so that the crash reports and the crashes
    # Windows logged are counted over the same days. Read with Get-Parameter and a literal
    # name: that is what the published reference finds.
    # Where the Stability Check ran, the period it read; where it did not, the one it
    # would have read.
    $days = ConvertTo-Number (Get-Parameter $Parameters 'Days' $script:StabilityDefaultDays)
    if ($null -eq $days -or $days -le 0) { $days = $script:StabilityDefaultDays }
    $stabilityDays = ConvertTo-Number (Get-DataProperty $Data 'CrashWindowDays')
    if ([bool](Get-DataProperty $Data 'StabilityObserved') -and $null -ne $stabilityDays -and $stabilityDays -gt 0) { $days = $stabilityDays }
    # More than three crash reports in the period: a rule of thumb, no limit of a browser.
    # A browser that crashed once a week is unlucky; one that crashes every few days has
    # something wrong with it.
    $warnAbove = ConvertTo-Number (Get-Parameter $Parameters 'CrashReportWarnAbove' 3)
    if ($null -eq $warnAbove) { $warnAbove = 3 }
    # More than fifteen extensions somebody chose, in one profile: a rule of thumb. Each
    # runs on the pages it may read, and a browser loaded with them is slow for that.
    $extensionsAbove = ConvertTo-Number (Get-Parameter $Parameters 'ExtensionWarnAbove' 15)
    if ($null -eq $extensionsAbove) { $extensionsAbove = 15 }
    # More than 2 GB in one profile: a rule of thumb. A profile that size is slow to open
    # and, in a roaming User Profile, slow to sign in with.
    $sizeAboveGB = ConvertTo-Number (Get-Parameter $Parameters 'ProfileWarnAboveGB' 2)
    if ($null -eq $sizeAboveGB) { $sizeAboveGB = 2 }
    # More than three crashes of WebView2 in the period: the same rule of thumb as for the
    # crash reports of a browser.
    $webViewAbove = ConvertTo-Number (Get-Parameter $Parameters 'WebView2CrashWarnAbove' 3)
    if ($null -eq $webViewAbove) { $webViewAbove = 3 }

    $asOf = Get-DataProperty $Data 'GatheredAt'
    $seen = @((Get-DataCollection $Data 'Browsers') | Where-Object { [bool](Get-DataProperty $_ 'Installed') -or @((Get-DataCollection $_ 'Profiles')).Count })
    if (-not $seen.Count) {
        # About no one browser: it stands with all the applications, under no one of them.
        New-Finding -Category Apps -Check (Get-Text 'Check.Browser.None') -Severity INFO `
            -Value (Get-Text 'Value.Browser.None') -Hint (Get-Text 'Hint.Browser.None')
    }

    foreach ($browser in $seen) {
        $name = "$(Get-DataProperty $browser 'Browser')"
        $own = @(
            New-BrowserProxyFinding -Data $Data -Browser $browser
            if (-not @((Get-DataCollection $browser 'Profiles')).Count) {
                # Installed and never opened by this User: no gap in the reading, and nothing to do.
                New-Finding -Category Browser -Check (Get-Text 'Check.Browser.Unused' $name) -Severity OK -Subject $name -Value (Get-Text 'Value.Browser.Unused')
            }
            else {
                if ("$(Get-DataProperty $browser 'Engine')" -eq 'Chromium') { New-BrowserClosedFinding -Data $Data -Browser $browser }
                New-BrowserCrashReportFinding -Browser $browser -AsOf $asOf -Days $days -WarnAbove $warnAbove
                New-BrowserExtensionFinding -Data $Data -Browser $browser -WarnAbove $extensionsAbove
                New-BrowserUnusualExtensionFinding -Browser $browser
                New-BrowserSizeFinding -Browser $browser -WarnAboveGB $sizeAboveGB -Seconds (Get-DataProperty $Data 'MeasureSeconds')
            })
        # Under the browser, where its App Check stands: a Technician looks for Edge in one
        # place. The Run keeps an App the Judge set (Set-CheckOrigin).
        foreach ($finding in $own) { $finding.App = $name; $finding }
    }
    # The policies are each browser's, and WebView2 is a runtime of its own that new
    # Outlook and Teams draw in: each under its own name, as an App Check would stand.
    $machine = @(
        if ($seen.Count) { New-BrowserPolicyFinding -Data $Data -Browser $seen }
        New-BrowserWebView2Finding -Data $Data
        New-BrowserWebView2CrashFinding -Data $Data -WarnAbove $webViewAbove)
    foreach ($finding in $machine) { $finding.App = "$($finding.Subject)"; $finding }
}

function ConvertTo-BrowserSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    foreach ($browser in @((Get-DataCollection $Data 'Browsers'))) {
        $profiles = @((Get-DataCollection $browser 'Profiles'))
        if (-not $profiles.Count) { continue }
        $name = "$(Get-DataProperty $browser 'Browser')"
        # Both tables of a browser stand under it, beside its App Check's evidence.
        $profileSection = New-Section -Title (Get-Text 'Title.Browser.Profiles' $name) -Help (Get-Text 'Help.Browser.Profiles') -Row @(
            $profiles | ForEach-Object {
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Browser.Profile')]  = "$(Get-DataProperty $_ 'Profile')"
                $row[(Get-Text 'Column.Browser.LastExit')]   = Get-BrowserExitText -Browser $browser -Row $_
                $row[(Get-Text 'Column.Browser.Extensions')] = $(if ("$(Get-DataProperty $_ 'Problem')") { '' } else { "$(@(Get-BrowserChosenExtension -Browser $browser -Row $_).Count)" })
                $size = Get-BrowserProfileSize -Row $_
                $row[(Get-Text 'Column.Browser.Size')]       = $(if (-not $size) { '' } elseif ($size.State -eq 'Unreadable') { Get-Text 'Value.Cell.Unreadable' } else { Get-BrowserSizeText -Bytes $size.Bytes -State $size.State })
                $row[(Get-Text 'Column.Browser.Cache')]      = $(if (-not $size -or $size.State -eq 'Unreadable') { '' } else { Get-BrowserSizeText -Bytes $size.Cache -State $size.State })
                $row[(Get-Text 'Column.Note')]               = Get-BrowserProblemText -Row $_
                [pscustomobject]$row
            }
        )
        if ($profileSection) { $profileSection.App = $name; $profileSection }

        # The extensions somebody chose, a row each, in a table of their own: thirty rows
        # of them would bury the three rows of the profiles.
        $extensions = @(foreach ($one in $profiles) {
            foreach ($extension in @(Get-BrowserChosenExtension -Browser $browser -Row $one)) {
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Browser.Profile')]   = "$(Get-DataProperty $one 'Profile')"
                $row[(Get-Text 'Column.Browser.Extension')] = Get-BrowserExtensionName -Extension $extension
                $row[(Get-Text 'Column.Browser.Version')]   = "$(Get-DataProperty $extension 'Version')"
                $row[(Get-Text 'Column.Browser.Enabled')]   = Get-TriStateText (Get-DataProperty $extension 'Enabled')
                $row[(Get-Text 'Column.Browser.Origin')]    = Get-BrowserOriginText -Browser $browser -Extension $extension
                $row[(Get-Text 'Column.Browser.Id')]        = "$(Get-DataProperty $extension 'Id')"
                [pscustomobject]$row
            }
        })
        $extensionSection = New-Section -Title (Get-Text 'Title.Browser.Extensions' $name) -Help (Get-Text 'Help.Browser.Extensions') -Row $extensions
        if ($extensionSection) { $extensionSection.App = $name; $extensionSection }
    }

    # The policies of each browser, as they were read, under that browser.
    $policies = @((Get-DataCollection $Data 'Policies'))
    foreach ($name in @($policies | ForEach-Object { "$(Get-DataProperty $_ 'Browser')" } | Select-Object -Unique)) {
        $section = New-Section -Title (Get-Text 'Title.Browser.Policies' $name) -Help (Get-Text 'Help.Browser.Policies') -Row @(
            $policies | Where-Object { "$(Get-DataProperty $_ 'Browser')" -eq $name } | ForEach-Object {
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Browser.Scope')]         = $(if ("$(Get-DataProperty $_ 'Scope')" -eq 'Machine') { Get-Text 'Value.Browser.Scope.Machine' } else { Get-Text 'Value.Browser.Scope.User' })
                $row[(Get-Text 'Column.Browser.Policy')]        = "$(Get-DataProperty $_ 'Name')"
                $row[(Get-Text 'Column.Browser.PolicyMeaning')] = Get-BrowserPolicyMeaning -Name "$(Get-DataProperty $_ 'Name')"
                $row[(Get-Text 'Column.Browser.Value')]         = Get-BrowserPolicyValueText -Policy $_
                [pscustomobject]$row
            }
        )
        if ($section) { $section.App = $name; $section }
    }
}