MSIX.PsfBinaries.ps1

# =============================================================================
# PSF binary management
# -----------------------------------------------------------------------------
# Downloads, caches, and updates the binaries the module needs at runtime:
#
# - PSF (Tim Mangan's fork — TMurgent, more actively maintained than upstream)
# https://github.com/TimMangan/MSIX-PackageSupportFramework/releases
# - Sysinternals Process Monitor
# https://download.sysinternals.com/files/ProcessMonitor.zip
#
# Default install root is "$ToolsRoot\psf" / "$ToolsRoot\procmon" so existing
# Get-MsixToolsRoot logic continues to find them.
# =============================================================================

$script:TMurgentRepo  = 'TimMangan/MSIX-PackageSupportFramework'
$script:ProcmonZipUrl    = 'https://download.sysinternals.com/files/ProcessMonitor.zip'
$script:DebugViewZipUrl  = 'https://download.sysinternals.com/files/DebugView.zip'
$script:SdkToolsNuGet = 'Microsoft.Windows.SDK.BuildTools'   # publishes MakeAppx + signtool

# =============================================================================
# Authenticode verification of downloaded tool binaries (Wave 2a / H1)
# -----------------------------------------------------------------------------
# Trusted publisher Subject prefixes. Match against the leaf cert's Subject
# (case-insensitive, StartsWith). New entries become trusted across the entire
# toolchain.
#
# Source of truth: signers.json at the module root (loaded at import time by
# _MsixLoadTrustedPublishers below). Issue #19 moved the list out of code so
# security teams can add publishers without re-shipping the module. The file
# is intentionally unsigned today; a future change will Authenticode-sign it
# and require Get-AuthenticodeSignature -Status -eq 'Valid' before load.
# =============================================================================

function _MsixLoadTrustedPublishers {
    <#
    .SYNOPSIS
        Loads the trusted-publisher allowlist from signers.json. Internal.
    .DESCRIPTION
        Reads $PSScriptRoot\signers.json, validates each entry's
        subjectPrefix matches the standard X.509 form (starts with 'CN=',
        ends with ','), returns the deduplicated prefix list as [string[]]
        for the existing module contract, and records structured entries in
        $script:MsixTrustedPublisherEntries for verification.
 
        An entry MAY also carry an optional 'thumbprint' (SHA-1, the standard
        certificate thumbprint format, hex, case/space-insensitive). When
        present it is scoped to that allowlist entry: only files whose signer
        Subject matches the entry's subjectPrefix must match that entry's pin.
        Prefix-only entries keep their existing behaviour even when another
        publisher entry is pinned.
 
        Throws on:
          - missing signers.json (loud failure; toolchain installs cannot
            verify downloads, so we refuse to run rather than silently
            degrade to "no allowlist").
          - malformed JSON.
          - any entry whose subjectPrefix doesn't match ^CN=.+,$.
          - zero valid entries (same reasoning as missing file).
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([string]$Path)

    if (-not $Path) { $Path = Join-Path -Path $PSScriptRoot -ChildPath 'signers.json' }
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "MSIX trusted-publisher allowlist not found at '$Path'. The module cannot Authenticode-verify downloaded toolchain binaries without it. Re-install the module or restore signers.json from source."
    }

    try {
        $doc = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    } catch {
        throw "Failed to parse trusted-publisher allowlist at '$Path': $($_.Exception.Message)"
    }

    if (-not $doc.publishers) {
        throw "Trusted-publisher allowlist at '$Path' has no 'publishers' array."
    }

    $rx          = [regex]'^CN=.+,$'
    $prefixes    = [System.Collections.Generic.List[string]]::new()
    $thumbprints       = [System.Collections.Generic.List[string]]::new()
    $publisherEntries  = [System.Collections.Generic.List[object]]::new()
    foreach ($entry in $doc.publishers) {
        $p = [string]$entry.subjectPrefix
        if (-not $p) {
            throw "Trusted-publisher allowlist at '$Path' has an entry missing 'subjectPrefix'."
        }
        if (-not $rx.IsMatch($p)) {
            throw "Trusted-publisher allowlist at '$Path' has an entry whose subjectPrefix does not match the X.509 form 'CN=...,': '$p'."
        }
        if (-not $prefixes.Contains($p)) { $prefixes.Add($p) }

        # Optional thumbprint pin (normalised: strip spaces, upper-case hex).
        $entryThumbprints = [System.Collections.Generic.List[string]]::new()
        $tp = [string]$entry.thumbprint
        if ($tp) {
            $tpNorm = ($tp -replace '\s', '').ToUpperInvariant()
            if ($tpNorm -notmatch '^[0-9A-F]{40}$') {
                throw "Trusted-publisher allowlist at '$Path' has an entry with an invalid 'thumbprint' (expected 40 hex chars, SHA-1): '$tp'."
            }
            if (-not $thumbprints.Contains($tpNorm)) { $thumbprints.Add($tpNorm) }
            if (-not $entryThumbprints.Contains($tpNorm)) { $entryThumbprints.Add($tpNorm) }
        }
        $publisherEntries.Add([pscustomobject]@{
            SubjectPrefix = $p
            Thumbprints   = [string[]]$entryThumbprints
        })
    }

    if ($prefixes.Count -eq 0) {
        throw "Trusted-publisher allowlist at '$Path' contains zero valid entries."
    }

    # Side-channel structured entries for verification while preserving the
    # original string[] prefix contract expected by existing tests/callers.
    $script:MsixTrustedPublisherEntries = [object[]]$publisherEntries
    $script:MsixTrustedThumbprints = [string[]]$thumbprints
    return [string[]]$prefixes
}

$script:MsixTrustedThumbprints = @()
$script:MsixTrustedPublisherEntries = @()
$script:MsixTrustedPublishers = _MsixLoadTrustedPublishers
$script:MsixTrustedPublishersPath = Join-Path -Path $PSScriptRoot -ChildPath 'signers.json'

function _MsixVerifyAuthenticode {
    <#
    .SYNOPSIS
        Verifies a file is Authenticode-signed by a trusted publisher.
    .DESCRIPTION
        Reject the file unless:
          - signature Status is 'Valid'
          - signer cert is in signers.json's trusted-publisher allowlist
          - when the matching entry has a thumbprint pin, the signer cert
            thumbprint matches that entry's pin
        Throws on rejection; returns the signature object on success.
    .PARAMETER Path
        File to verify.
    .PARAMETER ToolName
        Logical tool name for error messages.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$Path,
        [Parameter(Mandatory)] [string]$ToolName
    )
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "Cannot verify Authenticode: file not found at $Path"
    }
    $sig = Get-AuthenticodeSignature -LiteralPath $Path
    if ($sig.Status -ne 'Valid') {
        throw "Authenticode verification FAILED for $ToolName at $Path. Status: $($sig.Status). $($sig.StatusMessage)"
    }
    $subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' }
    if (-not $subject) {
        throw "Authenticode verification FAILED for $ToolName at $Path`: no signer cert."
    }
    $matchedEntries = @()
    if ($script:MsixTrustedPublisherEntries -and $script:MsixTrustedPublisherEntries.Count -gt 0) {
        foreach ($entry in $script:MsixTrustedPublisherEntries) {
            if ($subject -like "$($entry.SubjectPrefix)*") { $matchedEntries += $entry }
        }
    } else {
        foreach ($prefix in $script:MsixTrustedPublishers) {
            if ($subject -like "$prefix*") {
                $matchedEntries += [pscustomobject]@{
                    SubjectPrefix = $prefix
                    Thumbprints   = @()
                }
            }
        }
    }
    if (-not $matchedEntries) {
        throw @"
Authenticode verification FAILED for $ToolName at $Path.
Signer is NOT in the trusted-publisher allowlist:
  Subject: $subject
  Thumbprint: $($sig.SignerCertificate.Thumbprint)
If you trust this publisher, add its CN prefix to signers.json and follow the trusted-publisher governance notes in CONTRIBUTING.md.
"@

    }
    # When the matching signers.json entry pins one or more thumbprints,
    # require an exact match. Pinning is opt-in per entry: unrelated prefix-only
    # entries keep their existing behaviour even if another publisher is pinned.
    $pinnedEntries = @($matchedEntries | Where-Object { $_.Thumbprints -and @($_.Thumbprints).Count -gt 0 })
    if ($pinnedEntries) {
        $actualTp = if ($sig.SignerCertificate) { ($sig.SignerCertificate.Thumbprint -replace '\s', '').ToUpperInvariant() } else { '' }
        $allowedThumbprints = @($pinnedEntries | ForEach-Object { $_.Thumbprints })
        if ($actualTp -notin $allowedThumbprints) {
            throw @"
Authenticode verification FAILED for $ToolName at $Path.
Signer cert passed the publisher-prefix check but its thumbprint is NOT pinned
on the matching signers.json entry:
  Subject: $subject
  Thumbprint: $actualTp
If you trust this certificate, add its thumbprint to the matching signers.json entry.
"@

        }
    }
    Write-MsixLog -Level Info -Message "Authenticode verified: $ToolName ($subject)"
    return $sig
}

