Private/DFCatalog.Winget.ps1

#Requires -Version 7.0

# Winget catalog provider (snapshot kind). The winget CLI's slowness is process
# startup + MSIX verification, not the search — the catalog is just a SQLite
# database inside the locally cached source.msix. We extract Public/index.db
# once (keyed on msix mtime+size) and query it directly via winsqlite3.dll in
# milliseconds. Fallback when the schema is unreadable: parse `winget search`
# CLI output, cached through the query-cache engine (degraded mode).
#
# Verified index schema (V2): packages(id, name, moniker, latest_version) plus
# a metadata table carrying majorVersion. installed.db uses the V1 normalized
# layout (manifest joined to ids/names/versions). index.db carries no
# description/homepage/license — the merged card fills those from other catalogs.

function Get-DFCatalogWingetMsixPath {
    <#
    .SYNOPSIS
        Returns the expected location of winget's cached community source msix.
    #>

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

    Join-Path $Env:LOCALAPPDATA 'Microsoft\WinGet\State\defaultState\Microsoft.PreIndexed.Package\Microsoft.Winget.Source_8wekyb3d8bbwe\source.msix'
}

function Update-DFCatalogWingetIndex {
    <#
    .SYNOPSIS
        Extracts Public/index.db from winget's source.msix into the catalog
        cache, keyed on the msix's size + mtime so unchanged sources are never
        re-extracted.
    .PARAMETER MsixPath
        The source.msix location (defaults to the standard winget cache path).
    .PARAMETER Force
        Re-extract even when the msix is unchanged.
    #>

    [CmdletBinding()]
    param(
        [string]$MsixPath = (Get-DFCatalogWingetMsixPath),

        [switch]$Force
    )

    $cacheRoot = Get-DFCatalogCacheRoot
    if (-not $cacheRoot) { return }
    if (-not (Test-Path $MsixPath)) { return }

    $dir = Join-Path $cacheRoot 'winget'
    $indexFile = Join-Path $dir 'index.db'
    $metaFile = Join-Path $dir 'index.meta.json'
    $msix = Get-Item $MsixPath

    if (-not $Force -and (Test-Path $indexFile) -and (Test-Path $metaFile)) {
        try {
            $meta = Get-Content $metaFile -Raw | ConvertFrom-Json
            if ($meta.length -eq $msix.Length -and $meta.lastWriteTicks -eq $msix.LastWriteTimeUtc.Ticks) {
                return
            }
        } catch {}
    }

    New-DFDirectory $dir
    Add-Type -AssemblyName System.IO.Compression.FileSystem
    $zip = [System.IO.Compression.ZipFile]::OpenRead($msix.FullName)
    try {
        $entry = $zip.Entries | Where-Object FullName -eq 'Public/index.db' | Select-Object -First 1
        if (-not $entry) {
            Write-Verbose "DotForge: no Public/index.db inside $MsixPath"
            return
        }
        $tmp = "$indexFile.tmp.$PID"
        [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $tmp, $true)
        Move-Item -Path $tmp -Destination $indexFile -Force
    } finally {
        $zip.Dispose()
    }

    @{
        path           = $msix.FullName
        lastWriteTicks = $msix.LastWriteTimeUtc.Ticks
        length         = $msix.Length
    } | ConvertTo-Json | Set-Content -Path $metaFile -Encoding UTF8
}

function Get-DFCatalogWingetIndexPath {
    <#
    .SYNOPSIS
        Ensures the extracted winget index is current and returns its path,
        or $null when unavailable.
    .PARAMETER Force
        Force re-extraction first.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [switch]$Force
    )

    $cacheRoot = Get-DFCatalogCacheRoot
    if (-not $cacheRoot) { return $null }

    Update-DFCatalogWingetIndex -Force:$Force
    $indexFile = Join-Path $cacheRoot 'winget/index.db'
    (Test-Path $indexFile) ? $indexFile : $null
}

function Invoke-DFCatalogWingetCli {
    <#
    .SYNOPSIS
        Runs `winget search` and returns the raw output lines. Mockable seam
        for the CLI-parse fallback.
    .PARAMETER Query
        The search query.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Query
    )

    if (-not (Get-Command winget -ErrorAction Ignore)) {
        throw 'winget.exe is not available'
    }
    winget search --query $Query --source winget --disable-interactivity 2>$null
}

