From b312fe23a85c036d34bdf4ac58ec6fd171e65993 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 13:11:03 +0100 Subject: [PATCH 1/3] Index every package, not only those with a product code The detection index excluded packages whose manifests declare neither a product code nor an MSIX package family name, on the grounds that they cannot be matched by code. That also dropped their canonical display name, which is the more broadly useful field: it is what bridges a package ID to its ARP entry, and "Valve.Steam" never matches "Steam" on its own. Those packages were therefore undetectable by any route. Every package now gets an entry. A name-only row costs about 80 bytes. Detection also uses the canonical name from the index as a name candidate. Product codes alone are not sufficient even where the index has them: Chrome's installed product code varies by build, so the three its manifests declare missed a live install of Google Chrome 153.0.8010.52, which the canonical name then matched exactly. --- .github/workflows/build-manifest-index.yml | 14 +++++++++++--- Public/Test-TecharyApp.ps1 | 10 ++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-manifest-index.yml b/.github/workflows/build-manifest-index.yml index bb35293..ddaaf15 100644 --- a/.github/workflows/build-manifest-index.yml +++ b/.github/workflows/build-manifest-index.yml @@ -53,9 +53,11 @@ jobs: # in a YAML workflow file. Emitting pairs and grouping in jq avoids # the question entirely. # - # Packages with neither a product code nor a package family name are - # excluded: they cannot be identified this way, so they are dead - # weight in a file every endpoint downloads. + # Every package gets an entry, including those with neither a product + # code nor a package family name. Those cannot be matched by code, + # but the entry still carries the canonical display name, and that is + # what bridges an ID to its ARP entry: "Valve.Steam" never matches + # "Steam" on its own. A name-only row costs about 80 bytes. sqlite3 "$DB" -json " SELECT p.id AS Id, p.name AS Name, p.latest_version AS Version, 'P' AS Kind, c.productcode AS Value @@ -64,6 +66,12 @@ jobs: SELECT p.id, p.name, p.latest_version, 'F', f.pfn FROM packages p JOIN pfns2 f ON f.package = p.rowid + UNION ALL + SELECT p.id, p.name, p.latest_version, + 'N', NULL + FROM packages p + WHERE NOT EXISTS (SELECT 1 FROM productcodes2 WHERE package = p.rowid) + AND NOT EXISTS (SELECT 1 FROM pfns2 WHERE package = p.rowid) ORDER BY 1;" > pairs.json NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) diff --git a/Public/Test-TecharyApp.ps1 b/Public/Test-TecharyApp.ps1 index 7c0e335..ceec5ea 100644 --- a/Public/Test-TecharyApp.ps1 +++ b/Public/Test-TecharyApp.ps1 @@ -150,6 +150,16 @@ function Test-TecharyApp { if ($CustomApp -and $CustomApp.DisplayName) { $Candidates.Add($CustomApp.DisplayName) } } catch {} + # The detection index carries each package's canonical display name, which + # is what bridges an ID to its ARP entry: "Google.Chrome" never matches + # "Google Chrome" on its own. + # + # This matters more than it looks. Product codes alone are not sufficient + # even when the index has some: Chrome's installed product code varies by + # build, so the three the manifests declare missed a live install that the + # canonical name then matched exactly. + if ($Entry -and $Entry.Name) { $Candidates.Add($Entry.Name) } + $AllArp = foreach ($Hive in $Hives) { Get-ItemProperty -Path (Join-Path $Hive '*') -ErrorAction SilentlyContinue } From e825e4e19cca4a34c160eca8e00bdd5b7312d961 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 13:13:19 +0100 Subject: [PATCH 2/3] Reject architecture and channel folders when picking the latest version A winget package folder can contain siblings that are not versions. Discord carries x86, arm64, Canary, PTB and Development alongside 144 real versions. The version key ranked those as versions because it simply extracted digits: "x86" yields 86, which outranks the leading component of 1.0.9258 and won the sort, so resolution then looked for an installer manifest inside the x86 folder and threw. Discord.Discord could not be installed at all. Version folders start with a digit, so names that do not are now rejected outright. The original implementation on main filtered on ^\d; that guard was lost when the sort was rewritten to handle non-[Version] formats. Verified: Discord.Discord resolves to 1.0.9258, and x86, arm64, Canary, PTB and Development are rejected while 1.0.9258, v2.1 and 20240101 are kept. --- Private/Resolve-GitHubManifest.ps1 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Private/Resolve-GitHubManifest.ps1 b/Private/Resolve-GitHubManifest.ps1 index d69ab32..1ad21e8 100644 --- a/Private/Resolve-GitHubManifest.ps1 +++ b/Private/Resolve-GitHubManifest.ps1 @@ -6,6 +6,13 @@ function Get-ManifestVersionKey { # discarding the failures silently dropped real releases, and threw outright # for packages where no folder happened to parse. Build a zero-padded key so # ordinary string sorting gives correct numeric ordering instead. + # A winget version folder starts with a digit. Siblings like "x86", + # "arm64", "Canary", "PTB" and "Development" are architectures or release + # channels, not versions, and must not be ranked as such: "x86" yields 86, + # which outranks the first component of 1.0.9258 and wins the sort. Discord + # carries all five of those alongside 144 real versions. + if ($Name -notmatch '^v?\d') { return $null } + $Numbers = [regex]::Matches($Name, '\d+') | ForEach-Object { $_.Value } if (-not $Numbers) { return $null } From 34592007bb1ec70c03987c8e9d7c3fb18349fd48 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 13:15:51 +0100 Subject: [PATCH 3/3] Use canonical names for exact matching only Adding the index's canonical display name to the substring tier as well as the exact tier reported applications that are not installed. Those names are short and generic: "Steam" matched the MSIX package MSTeams and reported Valve.Steam as installed on a machine that has never had it, and "Git" would match "GitHub CLI" the same way. Candidates are now split. Exact comparison uses the supplied name, the custom catalogue display name and the index canonical name. Substring and MSIX name matching use only the first two, which is the behaviour before the canonical name was introduced. Verified against ground truth on a real machine, 8 packages, no failures: 7-Zip, Firefox, Chrome, Zoom, Git and Windows Terminal detected as installed, Steam and Discord as not installed. --- Public/Test-TecharyApp.ps1 | 46 +++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/Public/Test-TecharyApp.ps1 b/Public/Test-TecharyApp.ps1 index ceec5ea..823f405 100644 --- a/Public/Test-TecharyApp.ps1 +++ b/Public/Test-TecharyApp.ps1 @@ -143,28 +143,39 @@ function Test-TecharyApp { # --- 2. EXACT DISPLAY NAME ---------------------------------------- # A custom catalogue entry carries the real ARP DisplayName for its ID. - $Candidates = New-Object System.Collections.Generic.List[string] - $Candidates.Add($Name) + # Two lists, because a name safe to compare exactly is not safe to compare + # as a substring. + # + # The detection index carries each package's canonical display name, which + # is what bridges an ID to its ARP entry: "Google.Chrome" never matches + # "Google Chrome" on its own, and product codes alone do not cover it + # because Chrome's installed code varies by build. + # + # Those names are short and generic, so they are used for exact comparison + # ONLY. Feeding them to the substring tier reports anything that merely + # contains them: "Steam" matches the MSIX package "MSTeams", and "Git" + # matches "GitHub CLI". Both were observed. + $ExactCandidates = New-Object System.Collections.Generic.List[string] + $LooseCandidates = New-Object System.Collections.Generic.List[string] + + $ExactCandidates.Add($Name) + $LooseCandidates.Add($Name) + try { $CustomApp = Get-CustomApp -Id $Name -NoRefresh - if ($CustomApp -and $CustomApp.DisplayName) { $Candidates.Add($CustomApp.DisplayName) } + if ($CustomApp -and $CustomApp.DisplayName) { + $ExactCandidates.Add($CustomApp.DisplayName) + $LooseCandidates.Add($CustomApp.DisplayName) + } } catch {} - # The detection index carries each package's canonical display name, which - # is what bridges an ID to its ARP entry: "Google.Chrome" never matches - # "Google Chrome" on its own. - # - # This matters more than it looks. Product codes alone are not sufficient - # even when the index has some: Chrome's installed product code varies by - # build, so the three the manifests declare missed a live install that the - # canonical name then matched exactly. - if ($Entry -and $Entry.Name) { $Candidates.Add($Entry.Name) } + if ($Entry -and $Entry.Name) { $ExactCandidates.Add($Entry.Name) } $AllArp = foreach ($Hive in $Hives) { Get-ItemProperty -Path (Join-Path $Hive '*') -ErrorAction SilentlyContinue } - foreach ($Candidate in $Candidates) { + foreach ($Candidate in $ExactCandidates) { $Exact = $AllArp | Where-Object { $_.DisplayName -eq $Candidate } | Select-Object -First 1 if ($Exact) { Write-Verbose "Matched exactly on DisplayName '$Candidate'" @@ -174,7 +185,9 @@ function Test-TecharyApp { } # --- 3. SUBSTRING (imprecise, kept for compatibility) ------------- - foreach ($Candidate in $Candidates) { + # Loose list only. A canonical name from the index is too generic to + # widen with wildcards. + foreach ($Candidate in $LooseCandidates) { # Escaped: an unescaped name containing [ or ] is a wildcard pattern, # which previously made the comparison silently match nothing. $Pattern = "*" + [System.Management.Automation.WildcardPattern]::Escape($Candidate) + "*" @@ -196,7 +209,10 @@ function Test-TecharyApp { [Security.Principal.WindowsBuiltInRole]::Administrator) } catch {} - foreach ($Candidate in $Candidates) { + # Loose list only, for the same reason: "Steam" as a wildcard matches the + # MSIX package MSTeams, which was reported as Valve.Steam being installed + # on a machine that has never had it. + foreach ($Candidate in $LooseCandidates) { $Pattern = "*" + [System.Management.Automation.WildcardPattern]::Escape($Candidate) + "*" $Msix = $null try {