function _MsixVerifyAuthenticodeFolder {
    <#
    .SYNOPSIS
        Verifies every .exe and .dll under a folder.
    .DESCRIPTION
        Calls _MsixVerifyAuthenticode against every .exe / .dll under the given
        folder (recursively). Throws on the first untrusted / unsigned binary.
        Logs a warning if no .exe/.dll were found at all (caller decides whether
        that's acceptable - e.g. for archives that bundle only data).
 
        NOTE on the filter: we use the file's .Extension property (exact match)
        rather than -Include '*.exe','*.dll'. The wildcard form can spuriously
        match side-by-side assembly manifests like 'app.exe.manifest' in some
        PowerShell versions because the FileSystem provider's wildcard engine
        treats '*.exe' more loosely than the .Extension equality check.
        .manifest files are XML — not Authenticode-signable — so accidentally
        feeding them to Get-AuthenticodeSignature produced bogus "not signed"
        failures and aborted toolchain installs.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$Folder,
        [Parameter(Mandatory)] [string]$ToolName
    )
    $files = @(Get-ChildItem -LiteralPath $Folder -Recurse -File -ErrorAction SilentlyContinue |
        Where-Object { $_.Extension -in '.exe', '.dll' })
    if ($files.Count -eq 0) {
        Write-MsixLog -Level Warning -Message "No .exe/.dll under $Folder to verify ($ToolName)"
        return
    }
    foreach ($file in $files) {
        _MsixVerifyAuthenticode -Path $file.FullName -ToolName "$ToolName/$($file.Name)" | Out-Null
    }
}

function _MsixVerifyAuthenticodeMsixBundle {
    <#
    .SYNOPSIS
        Verifies a .msix / .msixbundle / .appxbundle is Authenticode-signed by a
        trusted publisher. Unlike _MsixVerifyAuthenticodeFolder this expects a
        single signed file (the bundle itself).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$Path,
        [Parameter(Mandatory)] [string]$ToolName
    )
    _MsixVerifyAuthenticode -Path $Path -ToolName $ToolName | Out-Null
}

function _MsixDownloadFile {
    param(
        [string]$Url,
        [string]$Destination,
        # Optional SHA-256 (hex) the downloaded file must match. When supplied,
        # the file is hashed after download and a mismatch throws (the partial
        # download is removed). Use for integrity-pinning where Authenticode is
        # unavailable (e.g. msixmgr) or to lock an immutable artifact.
        [string]$ExpectedSha256
    )
    Write-MsixLog -Level Info -Message "Downloading $Url"
    $oldPref = $ProgressPreference
    $ProgressPreference = 'SilentlyContinue'   # MUCH faster on Windows PowerShell 5.1
    try {
        Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -ErrorAction Stop
    } finally {
        $ProgressPreference = $oldPref
    }
    if ($ExpectedSha256) {
        $actual = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash
        if ($actual -ne $ExpectedSha256.Trim().ToUpperInvariant()) {
            Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue
            throw "SHA-256 mismatch for '$Url'.`n expected: $($ExpectedSha256.Trim().ToUpperInvariant())`n actual: $actual`nThe download was rejected and deleted."
        }
        Write-MsixLog -Level Info -Message "SHA-256 verified: $actual"
    }
}

function _MsixExpandZip {
    <#
    Extracts any zip-format archive into a folder. Unlike Expand-Archive,
    this works regardless of the file's extension (.nupkg, .vsix, etc.).
    #>

    param(
        [string]$ArchivePath,
        [string]$DestinationPath
    )
    Add-Type -AssemblyName System.IO.Compression.FileSystem
    if (-not (Test-Path -LiteralPath $DestinationPath)) {
        New-Item -Path $DestinationPath -ItemType Directory -Force | Out-Null
    }
    # SECURITY: .NET Framework's ZipFile.ExtractToDirectory does not sanitise
    # entry names, so a malicious archive (these come from third-party GitHub /
    # NuGet sources) can use '..\' or rooted paths to write outside the
    # destination (Zip-Slip) and overwrite e.g. toolchain binaries. Validate
    # every entry's resolved path stays under the destination root before
    # extracting.
    $root = [System.IO.Path]::GetFullPath($DestinationPath)
    if (-not $root.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
        $root += [System.IO.Path]::DirectorySeparatorChar
    }
    $zip = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath)
    try {
        foreach ($entry in $zip.Entries) {
            $target = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($DestinationPath, $entry.FullName))
            if (-not $target.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {
                throw "Refusing to extract entry that escapes the destination (Zip-Slip): $($entry.FullName)"
            }
            # Directory entries have an empty Name; just ensure the folder exists.
            if ([string]::IsNullOrEmpty($entry.Name)) {
                if (-not (Test-Path -LiteralPath $target)) {
                    New-Item -Path $target -ItemType Directory -Force | Out-Null
                }
                continue
            }
            $dir = [System.IO.Path]::GetDirectoryName($target)
            if ($dir -and -not (Test-Path -LiteralPath $dir)) {
                New-Item -Path $dir -ItemType Directory -Force | Out-Null
            }
            [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true)
        }
    } finally {
        $zip.Dispose()
    }
}

function _MsixGitHubLatest {
    param([string]$Repo)
    $api = "https://api.github.com/repos/$Repo/releases/latest"
    $hdr = @{ 'User-Agent' = 'MSIX-PowerShell-Module' }
    if ($env:GITHUB_TOKEN) { $hdr['Authorization'] = "Bearer $env:GITHUB_TOKEN" }
    return Invoke-RestMethod -Uri $api -Headers $hdr -UseBasicParsing -ErrorAction Stop
}


# =============================================================================
# Toolchain-installer scaffolding (issue #36)
# -----------------------------------------------------------------------------
# Every Install-Msix* / Update-Msix* in the module that targets a single-zip
# Sysinternals-style download (Procmon, DebugView, msixmgr, ...) used to
# repeat ~90 lines of marker-check + temp-dir + download + Authenticode +
# copy + rollback. The two helpers below centralise that scaffolding so each
# wrapper drops to ~15 lines and bug fixes apply in one place.
#
# Installers with version-aware idempotency (PSF tag from GitHub, SDK version
# from NuGet, AppRuntime multi-channel cache) DO NOT use these helpers because
# their acquire / idempotency semantics differ enough that forcing them in
# would create a leaky abstraction. They remain bespoke and are documented as
# such alongside the helper.
# =============================================================================

