MSIX.UpdateCheck.ps1

# =============================================================================
# PSGallery update notification
# -----------------------------------------------------------------------------
# Tells an interactive operator when a newer MSIX module version is published,
# so field fixes (data-loss guards, security hardening) actually reach the
# people running the module instead of waiting for them to think to check.
#
# Design constraints, in priority order:
# 1. NEVER break or noticeably slow Import-Module. Enterprise CI imports this
# module thousands of times; an import that can hang on a network call is
# worse than no notification at all. Hard timeout, everything swallowed.
# 2. Silent where a message is useless or harmful: CI, non-interactive hosts,
# and any session that opted out.
# 3. At most one network call per day per user, cached on disk.
# 4. Never fail closed on a parse/network/permission problem - the module must
# import identically on an air-gapped machine.
# =============================================================================

$script:MsixUpdateCheckUrl = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id='MSIX'&`$filter=IsLatestVersion%20eq%20true&`$select=Version"

function _MsixUpdateCachePath {
    [OutputType([string])]
    param()
    $root = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { [IO.Path]::GetTempPath() }
    Join-Path -Path (Join-Path -Path $root -ChildPath 'MSIX') -ChildPath 'update-check.json'
}

function _MsixShouldCheckForUpdate {
    <#
    .SYNOPSIS
        Returns $true when an update check is appropriate for this session.
    #>

    [OutputType([bool])]
    param()

    # Explicit opt-out.
    if ($env:MSIX_NO_UPDATE_CHECK) { return $false }

    # Automation: nobody reads it, and it would add a network dependency to
    # every build. These are the standard markers for the major CI systems.
    foreach ($ci in 'CI', 'TF_BUILD', 'GITHUB_ACTIONS', 'JENKINS_URL', 'TEAMCITY_VERSION', 'GITLAB_CI', 'BUILD_BUILDID') {
        if (Get-Item -Path "Env:\$ci" -ErrorAction SilentlyContinue) { return $false }
    }

    # Non-interactive host (scheduled task, remoting job, -NonInteractive).
    try {
        if (-not [Environment]::UserInteractive) { return $false }
    } catch { return $false }

    return $true
}

function _MsixGetLatestPublishedVersion {
    <#
    .SYNOPSIS
        Returns the newest MSIX version on PSGallery, or $null if unavailable.
 
    .DESCRIPTION
        Deliberately does NOT use Find-Module: that pulls in PowerShellGet,
        can be slow, and honours repository configuration we do not control.
        A single REST call with a hard timeout is predictable.
 
        The response is matched with a regex rather than parsed as XML: the
        value needed is one element, and not invoking an XML parser keeps this
        path free of any parser-level concern on untrusted input.
    #>

    [OutputType([version])]
    param([int]$TimeoutSec = 4)

    try {
        $resp = Invoke-WebRequest -Uri $script:MsixUpdateCheckUrl -UseBasicParsing `
                    -TimeoutSec $TimeoutSec -ErrorAction Stop
        $versions = [regex]::Matches([string]$resp.Content, '<d:Version[^>]*>([^<]+)</d:Version>') |
                    ForEach-Object {
                        $v = $null
                        # Strip any prerelease suffix before parsing.
                        if ([version]::TryParse((($_.Groups[1].Value) -split '-')[0], [ref]$v)) { $v }
                    }
        if (-not $versions) { return $null }
        return (@($versions) | Sort-Object -Descending | Select-Object -First 1)
    } catch {
        return $null
    }
}

function _MsixNotifyIfUpdateAvailable {
    <#
    .SYNOPSIS
        Emits a one-line notice when PSGallery has a newer version than the one
        just imported. Fail-silent by contract.
 
    .PARAMETER CurrentVersion
        The version of the module being imported.
 
    .PARAMETER MaxCacheAgeHours
        How long a cached result stays authoritative (default 24h).
    #>

    [OutputType([void])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    param(
        [Parameter(Mandatory)][version]$CurrentVersion,
        [int]$MaxCacheAgeHours = 24
    )

    # Everything here is best-effort. A failure must never surface to the caller
    # or prevent the module from importing.
    try {
        if (-not (_MsixShouldCheckForUpdate)) { return }

        $cachePath = _MsixUpdateCachePath
        $latest    = $null
        $fresh     = $false

        if (Test-Path -LiteralPath $cachePath) {
            try {
                $cache = Get-Content -LiteralPath $cachePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
                $checkedAt = [datetime]::Parse($cache.CheckedAt, [Globalization.CultureInfo]::InvariantCulture)
                if (((Get-Date) - $checkedAt).TotalHours -lt $MaxCacheAgeHours) {
                    $fresh = $true
                    $parsed = $null
                    if ($cache.Latest -and [version]::TryParse([string]$cache.Latest, [ref]$parsed)) { $latest = $parsed }
                }
            } catch {
                # Corrupt or unreadable cache: fall through to a live check.
                $fresh = $false
            }
        }

        if (-not $fresh) {
            $latest = _MsixGetLatestPublishedVersion
            # Record the attempt either way so a persistently offline machine
            # does not retry on every single import.
            try {
                $dir = Split-Path -Parent -Path $cachePath
                if ($dir -and -not (Test-Path -LiteralPath $dir)) {
                    New-Item -ItemType Directory -Path $dir -Force -WhatIf:$false -ErrorAction Stop | Out-Null
                }
                $payload = [ordered]@{
                    CheckedAt = (Get-Date).ToString('o')
                    Latest    = if ($latest) { $latest.ToString() } else { '' }
                } | ConvertTo-Json
                [IO.File]::WriteAllText($cachePath, $payload, [Text.UTF8Encoding]::new($false))
            } catch {
                # A read-only or redirected profile just means no caching; the
                # check still worked. Never surface this.
                Write-MsixLog -Level Debug -Message "Update-check cache could not be written: $($_.Exception.Message)"
            }
        }

        if ($latest -and $latest -gt $CurrentVersion) {
            Write-Information -MessageData ("MSIX $CurrentVersion is installed; $latest is available on PowerShell Gallery. Update with: Update-Module MSIX (silence this with `$env:MSIX_NO_UPDATE_CHECK = '1')") -InformationAction Continue
        }
    } catch {
        # Intentionally silent: an update notice is never worth a failed import.
        Write-MsixLog -Level Debug -Message "Update check skipped: $($_.Exception.Message)"
    }
}