function Invoke-DFCatalogWingetCliSearch {
    <#
    .SYNOPSIS
        Degraded-mode winget search: parses the CLI's fixed-width column output
        using the header line's column offsets.
    .PARAMETER Query
        Normalized query text.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Query
    )

    $lines = @(Invoke-DFCatalogWingetCli -Query $Query)

    $headerIndex = -1
    for ($i = 0; $i -lt $lines.Count; $i++) {
        if ($lines[$i] -match '^Name\s+Id\s+Version') { $headerIndex = $i; break }
    }
    if ($headerIndex -lt 0 -or $headerIndex + 2 -ge $lines.Count) { return @() }

    $header = $lines[$headerIndex]
    $idColumn = $header.IndexOf('Id')
    $versionColumn = $header.IndexOf('Version')

    foreach ($line in $lines[($headerIndex + 2)..($lines.Count - 1)]) {
        if ([string]::IsNullOrWhiteSpace($line) -or $line.Length -le $idColumn) { continue }

        $name = $line.Substring(0, $idColumn).Trim()
        $rest = $line.Substring($idColumn)
        $idWidth = $versionColumn - $idColumn
        $id = ($rest.Length -gt $idWidth) ? $rest.Substring(0, $idWidth).Trim() : $rest.Trim()
        $version = ($line.Length -gt $versionColumn) ? (($line.Substring($versionColumn) -split '\s{2,}')[0]).Trim() : ''
        if (-not $id) { continue }

        New-DFToolSourceInfo -Source 'winget' `
            -PackageId $id `
            -Name ($name ? $name : $id) `
            -LatestVersion $version `
            -MatchKind (($id -ieq $Query -or $name -ieq $Query) ? 'exact-id' : 'keyword')
    }
}