function _MsixInstallArchiveTool {
    <#
    .SYNOPSIS
        Internal helper: download a zip, Authenticode-verify, stage-copy, then
        write a date-stamped marker. Used by Install-MsixProcMon /
        Install-MsixDebugView / Install-MsixMgr.
 
    .DESCRIPTION
        Common scaffolding for "download a single zip from a stable URL and
        unpack it under Destination" installers. Idempotent via a marker file
        (re-runs are no-ops unless -Force). Rolls back the Destination folder
        if the install fails AND the folder didn't exist before the call.
 
        Authenticode verification is on by default. msixmgr is the one tool in
        the module that opts out (upstream signing is broken — see
        microsoft/msix-packaging#710) and supplies a custom warning via
        -SkipVerificationWarning.
 
        Output object exactly matches what the legacy bespoke installers
        returned: @{ Path; Updated; Source }. The Updated=$true on the
        ShouldProcess-skipped (WhatIf) path is preserved as legacy behaviour.
 
    .PARAMETER ToolName
        Logical name for error / log messages (e.g. 'Process Monitor').
 
    .PARAMETER Destination
        Where to extract the archive.
 
    .PARAMETER MarkerFile
        Full path to the idempotency marker file (e.g. "$dest\procmon.installed").
 
    .PARAMETER Url
        Download URL.
 
    .PARAMETER ArchiveFileName
        Filename to use when saving the download to the temp folder. Used by
        Expand-Archive to choose its extraction logic from the extension.
 
    .PARAMETER VerifyAuthenticode
        Default $true. Set to $false for tools whose upstream signing is
        broken — supply -SkipVerificationWarning to surface why.
 
    .PARAMETER SkipVerificationWarning
        Warning text emitted via Write-Warning when -VerifyAuthenticode is
        $false. Required in that case so operators are not silently shipped
        unverified binaries.
 
    .PARAMETER IdempotencyLogMessage
        Override the "already installed" log line. Defaults to
        "$ToolName already installed at $Destination. Use -Force to reinstall."
 
    .PARAMETER PostInstall
        Script block invoked after the copy succeeds, signature: { param($dest) ... }.
        Use this to set an env-var hint and log the resolved exe path.
 
    .PARAMETER Force
        Re-run the install even when the marker is present.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$ToolName,
        [Parameter(Mandatory)] [string]$Destination,
        [Parameter(Mandatory)] [string]$MarkerFile,
        [Parameter(Mandatory)] [string]$Url,
        [Parameter(Mandatory)] [string]$ArchiveFileName,
        [bool]$VerifyAuthenticode = $true,
        [string]$SkipVerificationWarning,
        [string]$IdempotencyLogMessage,
        [scriptblock]$PostInstall,
        [switch]$Force,
        # Optional SHA-256 of the downloaded archive. When supplied, the download
        # is rejected unless it matches. Off by default so installs work
        # out of the box; opt in for integrity-pinning (esp. tools whose upstream
        # is unsigned, e.g. msixmgr).
        [string]$ExpectedSha256
    )

    if (-not $IdempotencyLogMessage) {
        $IdempotencyLogMessage = "$ToolName already installed at $Destination. Use -Force to reinstall."
    }

    if ((Test-Path -LiteralPath $MarkerFile) -and -not $Force) {
        Write-MsixLog -Level Info -Message $IdempotencyLogMessage
        return [pscustomobject]@{ Path = $Destination; Updated = $false }
    }

    # ShouldProcess-skipped path: preserve legacy behaviour of returning
    # Updated=$true + the source URL even when nothing was actually done.
    if (-not $PSCmdlet.ShouldProcess($Destination, "Install $ToolName")) {
        return [pscustomobject]@{ Path = $Destination; Updated = $true; Source = $Url }
    }

    $tmpRoot = Join-Path -Path $env:TEMP -ChildPath ("{0}-{1}" -f ([System.IO.Path]::GetFileNameWithoutExtension($ArchiveFileName).ToLowerInvariant()), ([guid]::NewGuid().ToString('N').Substring(0,8)))
    $stage   = Join-Path -Path $tmpRoot -ChildPath 'extracted'
    $archive = Join-Path -Path $tmpRoot -ChildPath $ArchiveFileName
    New-Item -Path $tmpRoot -ItemType Directory -Force | Out-Null
    New-Item -Path $stage   -ItemType Directory -Force | Out-Null

    $destinationExisted = Test-Path -LiteralPath $Destination
    try {
        _MsixDownloadFile -Url $Url -Destination $archive -ExpectedSha256 $ExpectedSha256
        Expand-Archive -LiteralPath $archive -DestinationPath $stage -Force

        if ($VerifyAuthenticode) {
            _MsixVerifyAuthenticodeFolder -Folder $stage -ToolName $ToolName
        } elseif ($SkipVerificationWarning) {
            Write-Warning -Message $SkipVerificationWarning
        }

        New-Item -Path $Destination -ItemType Directory -Force | Out-Null
        Copy-Item -LiteralPath (Join-Path -Path $stage -ChildPath '*') -Destination $Destination -Recurse -Force
        (Get-Date -Format o) | Set-Content -LiteralPath $MarkerFile -Encoding ascii

        if ($PostInstall) { & $PostInstall $Destination }
    } catch {
        Write-MsixLog -Level Error -Message "$ToolName install rolled back: $_"
        if (-not $destinationExisted) {
            Remove-Item -LiteralPath $Destination -Recurse -Force -ErrorAction SilentlyContinue
        }
        throw
    } finally {
        Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue
    }

    return [pscustomobject]@{
        Path    = $Destination
        Updated = $true
        Source  = $Url
    }
}


function _MsixUpdateToolByAge {
    <#
    .SYNOPSIS
        Internal helper: age-based updater. Re-runs an install action only
        when the marker timestamp is older than -MaxAgeDays. Used by
        Update-MsixProcMon / Update-MsixDebugView / Update-MsixMgr /
        Update-MsixAppRuntime.
 
    .DESCRIPTION
        Reads the ISO-8601 timestamp from -MarkerFile (written by
        _MsixInstallArchiveTool) and compares against MaxAgeDays. Calls
        -InstallFresh when no marker exists, -InstallForce when the marker
        is too old, or returns a fresh no-op summary otherwise.
 
        Whole-function ShouldProcess (matches the previous behaviour of
        Update-MsixProcMon / Update-MsixDebugView / Update-MsixAppRuntime).
 
    .PARAMETER ToolName
        Logical name used in age log lines.
 
    .PARAMETER Destination
        Where the tool lives (used as the ShouldProcess target string).
 
    .PARAMETER MarkerFile
        Full path to the install marker file.
 
    .PARAMETER MaxAgeDays
        Refresh threshold in days.
 
    .PARAMETER InstallFresh
        Script block invoked when nothing is cached. Typically calls the
        Install-Msix* with no -Force.
 
    .PARAMETER InstallForce
        Script block invoked when the marker is too old. Typically calls the
        Install-Msix* with -Force.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$ToolName,
        [Parameter(Mandatory)] [string]$Destination,
        [Parameter(Mandatory)] [string]$MarkerFile,
        [Parameter(Mandatory)] [int]$MaxAgeDays,
        [Parameter(Mandatory)] [scriptblock]$InstallFresh,
        [Parameter(Mandatory)] [scriptblock]$InstallForce
    )

    if (-not $PSCmdlet.ShouldProcess($Destination, "Update $ToolName")) { return }

    if (-not (Test-Path -LiteralPath $MarkerFile)) {
        return & $InstallFresh
    }
    $stamp = [datetime](Get-Content -LiteralPath $MarkerFile -Raw).Trim()
    $age   = (Get-Date) - $stamp
    if ($age.TotalDays -gt $MaxAgeDays) {
        Write-MsixLog -Level Info -Message "$ToolName is $([int]$age.TotalDays) days old; refreshing."
        return & $InstallForce
    }
    Write-MsixLog -Level Info -Message "$ToolName is fresh ($([int]$age.TotalDays) days old; threshold $MaxAgeDays)."
    return [pscustomobject]@{ Path = $Destination; Updated = $false }
}


