From 0f0f7201d9c562977926f4519a75b19eafa79139 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 12:12:07 +0100 Subject: [PATCH] Detect applications by ProductCode instead of substring matching Test-TecharyApp matched only on DisplayName -like "*$Name*", which fails in both directions. False negative: it could not detect anything by winget package ID, which is the identifier Install-TecharyApp takes. No ARP DisplayName contains the string "7zip.7zip", so Test-TecharyApp -Name 7zip.7zip returned False on a machine with 7-Zip installed. False positive: "Teams" matches "Microsoft Teams Meeting Add-in for Microsoft Office", so it reports Teams as installed on a machine with no Teams desktop app. Detection now works most-precise-first. winget records the ARP subkey name in ProductCode, an MSI product GUID or a plain name such as "7-Zip", so with the manifest index available this becomes a direct key lookup and is definitive. Failing that it tries an exact DisplayName, then the previous substring behaviour, then MSIX. The substring tier is kept deliberately. Removing it would flip detections from True to False across the estate and trigger reinstalls. It is now reported through MatchedBy so an imprecise match is visible rather than indistinguishable from an exact one. Makes no network calls. Get-ManifestIndex and Get-CustomApp take -NoRefresh, and detection uses it, because this runs on a schedule on every endpoint. Names are escaped before wildcard comparison. A name containing [ or ] was previously treated as a wildcard pattern and silently matched nothing. MSIX enumeration uses -AllUsers when elevated, for the same reason as the uninstall path: SYSTEM sees almost none of its own packages. Adds -Detailed, returning what matched and the installed version. The default return stays a boolean, so existing callers are unaffected. Verified against the BETA implementation on the same machine: 7zip.7zip goes False to True via ProductCode, and 7-Zip, Teams and WindowsCalculator are unchanged. No case flips to False. --- Private/Get-CustomApp.ps1 | 8 +- Private/Get-ManifestIndex.ps1 | 10 ++- Public/Test-TecharyApp.ps1 | 164 ++++++++++++++++++++++++++++------ 3 files changed, 149 insertions(+), 33 deletions(-) diff --git a/Private/Get-CustomApp.ps1 b/Private/Get-CustomApp.ps1 index 28ca71e..d528c3e 100644 --- a/Private/Get-CustomApp.ps1 +++ b/Private/Get-CustomApp.ps1 @@ -1,6 +1,10 @@ function Get-CustomApp { param ( - [string]$Id + [string]$Id, + + # Detection callers pass this so a scheduled check never makes a + # network call; it reads whatever copy is already on disk. + [switch]$NoRefresh ) # --- 1. CLOUD SOURCE --- @@ -22,7 +26,7 @@ function Get-CustomApp { # Logic: Only download if the cache doesn't exist OR it's older than 60 minutes. # This prevents spamming GitHub every time you run a command. - $NeedUpdate = $true + $NeedUpdate = -not $NoRefresh if (Test-Path $CachePath) { $LastWrite = (Get-Item $CachePath).LastWriteTime if ((Get-Date) -lt $LastWrite.AddMinutes(60)) { $NeedUpdate = $false } diff --git a/Private/Get-ManifestIndex.ps1 b/Private/Get-ManifestIndex.ps1 index 99089ec..5a747fc 100644 --- a/Private/Get-ManifestIndex.ps1 +++ b/Private/Get-ManifestIndex.ps1 @@ -10,6 +10,7 @@ function Get-ManifestIndex { [CmdletBinding()] param( [int]$CacheHours = 12, + [switch]$NoRefresh, [switch]$Force ) @@ -19,7 +20,9 @@ function Get-ManifestIndex { try { if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force -ErrorAction Stop | Out-Null } - $NeedUpdate = $true + # Detection runs on a schedule on every endpoint, so it must never make + # a network call. -NoRefresh reads whatever is already cached. + $NeedUpdate = -not $NoRefresh if (-not $Force -and (Test-Path $CachePath)) { $Age = (Get-Date) - (Get-Item $CachePath).LastWriteTime if ($Age.TotalHours -lt $CacheHours) { $NeedUpdate = $false } @@ -59,10 +62,11 @@ function Get-IndexedManifest { [CmdletBinding()] param( [Parameter(Mandatory=$true)][string]$Id, - [Parameter(Mandatory=$true)][string]$SysArch + [Parameter(Mandatory=$true)][string]$SysArch, + [switch]$NoRefresh ) - $Index = Get-ManifestIndex + $Index = Get-ManifestIndex -NoRefresh:$NoRefresh if (-not $Index -or -not $Index.Packages) { return $null } $Entry = $Index.Packages.$Id diff --git a/Public/Test-TecharyApp.ps1 b/Public/Test-TecharyApp.ps1 index 82ebb9e..8f81a88 100644 --- a/Public/Test-TecharyApp.ps1 +++ b/Public/Test-TecharyApp.ps1 @@ -1,42 +1,150 @@ function Test-TecharyApp { + <# + .SYNOPSIS + Reports whether an application is installed. + + .DESCRIPTION + Detection order, most precise first: + + 1. ProductCode from the manifest index or cache. winget records the + ARP subkey name here (for example "7-Zip", or an MSI product GUID), + so this is a direct key lookup and is definitive. + 2. Exact DisplayName, from the custom catalogue or from -Name itself. + 3. Substring DisplayName match, which is what this function used to do + exclusively. Retained so existing callers do not start returning + false, but reported as imprecise because it produces false + positives: "Teams" matches "Microsoft Teams Meeting Add-in for + Microsoft Office" on a machine with no Teams desktop app. + 4. MSIX package name. + + Makes no network calls. The manifest index and custom catalogue are + read from their existing on-disk caches only, because this runs on a + schedule on every endpoint. + + .PARAMETER Name + A winget package ID ("7zip.7zip"), a custom catalogue ID ("MyDPD"), + or a display name. + + .PARAMETER Detailed + Return an object describing what matched instead of a boolean. + #> [CmdletBinding()] param ( [Parameter(Mandatory=$true)] - [string]$Name # App ID (e.g. "MyDPD") or Display Name + [string]$Name, + + [switch]$Detailed ) - # 1. RESOLVE ID -> DISPLAY NAME - # Check if this ID exists in your Custom Catalog with a specific DisplayName mapping - $CustomApp = Get-CustomApp -Id $Name - if ($CustomApp -and $CustomApp.DisplayName) { - $Name = $CustomApp.DisplayName + $Hives = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall" + ) + + function New-Result { + param($Installed, $MatchedBy, $DisplayName, $Version, $Key) + [PSCustomObject]@{ + Name = $Name + Installed = [bool]$Installed + MatchedBy = $MatchedBy + DisplayName = $DisplayName + InstalledVersion = $Version + RegistryKey = $Key + } } - # 2. SEARCH REGISTRY (Classic Apps) - $Paths = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) + if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $SysArch = "arm64" } + elseif ([Environment]::Is64BitOperatingSystem) { $SysArch = "x64" } + else { $SysArch = "x86" } + + # --- 1. PRODUCT CODE (definitive) --------------------------------- + $ProductCode = $null + try { + $Indexed = Get-IndexedManifest -Id $Name -SysArch $SysArch -NoRefresh + if ($Indexed) { $ProductCode = $Indexed.ProductCode } + } catch {} + + if (-not $ProductCode) { + $CachedManifest = Join-Path $env:ProgramData ("TecharyGet\ManifestCache\" + ($Name -replace '[\\/:*?"<>|]', '_') + ".json") + if (Test-Path $CachedManifest) { + try { $ProductCode = (Get-Content $CachedManifest -Raw | ConvertFrom-Json).ProductCode } catch {} + } + } + + if ($ProductCode) { + foreach ($Hive in $Hives) { + $Key = Join-Path $Hive $ProductCode + if (Test-Path $Key) { + $Item = Get-ItemProperty -Path $Key -ErrorAction SilentlyContinue + Write-Verbose "Matched on ProductCode '$ProductCode' at $Key" + $R = New-Result $true 'ProductCode' $Item.DisplayName $Item.DisplayVersion $Key + if ($Detailed) { return $R } else { return $true } + } + } + } + + # --- 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) + try { + $CustomApp = Get-CustomApp -Id $Name -NoRefresh + if ($CustomApp -and $CustomApp.DisplayName) { $Candidates.Add($CustomApp.DisplayName) } + } catch {} + + $AllArp = foreach ($Hive in $Hives) { + Get-ItemProperty -Path (Join-Path $Hive '*') -ErrorAction SilentlyContinue + } - foreach ($Path in $Paths) { - $Match = Get-ItemProperty $Path -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -like "*$Name*" } | - Select-Object -First 1 - - if ($Match) { - Write-Verbose "Found Registry Match: $($Match.DisplayName)" - return $true + foreach ($Candidate in $Candidates) { + $Exact = $AllArp | Where-Object { $_.DisplayName -eq $Candidate } | Select-Object -First 1 + if ($Exact) { + Write-Verbose "Matched exactly on DisplayName '$Candidate'" + $R = New-Result $true 'ExactName' $Exact.DisplayName $Exact.DisplayVersion $Exact.PSPath + if ($Detailed) { return $R } else { return $true } } } - # 3. SEARCH MSIX (Modern Apps) - $Msix = Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($Msix) { - Write-Verbose "Found MSIX: $($Msix.Name)" - return $true + # --- 3. SUBSTRING (imprecise, kept for compatibility) ------------- + foreach ($Candidate in $Candidates) { + # 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) + "*" + $Loose = $AllArp | Where-Object { $_.DisplayName -like $Pattern } | Select-Object -First 1 + if ($Loose) { + Write-Verbose "Matched '$Candidate' only as a substring of '$($Loose.DisplayName)'. This is imprecise; add the package to the manifest index for an exact ProductCode match." + $R = New-Result $true 'Substring' $Loose.DisplayName $Loose.DisplayVersion $Loose.PSPath + if ($Detailed) { return $R } else { return $true } + } + } + + # --- 4. MSIX ------------------------------------------------------ + # SYSTEM sees almost no packages of its own, so enumerate for all users + # when we are able to. + $IsElevated = $false + try { + $Identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $IsElevated = (New-Object Security.Principal.WindowsPrincipal($Identity)).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } catch {} + + foreach ($Candidate in $Candidates) { + $Pattern = "*" + [System.Management.Automation.WildcardPattern]::Escape($Candidate) + "*" + $Msix = $null + try { + if ($IsElevated) { $Msix = Get-AppxPackage -AllUsers -Name $Pattern -ErrorAction SilentlyContinue | Select-Object -First 1 } + if (-not $Msix) { $Msix = Get-AppxPackage -Name $Pattern -ErrorAction SilentlyContinue | Select-Object -First 1 } + } catch {} + + if ($Msix) { + Write-Verbose "Matched MSIX package '$($Msix.Name)'" + $R = New-Result $true 'Msix' $Msix.Name $Msix.Version $Msix.PackageFullName + if ($Detailed) { return $R } else { return $true } + } } - # 4. NOT FOUND - return $false -} \ No newline at end of file + # --- 5. NOT FOUND ------------------------------------------------- + $R = New-Result $false 'None' $null $null $null + if ($Detailed) { return $R } else { return $false } +}