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 } +}