function Install-MsixPsfBinary {
    <#
    .SYNOPSIS
        Downloads the latest TMurgent PSF release and installs it under the
        module's tools root (or a path you specify), ready for Add-MsixPsfV2.
 
    .DESCRIPTION
        Tim Mangan's fork of the Package Support Framework
        (https://github.com/TimMangan/MSIX-PackageSupportFramework) ships
        pre-built binaries — including PsfLauncher*.exe, PsfRuntime*.dll,
        StartingScriptWrapper.ps1 and the modern MFRFixup — that the upstream
        Microsoft repo does not always include in releases.
 
        This function uses the GitHub API to find the latest release, downloads
        the asset that contains the binaries (.zip), extracts everything into
        $ToolsRoot\psf, and writes a `psf.version` marker so subsequent calls
        know what's installed. The install is idempotent: re-running with the
        same latest tag is a no-op unless -Force is supplied.
 
        SECURITY: every .exe / .dll in the extracted archive is verified to
        have a valid Authenticode signature from a trusted publisher BEFORE
        anything is copied into the toolchain folder. A failed verification
        rolls back the install (the destination folder is removed if this
        cmdlet created it). See signers.json for the allowlist.
 
        Related: Update-MsixPsfBinary (re-installs only when GitHub publishes a
        newer tag), Get-MsixPsfBinariesVersion (queries what's currently
        installed), Add-MsixPsfV2 (the consumer that uses the binaries).
 
    .PARAMETER Destination
        Where to extract. Defaults to "(Get-MsixToolsRoot)\psf".
 
    .PARAMETER Force
        Reinstall even if the latest version is already present.
 
    .PARAMETER AssetPattern
        Regex matched against asset names. Defaults to '\.zip$' so any zip works.
 
    .OUTPUTS
        [pscustomobject] with Path, Version, Updated, and Source (download URL).
 
    .EXAMPLE
        # Install the latest PSF binaries into the default tools root.
        Install-MsixPsfBinary
 
    .EXAMPLE
        # Force reinstall (useful after deleting binaries by hand).
        Install-MsixPsfBinary -Force
 
    .EXAMPLE
        # Install into a custom location.
        Install-MsixPsfBinary -Destination 'D:\msix-tools\psf'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Destination,
        [switch]$Force,
        [string]$AssetPattern = '\.zip$'
    )

    if (-not $Destination) { $Destination = Join-Path -Path (Get-MsixToolsRoot) -ChildPath 'psf' }

    $release = _MsixGitHubLatest -Repo $script:TMurgentRepo
    $tag     = $release.tag_name
    Write-MsixLog -Level Info -Message "Latest TMurgent PSF release: $tag"

    $marker = Join-Path -Path $Destination -ChildPath 'psf.version'
    if ((Test-Path -LiteralPath $marker) -and -not $Force) {
        $current = (Get-Content -LiteralPath $marker -Raw -ErrorAction SilentlyContinue).Trim()
        if ($current -eq $tag) {
            Write-MsixLog -Level Info -Message "PSF $tag already installed at $Destination. Use -Force to reinstall."
            return [pscustomobject]@{ Path = $Destination; Version = $tag; Updated = $false }
        }
    }

    $asset = $release.assets | Where-Object { $_.name -match $AssetPattern } | Select-Object -First 1
    if (-not $asset) {
        throw "No release asset matching '$AssetPattern' in $tag. Assets: $($release.assets.name -join ', ')"
    }

    $tmp = Join-Path -Path $env:TEMP -ChildPath "tmurgent-psf-$([guid]::NewGuid().ToString('N').Substring(0,8))"
    New-Item -Path $tmp -ItemType Directory -Force | Out-Null
    $zip = Join-Path -Path $tmp -ChildPath $asset.name

    if ($PSCmdlet.ShouldProcess($Destination, "Install PSF $tag")) {
        $destinationCreated = $false
        try {
            _MsixDownloadFile -Url $asset.browser_download_url -Destination $zip
            _MsixExpandZip -ArchivePath $zip -DestinationPath $tmp

            # H1: verify Authenticode signer on every .exe/.dll before we copy
            # any of them into the toolchain root.
            _MsixVerifyAuthenticodeFolder -Folder $tmp -ToolName 'PSF'

            if (-not (Test-Path -LiteralPath $Destination)) {
                New-Item -Path $Destination -ItemType Directory -Force | Out-Null
                $destinationCreated = $true
            }
            # Copy every file from extracted layout into Destination flatly
            Get-ChildItem -LiteralPath $tmp -Recurse -File | Where-Object { $_.FullName -ne $zip } |
                ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $Destination -Force }

            Set-Content -Path $marker -Value $tag -Encoding ascii
            Write-MsixLog -Level Info -Message "PSF $tag installed to $Destination"

        } catch {
            Write-MsixLog -Level Error -Message "PSF install rolled back: $_"
            if ($destinationCreated) {
                Remove-Item -LiteralPath $Destination -Recurse -Force -ErrorAction SilentlyContinue
            }
            throw
        } finally {
            Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    return [pscustomobject]@{
        Path    = $Destination
        Version = $tag
        Updated = $true
        Source  = $asset.browser_download_url
    }
}


function Get-MsixPsfBinariesVersion {
    <#
    .SYNOPSIS
        Reports the version of PSF binaries currently installed under the
        tools root (or the path you provide).
 
    .DESCRIPTION
        Reads the `psf.version` marker that Install-MsixPsfBinary writes into
        the destination folder and reports whether PsfLauncher32.exe is also
        present (sanity check that the install wasn't partially deleted).
 
    .PARAMETER Path
        Folder to inspect. Defaults to "(Get-MsixToolsRoot)\psf".
 
    .OUTPUTS
        [pscustomobject] with Path, Installed (bool), Version (GitHub tag),
        and HasLauncher (bool).
 
    .EXAMPLE
        # Print the cached PSF version on the current machine.
        Get-MsixPsfBinariesVersion
    #>

    [CmdletBinding()]
    param(
        [string]$Path
    )
    if (-not $Path) { $Path = Join-Path -Path (Get-MsixToolsRoot) -ChildPath 'psf' }
    $marker = Join-Path -Path $Path -ChildPath 'psf.version'
    return [pscustomobject]@{
        Path        = $Path
        Installed   = Test-Path -LiteralPath $marker
        Version     = if (Test-Path -LiteralPath $marker) { (Get-Content -LiteralPath $marker -Raw).Trim() } else { $null }
        HasLauncher = Test-Path -LiteralPath (Join-Path -Path $Path -ChildPath 'PsfLauncher32.exe')
    }
}


function Update-MsixPsfBinary {
    <#
    .SYNOPSIS
        Convenience wrapper: re-runs Install-MsixPsfBinary only when the GitHub
        latest tag differs from what's installed.
 
    .DESCRIPTION
        Idempotent updater suitable for scheduled / CI use. If no PSF is cached
        locally, runs a fresh install. Otherwise queries the TMurgent GitHub
        releases API and compares against the local `psf.version` marker;
        re-downloads (with Authenticode verification) only when the tag has
        changed.
 
    .PARAMETER Destination
        Folder containing PSF. Defaults to "(Get-MsixToolsRoot)\psf".
 
    .OUTPUTS
        [pscustomobject] from Install-MsixPsfBinary or Get-MsixPsfBinariesVersion.
 
    .EXAMPLE
        # Refresh PSF if a newer release has appeared upstream.
        Update-MsixPsfBinary
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param([string]$Destination)

    if (-not $PSCmdlet.ShouldProcess($Destination, 'Update PSF Binaries')) { return }
    $current = Get-MsixPsfBinariesVersion -Path $Destination
    if (-not $current.Installed) {
        Write-MsixLog -Level Info -Message "No PSF found locally; installing."
        return Install-MsixPsfBinary -Destination $Destination
    }

    $latest = (_MsixGitHubLatest -Repo $script:TMurgentRepo).tag_name
    if ($current.Version -eq $latest) {
        Write-MsixLog -Level Info -Message "PSF up to date ($latest)"
        return $current
    }
    Write-MsixLog -Level Info -Message "Update available: $($current.Version) -> $latest"
    return Install-MsixPsfBinary -Destination $Destination -Force
}


function Install-MsixProcMon {
    <#
    .SYNOPSIS
        Downloads and extracts Sysinternals Process Monitor under the tools root
        (or to a path you specify), ready for Invoke-MsixProcMonCapture.
 
    .DESCRIPTION
        Downloads https://download.sysinternals.com/files/ProcessMonitor.zip,
        Authenticode-verifies every .exe/.dll against the Microsoft / Microsoft
        Windows Publisher trusted-publisher allowlist BEFORE copying anything
        into $Destination, and sets $env:MSIX_PROCMON_PATH so
        Resolve-MsixProcMonPath / Invoke-MsixProcMonCapture pick it up
        immediately. Idempotent: existing installs are skipped unless -Force.
 
    .PARAMETER Destination
        Where to extract. Defaults to "(Get-MsixToolsRoot)\procmon".
 
    .PARAMETER Force
        Re-download even if procmon is already present.
 
    .OUTPUTS
        [pscustomobject] with Path, Updated, and (on fresh install) Source URL.
 
    .NOTES
        Sysinternals doesn't expose a versioned download URL — the zip is always
        the latest. This function therefore stamps the install date as the
        "version" so Update-MsixProcMon knows when to refresh.
 
    .EXAMPLE
        # Install Process Monitor into the default location.
        Install-MsixProcMon
 
    .EXAMPLE
        # Force re-download (e.g. after an accidental delete).
        Install-MsixProcMon -Force
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Destination,
        [switch]$Force
    )
    if (-not $Destination) { $Destination = Join-Path -Path (Get-MsixToolsRoot) -ChildPath 'procmon' }
    _MsixInstallArchiveTool `
        -ToolName 'Process Monitor' `
        -Destination $Destination `
        -MarkerFile (Join-Path -Path $Destination -ChildPath 'procmon.installed') `
        -Url $script:ProcmonZipUrl `
        -ArchiveFileName 'ProcessMonitor.zip' `
        -Force:$Force `
        -PostInstall {
            param($dest)
            $exe = Join-Path -Path $dest -ChildPath 'Procmon.exe'
            if (Test-Path -LiteralPath $exe) {
                $env:MSIX_PROCMON_PATH = $exe
                Write-MsixLog -Level Info -Message "Process Monitor installed at $exe"
            } else {
                Write-MsixLog -Level Warning -Message "Procmon.exe not found after extraction; check $dest"
            }
        }
}


function Update-MsixProcMon {
    <#
    .SYNOPSIS
        Refreshes Process Monitor if the local copy is older than -MaxAgeDays
        (default 30). Sysinternals updates infrequently so a slow cadence is fine.
 
    .DESCRIPTION
        Age-based updater. Re-runs Install-MsixProcMon -Force only when the
        install marker is older than -MaxAgeDays. If nothing is installed yet,
        falls back to a fresh Install-MsixProcMon.
 
    .PARAMETER Destination
        Folder containing Procmon. Defaults to "(Get-MsixToolsRoot)\procmon".
 
    .PARAMETER MaxAgeDays
        Refresh threshold in days. Default 30.
 
    .OUTPUTS
        [pscustomobject] from Install-MsixProcMon or a no-op summary.
 
    .EXAMPLE
        # Refresh Procmon if its cached copy is over a month old.
        Update-MsixProcMon
 
    .EXAMPLE
        # Tighter cadence for kiosk-style refresh.
        Update-MsixProcMon -MaxAgeDays 7
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Destination,
        [int]$MaxAgeDays = 30
    )
    if (-not $Destination) { $Destination = Join-Path -Path (Get-MsixToolsRoot) -ChildPath 'procmon' }
    _MsixUpdateToolByAge `
        -ToolName 'Procmon' `
        -Destination $Destination `
        -MarkerFile (Join-Path -Path $Destination -ChildPath 'procmon.installed') `
        -MaxAgeDays $MaxAgeDays `
        -InstallFresh { Install-MsixProcMon -Destination $Destination } `
        -InstallForce { Install-MsixProcMon -Destination $Destination -Force }
}


function Install-MsixDebugView {
    <#
    .SYNOPSIS
        Downloads and extracts Sysinternals DebugView under the tools root,
        ready for Resolve-MsixDebugViewPath / Start-MsixDebugSession.
 
    .DESCRIPTION
        DebugView ships separately from Process Monitor (different zip on
        the Sysinternals download server). Start-MsixDebugSession was
        printing "DebugView not found" if the operator had only run
        Initialize-MsixToolchain; this cmdlet closes that gap.
 
        Every .exe / .dll in the extracted archive is Authenticode-verified
        against the trusted-publisher allowlist BEFORE anything is copied into
        $Destination. A failed verification rolls the install back. The
        environment variable $env:MSIX_DEBUGVIEW_PATH is set to the resolved
        Dbgview64.exe so subsequent calls find it without further setup.
 
    .PARAMETER Destination
        Where to extract. Defaults to "(Get-MsixToolsRoot)\debugview".
 
    .PARAMETER Force
        Re-download even if already present.
 
    .OUTPUTS
        [pscustomobject] with Path, Updated, and (on fresh install) Source URL.
 
    .EXAMPLE
        # Cache DebugView so PSF TraceFixup output can be captured.
        Install-MsixDebugView
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Destination,
        [switch]$Force
    )
    if (-not $Destination) { $Destination = Join-Path -Path (Get-MsixToolsRoot) -ChildPath 'debugview' }
    _MsixInstallArchiveTool `
        -ToolName 'DebugView' `
        -Destination $Destination `
        -MarkerFile (Join-Path -Path $Destination -ChildPath 'debugview.installed') `
        -Url $script:DebugViewZipUrl `
        -ArchiveFileName 'DebugView.zip' `
        -Force:$Force `
        -PostInstall {
            param($dest)
            $exe = Join-Path -Path $dest -ChildPath 'Dbgview64.exe'
            if (-not (Test-Path -LiteralPath $exe)) { $exe = Join-Path -Path $dest -ChildPath 'Dbgview.exe' }
            if (Test-Path -LiteralPath $exe) {
                $env:MSIX_DEBUGVIEW_PATH = $exe
                Write-MsixLog -Level Info -Message "DebugView installed at $exe"
            } else {
                Write-MsixLog -Level Warning -Message "Dbgview.exe / Dbgview64.exe not found after extraction; check $dest"
            }
        }
}


function Update-MsixDebugView {
    <#
    .SYNOPSIS
        Refreshes DebugView if older than -MaxAgeDays (default 30).
 
    .DESCRIPTION
        Age-based updater. Re-runs Install-MsixDebugView -Force only when the
        cached install marker is older than -MaxAgeDays. Mirrors
        Update-MsixProcMon semantics.
 
    .PARAMETER Destination
        Folder containing DebugView. Defaults to "(Get-MsixToolsRoot)\debugview".
 
    .PARAMETER MaxAgeDays
        Refresh threshold in days. Default 30.
 
    .OUTPUTS
        [pscustomobject] from Install-MsixDebugView or a no-op summary.
 
    .EXAMPLE
        # Keep DebugView fresh-ish on a CI agent.
        Update-MsixDebugView
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Destination,
        [int]$MaxAgeDays = 30
    )
    if (-not $Destination) { $Destination = Join-Path -Path (Get-MsixToolsRoot) -ChildPath 'debugview' }
    _MsixUpdateToolByAge `
        -ToolName 'DebugView' `
        -Destination $Destination `
        -MarkerFile (Join-Path -Path $Destination -ChildPath 'debugview.installed') `
        -MaxAgeDays $MaxAgeDays `
        -InstallFresh { Install-MsixDebugView -Destination $Destination } `
        -InstallForce { Install-MsixDebugView -Destination $Destination -Force }
}