function Search-DFCatalogWinget {
    <#
    .SYNOPSIS
        Searches the winget catalog: direct SQLite on the extracted index
        (milliseconds), degrading to cached CLI parsing when the index or its
        schema is unreadable.
    .PARAMETER Query
        Name, keywords, package id, or moniker (e.g. 'rg').
    .PARAMETER IndexPath
        Override the extracted index.db location (tests).
    .PARAMETER Fresh
        Force re-extraction / a live CLI fetch.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Query,

        [string]$IndexPath,

        [switch]$Fresh
    )

    $normalized = (ConvertTo-DFCatalogQueryKey -Query $Query).Normalized
    $ageMinutes = 0

    if (-not $IndexPath) {
        $IndexPath = Get-DFCatalogWingetIndexPath -Force:$Fresh
        if ($IndexPath) {
            $msixPath = Get-DFCatalogWingetMsixPath
            if (Test-Path $msixPath) {
                $ageMinutes = [int]([datetime]::UtcNow - (Get-Item $msixPath).LastWriteTimeUtc).TotalMinutes
            }
        }
    }

    if ($IndexPath) {
        # A readable metadata table proves the db is queryable, so an empty
        # search result below is a real no-match, not a failure.
        $major = @(Invoke-DFSqliteQuery -Database $IndexPath -Query "SELECT value FROM metadata WHERE name = 'majorVersion'")
        if ($major -and $major[0].value -eq '2') {
            $safe = $normalized -replace "'", "''"
            $words = $safe -split ' '
            $conditions = foreach ($word in $words) { "(lower(id) || ' ' || lower(name)) LIKE '%$word%'" }
            $where = $conditions -join ' AND '
            if ($words.Count -eq 1) { $where = "($where) OR lower(moniker) = '$safe'" }

            $exact = "lower(id) = '$safe' OR lower(name) = '$safe' OR lower(moniker) = '$safe'"
            $rows = @(Invoke-DFSqliteQuery -Database $IndexPath `
                -Query "SELECT id, name, moniker, latest_version FROM packages WHERE $where ORDER BY CASE WHEN $exact THEN 0 ELSE 1 END, id LIMIT 25")

            $timestamp = [datetime]::UtcNow.AddMinutes(-$ageMinutes)
            foreach ($row in $rows) {
                if (-not $row.id) { continue }
                $kind = if ($row.id.ToLowerInvariant() -eq $normalized) { 'exact-id' }
                        elseif (($row.name -and $row.name.ToLowerInvariant() -eq $normalized) -or
                                ($row.moniker -and $row.moniker.ToLowerInvariant() -eq $normalized)) { 'exact-name' }
                        else { 'keyword' }
                New-DFToolSourceInfo -Source 'winget' `
                    -PackageId $row.id `
                    -Name ($row.name ? $row.name : $row.id) `
                    -LatestVersion ([string]$row.latest_version) `
                    -MatchKind $kind `
                    -CacheTimestamp $timestamp `
                    -CacheAgeMinutes $ageMinutes
            }
            return
        }
        Write-Verbose 'DotForge: winget index schema unreadable — degrading to CLI parse'
    }

    Search-DFCatalogQueryCache -Provider 'winget' -Query $Query -Fresh:$Fresh `
        -Fetch { param($q) Invoke-DFCatalogWingetCliSearch -Query $q }
}

function Get-DFCatalogWingetInstalledDbPath {
    <#
    .SYNOPSIS
        Locates winget's installed-package tracking database. Mockable seam.
    #>

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

    $candidates = @(
        (Join-Path $Env:LOCALAPPDATA 'Microsoft\WinGet\State\defaultState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db'),
        (Join-Path $Env:LOCALAPPDATA 'Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db')
    )
    foreach ($candidate in $candidates) {
        if (Test-Path $candidate) { return $candidate }
    }
    $null
}

function Get-DFCatalogWingetInstalled {
    <#
    .SYNOPSIS
        Lists winget-installed packages by querying the V1-layout installed.db
        (manifest joined to ids/names/versions) — no winget.exe invocation.
    #>

    [CmdletBinding()]
    param()

    $dbPath = Get-DFCatalogWingetInstalledDbPath
    if (-not $dbPath) { return }

    $rows = Invoke-DFSqliteQuery -Database $dbPath -Query @'
SELECT i.id AS pkg_id, n.name AS pkg_name, v.version AS pkg_version
FROM manifest m
JOIN ids i ON m.id = i.rowid
JOIN names n ON m.name = n.rowid
JOIN versions v ON m.version = v.rowid
'@


    foreach ($row in @($rows)) {
        if (-not $row.pkg_id) { continue }
        [pscustomobject]@{
            Source           = 'winget'
            Name             = $row.pkg_name ? $row.pkg_name : $row.pkg_id
            PackageId        = $row.pkg_id
            InstalledVersion = [string]$row.pkg_version
        }
    }
}

function Invoke-DFCatalogWingetShowCli {
    <#
    .SYNOPSIS
        Runs `winget show` for an exact package id and returns the raw output
        lines. Mockable seam.
    .PARAMETER PackageId
        The winget package id.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$PackageId
    )

    if (-not (Get-Command winget -ErrorAction Ignore)) {
        throw 'winget.exe is not available'
    }
    winget show --id $PackageId --exact --source winget --disable-interactivity 2>$null
}

function ConvertFrom-DFCatalogWingetShow {
    <#
    .SYNOPSIS
        Parses `winget show` Key: value output into a DotForge.ToolSourceDetail.
        Indented lines continue the previous key; the Tags block becomes the
        Tags array. Returns $null when no keys parse (package not found).
        Limitation: key labels are localized — non-English systems degrade to
        an empty parse (null), which the card renders as details-unavailable.
    .PARAMETER Lines
        Raw winget show output lines.
    .PARAMETER PackageId
        The id the lookup was for.
    #>

    [CmdletBinding()]
    param(
        [AllowEmptyCollection()]
        [string[]]$Lines = @(),

        [Parameter(Mandatory)]
        [string]$PackageId
    )

    $map = [ordered]@{}
    $currentKey = $null
    foreach ($line in @($Lines)) {
        if ($null -eq $line) { continue }
        if ($line -cmatch '^([A-Z][A-Za-z ]*?):\s*(.*?)\r?$') {
            $currentKey = $Matches[1].Trim()
            $map[$currentKey] = @()
            if ($Matches[2].Trim()) { $map[$currentKey] = @($Matches[2].Trim()) }
        } elseif ($currentKey -and $line -match '^\s+(\S.*?)\r?$') {
            $map[$currentKey] = @($map[$currentKey]) + $Matches[1].Trim()
        }
    }
    if ($map.Count -eq 0) { return $null }

    $joined = { param($k) (@($map[$k]) -join ' ') }

    New-DFToolSourceDetail -Source 'winget' `
        -PackageId $PackageId `
        -Publisher (& $joined 'Publisher') `
        -Maintainers @(@(& $joined 'Author') -ne '') `
        -Tags @($map['Tags']) `
        -ReleaseNotes (& $joined 'Release Notes') `
        -ReleaseNotesUrl (& $joined 'Release Notes Url') `
        -RepositoryUrl (((& $joined 'Homepage') -match 'github\.com') ? (& $joined 'Homepage') : '') `
        -InstallHint "winget install --id $PackageId --exact" `
        -Notes (& $joined 'Description')
}

function Get-DFCatalogWingetDetail {
    <#
    .SYNOPSIS
        Cache-first winget detail: `winget show` is a slow process spawn, so
        results go through the detail cache engine.
    .PARAMETER PackageId
        The winget package id.
    .PARAMETER Fresh
        Force a live winget show.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$PackageId,

        [switch]$Fresh
    )

    Get-DFCatalogDetailCache -Provider 'winget' -PackageId $PackageId -Fresh:$Fresh -Fetch {
        param($id)
        ConvertFrom-DFCatalogWingetShow -Lines @(Invoke-DFCatalogWingetShowCli -PackageId $id) -PackageId $id
    }
}

if (-not (Get-Variable -Name DFCatalogProviders -Scope Script -ErrorAction Ignore)) { $script:DFCatalogProviders = @{} }
$script:DFCatalogProviders['winget'] = @{
    Name         = 'winget'
    Kind         = 'snapshot'
    Test         = { (Test-Path (Get-DFCatalogWingetMsixPath)) -or [bool](Get-Command winget -ErrorAction Ignore) }
    Search       = { param($Query, $Fresh) Search-DFCatalogWinget -Query $Query -Fresh:$Fresh }
    GetInstalled = { Get-DFCatalogWingetInstalled }
    Refresh      = { param($Query) Update-DFCatalogWingetIndex -Force }
    Detail       = { param($PackageId, $Fresh) Get-DFCatalogWingetDetail -PackageId $PackageId -Fresh:$Fresh }
}