function Get-MsixDebugViewVersion {
    <#
    .SYNOPSIS
        Reports the cached DebugView install timestamp and resolved Dbgview path.
 
    .DESCRIPTION
        Reads the `debugview.installed` marker that Install-MsixDebugView wrote
        and resolves Dbgview64.exe / Dbgview.exe under the folder.
 
    .PARAMETER Path
        Folder to inspect. Defaults to "(Get-MsixToolsRoot)\debugview".
 
    .OUTPUTS
        [pscustomobject] with Path, Installed, InstalledOn ([datetime]), and
        Executable (resolved Dbgview path).
 
    .EXAMPLE
        # See how stale the cached DebugView is.
        Get-MsixDebugViewVersion
    #>

    [CmdletBinding()]
    param([string]$Path)
    if (-not $Path) { $Path = Join-Path -Path (Get-MsixToolsRoot) -ChildPath 'debugview' }
    $marker = Join-Path -Path $Path -ChildPath 'debugview.installed'
    $exe    = Join-Path -Path $Path -ChildPath 'Dbgview64.exe'
    if (-not (Test-Path -LiteralPath $exe)) { $exe = Join-Path -Path $Path -ChildPath 'Dbgview.exe' }

    return [pscustomobject]@{
        Path        = $Path
        Installed   = Test-Path -LiteralPath $marker
        InstalledOn = if (Test-Path -LiteralPath $marker) { [datetime](Get-Content -LiteralPath $marker -Raw).Trim() } else { $null }
        Executable  = if (Test-Path -LiteralPath $exe) { $exe } else { $null }
    }
}


function Install-MsixSdkTool {
    <#
    .SYNOPSIS
        Downloads MakeAppx.exe + signtool.exe from the official Microsoft
        Windows SDK BuildTools NuGet package and lays them out under the
        module's tools root so Get-MsixToolsRoot finds them.
 
    .DESCRIPTION
        The package id is `Microsoft.Windows.SDK.BuildTools` — Microsoft
        publishes signed CLI tools there for use in CI / build pipelines
        without requiring the full SDK installer. The NuGet package
        contains:
 
          bin\<sdk-version>\<arch>\MakeAppx.exe
          bin\<sdk-version>\<arch>\signtool.exe
          bin\<sdk-version>\<arch>\makepri.exe
          ... + the AppxPackaging COM stack DLLs
 
        This function pulls the latest stable version (or the version you
        pin via -Version), extracts the matching architecture into
        "$ToolsRoot\Tools\", and writes a `sdk.version` marker so
        Update-MsixSdkTool knows what's installed.
 
        SECURITY: every .exe / .dll inside the chosen arch folder is
        Authenticode-verified against the Microsoft trusted-publisher
        allowlist BEFORE anything is copied to "$Destination\Tools".
 
    .PARAMETER Destination
        Where to land the binaries. Default: the module folder. After install,
        Get-MsixToolsRoot returns this path automatically.
 
    .PARAMETER Architecture
        x64 (default) or x86. Use whatever matches the architecture you'll be
        signing/packaging from (the host architecture, not the package's).
 
    .PARAMETER Version
        Pin to a specific NuGet version. Default: latest stable.
 
    .PARAMETER Force
        Reinstall even if the version is already present.
 
    .OUTPUTS
        [pscustomobject] with Path, Version, Architecture, Updated, and (on
        fresh install) Source URL.
 
    .EXAMPLE
        # Install latest x64 SDK tools into the module folder.
        Install-MsixSdkTool
 
    .EXAMPLE
        # 32-bit signtool, forced reinstall.
        Install-MsixSdkTool -Architecture x86 -Force
 
    .EXAMPLE
        # Pin a specific NuGet version for reproducible CI builds.
        Install-MsixSdkTool -Version '10.0.26100.1742'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Destination,
        [ValidateSet('x86','x64')]
        [string]$Architecture = 'x64',
        [string]$Version,
        [switch]$Force
    )

    if (-not $Destination) { $Destination = $PSScriptRoot }

    # ── Find latest version if not pinned ─────────────────────────────────
    if (-not $Version) {
        $idxUrl = "https://api.nuget.org/v3-flatcontainer/$($script:SdkToolsNuGet.ToLower())/index.json"
        try {
            $idx = Invoke-RestMethod -Uri $idxUrl -UseBasicParsing -ErrorAction Stop
        } catch {
            throw "Could not query NuGet for $($script:SdkToolsNuGet) versions: $_"
        }
        # The index lists every version. Take the highest non-prerelease one.
        $stable = $idx.versions |
                  Where-Object { $_ -notmatch '-' } |
                  ForEach-Object { [pscustomobject]@{ Raw = $_; Ver = [version]($_ -replace '[^0-9.]','') } } |
                  Sort-Object -Property Ver -Descending |
                  Select-Object -First 1
        if (-not $stable) {
            throw "No stable versions found for $($script:SdkToolsNuGet)."
        }
        $Version = $stable.Raw
    }
    Write-MsixLog -Level Info -Message "Microsoft.Windows.SDK.BuildTools version: $Version"

    # ── Idempotency check ─────────────────────────────────────────────────
    $marker = Join-Path -Path $Destination -ChildPath 'Tools\sdk.version'
    if ((Test-Path -LiteralPath $marker) -and -not $Force) {
        $current = (Get-Content -LiteralPath $marker -Raw -ErrorAction SilentlyContinue).Trim()
        if ($current -eq "$Version|$Architecture") {
            Write-MsixLog -Level Info -Message "SDK tools $Version ($Architecture) already installed at $Destination\Tools."
            return [pscustomobject]@{ Path = "$Destination\Tools"; Version = $Version; Architecture = $Architecture; Updated = $false }
        }
    }

    # ── Download + extract ────────────────────────────────────────────────
    $tmp = Join-Path -Path $env:TEMP -ChildPath "sdk-buildtools-$([guid]::NewGuid().ToString('N').Substring(0,8))"
    New-Item -Path $tmp -ItemType Directory -Force | Out-Null
    $nupkg = Join-Path -Path $tmp -ChildPath "$($script:SdkToolsNuGet).$Version.nupkg"
    $url   = "https://api.nuget.org/v3-flatcontainer/$($script:SdkToolsNuGet.ToLower())/$Version/$($script:SdkToolsNuGet.ToLower()).$Version.nupkg"

    if ($PSCmdlet.ShouldProcess("$Destination\Tools", "Install Microsoft.Windows.SDK.BuildTools $Version ($Architecture)")) {
        $toolsDir = Join-Path -Path $Destination -ChildPath 'Tools'
        $toolsDirExisted = Test-Path -LiteralPath $toolsDir
        try {
            _MsixDownloadFile -Url $url -Destination $nupkg

            $extracted = Join-Path -Path $tmp -ChildPath 'extracted'
            _MsixExpandZip -ArchivePath $nupkg -DestinationPath $extracted

            # Locate the bin\<sdk-ver>\<arch> folder. NuGet packages may have a
            # versioned subdirectory we need to discover.
            $archDir = Get-ChildItem -LiteralPath (Join-Path -Path $extracted -ChildPath 'bin') -Directory -ErrorAction SilentlyContinue |
                       ForEach-Object { Join-Path -Path $_.FullName -ChildPath $Architecture } |
                       Where-Object { Test-Path -LiteralPath (Join-Path -Path $_ -ChildPath 'MakeAppx.exe') } |
                       Sort-Object -Descending |
                       Select-Object -First 1
            if (-not $archDir) {
                throw "MakeAppx.exe not found inside the NuGet package for architecture '$Architecture'."
            }

            # H1: verify every .exe/.dll in the SDK arch folder before we copy
            # them into Tools\ where Get-MsixToolsRoot will surface them.
            _MsixVerifyAuthenticodeFolder -Folder $archDir -ToolName "SDK BuildTools $Version/$Architecture"

            New-Item -Path $toolsDir -ItemType Directory -Force | Out-Null

            # Copy the whole arch folder (MakeAppx, signtool, makepri, plus
            # the AppxPackaging dependency DLLs that signtool needs at runtime).
            Copy-Item -Path "$archDir\*" -Destination $toolsDir -Recurse -Force

            "$Version|$Architecture" | Set-Content -LiteralPath $marker -Encoding ascii
            Write-MsixLog -Level Info -Message "MakeAppx.exe + signtool.exe installed at $toolsDir"

            # Reset the cached tools root so the next Get-MsixToolsRoot picks this up
            Set-MsixToolsRoot -Path $Destination

        } catch {
            Write-MsixLog -Level Error -Message "SDK tools install rolled back: $_"
            if (-not $toolsDirExisted) {
                Remove-Item -LiteralPath $toolsDir -Recurse -Force -ErrorAction SilentlyContinue
            }
            throw
        } finally {
            Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    return [pscustomobject]@{
        Path         = "$Destination\Tools"
        Version      = $Version
        Architecture = $Architecture
        Updated      = $true
        Source       = $url
    }
}


function Update-MsixSdkTool {
    <#
    .SYNOPSIS
        Refreshes the bundled SDK tools to the latest NuGet version, but only
        when a new one exists.
 
    .DESCRIPTION
        Idempotent updater. Queries the NuGet flat-container index for the
        highest non-prerelease version of Microsoft.Windows.SDK.BuildTools and
        re-runs Install-MsixSdkTool only if the local `sdk.version` marker
        doesn't already match "<version>|<architecture>".
 
    .PARAMETER Destination
        Where SDK tools are installed. Defaults to the module folder
        ($PSScriptRoot).
 
    .PARAMETER Architecture
        x64 (default) or x86.
 
    .OUTPUTS
        [pscustomobject] from Install-MsixSdkTool or a no-op summary.
 
    .EXAMPLE
        # Refresh the SDK tools (no-op if already on the latest tag).
        Update-MsixSdkTool
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Destination,
        [ValidateSet('x86','x64')]
        [string]$Architecture = 'x64'
    )
    if (-not $Destination) { $Destination = $PSScriptRoot }
    if (-not $PSCmdlet.ShouldProcess($Destination, 'Update SDK Tools')) { return }
    $marker = Join-Path -Path $Destination -ChildPath 'Tools\sdk.version'
    if (-not (Test-Path -LiteralPath $marker)) {
        return Install-MsixSdkTool -Destination $Destination -Architecture $Architecture
    }

    # Find latest published version
    $idxUrl = "https://api.nuget.org/v3-flatcontainer/$($script:SdkToolsNuGet.ToLower())/index.json"
    $idx    = Invoke-RestMethod -Uri $idxUrl -UseBasicParsing -ErrorAction Stop
    $latest = ($idx.versions | Where-Object { $_ -notmatch '-' } |
               ForEach-Object { [pscustomobject]@{ Raw=$_; Ver=[version]($_ -replace '[^0-9.]','') } } |
               Sort-Object Ver -Descending | Select-Object -First 1).Raw

    $current = (Get-Content -LiteralPath $marker -Raw).Trim()
    if ($current -eq "$latest|$Architecture") {
        Write-MsixLog -Level Info -Message "SDK tools up to date ($latest, $Architecture)."
        return [pscustomobject]@{ Path = "$Destination\Tools"; Version = $latest; Architecture = $Architecture; Updated = $false }
    }
    Write-MsixLog -Level Info -Message "SDK tools update available: $current -> $latest|$Architecture"
    return Install-MsixSdkTool -Destination $Destination -Architecture $Architecture -Version $latest -Force
}


function Get-MsixSdkToolsVersion {
    <#
    .SYNOPSIS
        Reports the version + architecture of MakeAppx.exe / signtool.exe
        currently installed under the module's tools root.
 
    .DESCRIPTION
        Parses the `sdk.version` marker that Install-MsixSdkTool writes
        ("<version>|<architecture>"). Returns Installed=$false when the marker
        is missing.
 
    .PARAMETER Destination
        Module / install folder. Defaults to $PSScriptRoot.
 
    .OUTPUTS
        [pscustomobject] with Path, Installed, Version, Architecture.
 
    .EXAMPLE
        # Verify which signtool / MakeAppx version is bundled.
        Get-MsixSdkToolsVersion
    #>

    [CmdletBinding()]
    param([string]$Destination)
    if (-not $Destination) { $Destination = $PSScriptRoot }
    $marker = Join-Path -Path $Destination -ChildPath 'Tools\sdk.version'
    if (-not (Test-Path -LiteralPath $marker)) {
        return [pscustomobject]@{ Path = "$Destination\Tools"; Installed = $false; Version = $null; Architecture = $null }
    }
    $current = (Get-Content -LiteralPath $marker -Raw).Trim()
    $parts   = $current -split '\|'
    return [pscustomobject]@{
        Path         = "$Destination\Tools"
        Installed    = $true
        Version      = $parts[0]
        Architecture = if ($parts.Count -gt 1) { $parts[1] } else { 'x64' }
    }
}


# ===========================================================================
# Windows App Runtime + DesktopAppInstaller (for sandbox / fresh hosts)
# ===========================================================================
# Default Win11 Sandbox cannot install MSIX packages out of the box — it
# lacks the AppInstaller MSIX shell handler and (depending on the package's
# uap10:HostRuntimeDependency / Windows App SDK target) the Windows App
# Runtime. These two installers fix both:
#
# - DesktopAppInstaller (Microsoft.DesktopAppInstaller msixbundle)
# Adds the Add-AppPackage UI handler and winget.
# Served by Microsoft at https://aka.ms/getwinget
# (redirects to the latest stable msixbundle).
#
# - WindowsAppRuntime (WindowsAppRuntimeInstall-x64.exe)
# The Windows App SDK runtime that many modern
# MSIX packages depend on. Pinned to a known good
# channel via Microsoft's aka.ms redirect.
# ===========================================================================

# aka.ms redirects -- Microsoft keeps these stable across releases.
$script:DesktopAppInstallerUrl = 'https://aka.ms/getwinget'

# Channels we cache by default. Real-world packages still pin specific
# channels (Notepad 8.9.x pins 1.4, etc.) so we keep a broad floor here.
# The sandbox bootstrap also reads the actual manifest dependencies and
# downloads any missing channel on demand.
$script:WindowsAppRuntimeDefaultChannels = @('1.4','1.5','1.6','1.7','1.8')

function _MsixAppRuntimeUrl {
    param([string]$Channel)
    "https://aka.ms/windowsappsdk/$Channel/latest/windowsappruntimeinstall-x64.exe"
}

function _MsixAppRuntimeFileName {
    param([string]$Channel)
    "WindowsAppRuntimeInstall-x64-$Channel.exe"
}


function Install-MsixAppRuntime {
    <#
    .SYNOPSIS
        Downloads the DesktopAppInstaller bundle + one or more Windows App
        Runtime channel installers so a sandbox (or a freshly imaged host)
        can install ANY MSIX package, including ones that pin a specific
        WindowsAppRuntime version.
 
    .DESCRIPTION
        Default Windows Sandbox lacks both components; .msix install fails
        with HRESULT 0x80073CF3 when the required WindowsAppRuntime channel
        is missing.
 
        Packages declare their WindowsAppRuntime dependency in the manifest:
            <PackageDependency Name="Microsoft.WindowsAppRuntime.1.4" .../>
        So one fixed installer is not enough. This function caches ALL
        requested channels under $ToolsRoot\runtime\ as
        WindowsAppRuntimeInstall-x64-<channel>.exe.
 
        Use Get-MsixRequiredAppRuntimeChannel against a specific .msix to
        find out which channels it pins, then pass that list to -Channels.
 
    .PARAMETER Destination
        Cache folder. Defaults to "$Get-MsixToolsRoot\runtime".
 
    .PARAMETER Channels
        WindowsAppRuntime channels (major.minor strings, e.g. '1.4').
        Defaults to 1.4 / 1.5 / 1.6 so the cache covers the long tail.
 
    .PARAMETER Force
        Re-download even if cached.
 
    .OUTPUTS
        [pscustomobject] with Path, Updated, Channels (string[]),
        DesktopAppInstaller (path), and WindowsAppRuntimeExes (string[]).
 
    .EXAMPLE
        # Cache the default 1.4 / 1.5 / 1.6 / 1.7 / 1.8 channels + DesktopAppInstaller.
        Install-MsixAppRuntime
 
    .EXAMPLE
        # Cache only what one specific package actually needs.
        $req = Get-MsixRequiredAppRuntimeChannel -PackagePath app.msix
        Install-MsixAppRuntime -Channels $req
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Destination,
        [string[]]$Channels = $script:WindowsAppRuntimeDefaultChannels,
        [switch]$Force
    )
    if (-not $Destination) { $Destination = Join-Path -Path (Get-MsixToolsRoot) -ChildPath 'runtime' }

    $marker = Join-Path -Path $Destination -ChildPath 'runtime.installed'
    if ((Test-Path -LiteralPath $marker) -and -not $Force) {
        # Check whether all requested channels are cached; if any is missing
        # we still need to download just that one (don't bail out).
        $missing = $Channels | Where-Object {
            -not (Test-Path -LiteralPath (Join-Path -Path $Destination -ChildPath (_MsixAppRuntimeFileName -Channel $_)))
        }
        if (-not $missing) {
            Write-MsixLog -Level Info -Message "Windows App Runtime ($($Channels -join ', ')) + DesktopAppInstaller cached at $Destination."
            return [pscustomobject]@{
                Path = $Destination; Updated = $false; Channels = $Channels
            }
        }
        $Channels = $missing
        Write-MsixLog -Level Info -Message "Caching additional WindowsAppRuntime channels: $($missing -join ', ')"
    }

    if (-not $PSCmdlet.ShouldProcess($Destination, "Install Windows App Runtime ($($Channels -join ', ')) + DesktopAppInstaller")) { return }

    New-Item -ItemType Directory -Path $Destination -Force | Out-Null

    # Issue #42: fail closed + Authenticode verify EVERY downloaded artifact
    # before we touch the install marker. The previous flow swallowed per-
    # channel download failures and still wrote the marker, so a sandbox
    # could report the runtime cache as installed while required runtime
    # installers were missing. It also skipped signature verification, so
    # the bundle and channel installers could land from any redirect target
    # without being checked against the trusted-publisher allowlist.

    # Track files we created in THIS call so we can clean them up on a
    # failure mid-flight. Files that existed before the call are left alone.
    $createdThisRun = [System.Collections.Generic.List[string]]::new()
    try {
        # ── DesktopAppInstaller msixbundle ────────────────────────────────
        $bundlePath = Join-Path -Path $Destination -ChildPath 'Microsoft.DesktopAppInstaller.msixbundle'
        if ($Force -or -not (Test-Path -LiteralPath $bundlePath)) {
            _MsixDownloadFile -Url $script:DesktopAppInstallerUrl -Destination $bundlePath
            $createdThisRun.Add($bundlePath)
        }
        _MsixVerifyAuthenticodeMsixBundle -Path $bundlePath -ToolName 'DesktopAppInstaller'

        # ── WindowsAppRuntime channel installers (one .exe per channel) ───
        # Treat any download or verification failure as a hard failure --
        # caller can pass -Channels with only the channels they truly need
        # to scope the install.
        $runtimePaths = [System.Collections.Generic.List[string]]::new()
        foreach ($ch in $Channels) {
            $rt = Join-Path -Path $Destination -ChildPath (_MsixAppRuntimeFileName -Channel $ch)
            if ($Force -or -not (Test-Path -LiteralPath $rt)) {
                _MsixDownloadFile -Url (_MsixAppRuntimeUrl -Channel $ch) -Destination $rt
                $createdThisRun.Add($rt)
            }
            _MsixVerifyAuthenticode -Path $rt -ToolName "WindowsAppRuntime/$ch" | Out-Null
            $runtimePaths.Add($rt)
        }

        # Marker is the LAST step -- only written if every requested channel
        # downloaded AND verified. A subsequent Get-MsixAppRuntimeVersion
        # will then see Installed=$true with a confidence guarantee.
        (Get-Date -Format o) | Set-Content -LiteralPath $marker -Encoding ascii
        Write-MsixLog -Level Info -Message "AppRuntime cached + verified: $Destination"

        return [pscustomobject]@{
            Path                  = $Destination
            Updated               = $true
            Channels              = [string[]]$Channels
            DesktopAppInstaller   = $bundlePath
            WindowsAppRuntimeExes = [string[]]$runtimePaths
        }
    } catch {
        # Roll back files we created in THIS call so a partial cache isn't
        # left behind. Files that pre-existed are intentionally preserved.
        foreach ($p in $createdThisRun) {
            if (Test-Path -LiteralPath $p) {
                Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue
            }
        }
        Write-MsixLog -Level Error -Message "AppRuntime install rolled back: $($_.Exception.Message)"
        throw
    }
}


function Get-MsixRequiredAppRuntimeChannel {
    <#
    .SYNOPSIS
        Parses the AppxManifest of an MSIX package and returns the list of
        Microsoft.WindowsAppRuntime.<channel> dependencies it declares.
 
    .DESCRIPTION
        Returns an array of channel strings ('1.4', '1.5', etc.) that can be
        passed directly to Install-MsixAppRuntime -Channels.
 
        Returns an empty array if the manifest declares no WindowsAppRuntime
        dependency (typical for older unpackaged-bridged Win32 apps).
 
    .PARAMETER PackagePath
        Path to the .msix / .appx / folder containing AppxManifest.xml.
 
    .OUTPUTS
        [string[]] — sorted, unique list of channel strings, or an empty array.
 
    .EXAMPLE
        # Inspect what runtime a single package needs.
        Get-MsixRequiredAppRuntimeChannel -PackagePath app.msix
        # => @('1.4')
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [string]$PackagePath
    )

    [xml]$m = Get-MsixManifest -Path $PackagePath
    $deps   = @($m.Package.Dependencies.PackageDependency) | Where-Object { $_.Name }

    $channels = foreach ($d in $deps) {
        if ($d.Name -match '^Microsoft\.WindowsAppRuntime\.(\d+\.\d+)$') {
            $matches[1]
        }
    }
    return [string[]]@($channels | Sort-Object -Unique)
}


function Update-MsixAppRuntime {
    <#
    .SYNOPSIS
        Refreshes the cached Windows App Runtime + DesktopAppInstaller if the
        local copy is older than -MaxAgeDays (default 45).
 
    .DESCRIPTION
        Age-based updater. Re-runs Install-MsixAppRuntime -Force only when the
        cached marker is older than -MaxAgeDays; otherwise reports the existing
        cache. If nothing is cached yet, performs a fresh install.
 
    .PARAMETER Destination
        Cache folder. Defaults to "(Get-MsixToolsRoot)\runtime".
 
    .PARAMETER MaxAgeDays
        Refresh threshold. Default 45.
 
    .OUTPUTS
        [pscustomobject] from Install-MsixAppRuntime or a no-op summary.
 
    .EXAMPLE
        # Refresh AppRuntime cache if older than ~6 weeks.
        Update-MsixAppRuntime
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Destination,
        [int]$MaxAgeDays = 45
    )
    if (-not $Destination) { $Destination = Join-Path -Path (Get-MsixToolsRoot) -ChildPath 'runtime' }
    _MsixUpdateToolByAge `
        -ToolName 'AppRuntime cache' `
        -Destination $Destination `
        -MarkerFile (Join-Path -Path $Destination -ChildPath 'runtime.installed') `
        -MaxAgeDays $MaxAgeDays `
        -InstallFresh { Install-MsixAppRuntime -Destination $Destination } `
        -InstallForce { Install-MsixAppRuntime -Destination $Destination -Force }
}


function Get-MsixAppRuntimeVersion {
    <#
    .SYNOPSIS
        Reports the cached AppRuntime install timestamp and resolved paths.
 
    .DESCRIPTION
        Inspects the `runtime.installed` marker plus the bundle and every
        WindowsAppRuntimeInstall-x64-<channel>.exe on disk and returns a
        summary object that Update-MsixAppRuntime / Initialize-MsixToolchain
        consume.
 
        Issue #42: prior versions looked for a single non-existent
        WindowsAppRuntimeInstall-x64.exe (no channel suffix), so the
        WindowsAppRuntimeExe property was always `$null` once
        Install-MsixAppRuntime started writing channel-specific filenames.
        This cmdlet now enumerates the channel-specific files actually
        present and returns the cached channel list.
 
    .PARAMETER Path
        Cache folder. Defaults to "(Get-MsixToolsRoot)\runtime".
 
    .OUTPUTS
        [pscustomobject] with Path, Installed, InstalledOn,
        DesktopAppInstaller (bundle path or $null), Channels (string[],
        zero-length when nothing is cached), and WindowsAppRuntimeExes
        (string[], parallel to Channels).
 
    .EXAMPLE
        # Check whether the sandbox runtime cache is ready.
        (Get-MsixAppRuntimeVersion).Channels
        # => @('1.4', '1.5', '1.6', '1.7', '1.8')
    #>

    [CmdletBinding()]
    param([string]$Path)

    if (-not $Path) { $Path = Join-Path -Path (Get-MsixToolsRoot) -ChildPath 'runtime' }
    $marker = Join-Path -Path $Path -ChildPath 'runtime.installed'
    $bundle = Join-Path -Path $Path -ChildPath 'Microsoft.DesktopAppInstaller.msixbundle'

    # Channel-aware exe discovery. Filename convention is
    # WindowsAppRuntimeInstall-x64-<major>.<minor>.exe
    # (set by _MsixAppRuntimeFileName in this file).
    $rx = [regex]'^WindowsAppRuntimeInstall-x64-(?<ch>\d+\.\d+)\.exe$'
    $channels = [System.Collections.Generic.List[string]]::new()
    $exes     = [System.Collections.Generic.List[string]]::new()
    if (Test-Path -LiteralPath $Path) {
        foreach ($file in (Get-ChildItem -LiteralPath $Path -File -Filter 'WindowsAppRuntimeInstall-x64-*.exe' -ErrorAction SilentlyContinue)) {
            $m = $rx.Match($file.Name)
            if ($m.Success) {
                $channels.Add($m.Groups['ch'].Value)
                $exes.Add($file.FullName)
            }
        }
    }

    return [pscustomobject]@{
        Path                  = $Path
        Installed             = Test-Path -LiteralPath $marker
        InstalledOn           = if (Test-Path -LiteralPath $marker) { [datetime](Get-Content -LiteralPath $marker -Raw).Trim() } else { $null }
        DesktopAppInstaller   = if (Test-Path -LiteralPath $bundle) { $bundle } else { $null }
        Channels              = [string[]]$channels
        WindowsAppRuntimeExes = [string[]]$exes
    }
}


function Initialize-MsixToolchain {
    <#
    .SYNOPSIS
        One-call setup: ensures SDK tools, PSF binaries (TMurgent),
        Process Monitor, DebugView, msixmgr, AND the Windows App Runtime +
        DesktopAppInstaller (for sandbox/MSIX install support) are present
        and up to date under the tools root.
 
    .DESCRIPTION
        Runs the Update-* cmdlet for each toolchain component in dependency
        order (SDK first, since MakeAppx is needed by almost every other
        operation), respecting -Skip. Every component is downloaded only if
        missing or stale; all downloaded binaries are Authenticode-verified
        against the trusted-publisher allowlist before they land in the
        toolchain root. Safe to run repeatedly — idempotent across components.
 
        This is what you should call from a fresh CI agent / VM / sandbox
        before doing anything else with this module.
 
    .PARAMETER Skip
        One or more component names to skip:
          Sdk, Psf, Procmon, DebugView, MsixMgr, Runtime.
 
    .OUTPUTS
        [pscustomobject] with one property per component (Sdk, Psf, Procmon,
        DebugView, MsixMgr, Runtime). Each holds the return value of its
        corresponding Update-* call or $null when skipped.
 
    .EXAMPLE
        # Default: install / refresh everything the module needs.
        Initialize-MsixToolchain
 
    .EXAMPLE
        # Skip Procmon (e.g. on a server you don't want UI tools on).
        Initialize-MsixToolchain -Skip Procmon
 
    .EXAMPLE
        # Minimal: only the SDK tools (signtool, MakeAppx) and PSF.
        Initialize-MsixToolchain -Skip Procmon,DebugView,MsixMgr,Runtime
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('Sdk','Psf','Procmon','DebugView','MsixMgr','Runtime')]
        [string[]]$Skip
    )

    $result = [ordered]@{
        Sdk = $null; Psf = $null; Procmon = $null; DebugView = $null
        MsixMgr = $null; Runtime = $null
    }
    # SDK tools first -- everything else needs MakeAppx.exe to do anything useful.
    if ($Skip -notcontains 'Sdk')       { $result.Sdk       = Update-MsixSdkTool }
    if ($Skip -notcontains 'Psf')       { $result.Psf       = Update-MsixPsfBinary }
    if ($Skip -notcontains 'Procmon')   { $result.Procmon   = Update-MsixProcMon }
    if ($Skip -notcontains 'DebugView') { $result.DebugView = Update-MsixDebugView }
    if ($Skip -notcontains 'MsixMgr')   { $result.MsixMgr   = Update-MsixMgr }
    if ($Skip -notcontains 'Runtime')   { $result.Runtime   = Update-MsixAppRuntime }
    return [pscustomobject]$result
}


# Backward-compatible plural aliases
Set-Alias Install-MsixPsfBinaries Install-MsixPsfBinary
Set-Alias Update-MsixPsfBinaries Update-MsixPsfBinary
Set-Alias Install-MsixSdkTools Install-MsixSdkTool
Set-Alias Update-MsixSdkTools Update-MsixSdkTool