Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions Private/Get-CustomApp.ps1
Original file line number Diff line number Diff line change
@@ -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 ---
Expand All @@ -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 }
Expand Down
10 changes: 7 additions & 3 deletions Private/Get-ManifestIndex.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ function Get-ManifestIndex {
[CmdletBinding()]
param(
[int]$CacheHours = 12,
[switch]$NoRefresh,
[switch]$Force
)

Expand All @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
164 changes: 136 additions & 28 deletions Public/Test-TecharyApp.ps1
Original file line number Diff line number Diff line change
@@ -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
}
# --- 5. NOT FOUND -------------------------------------------------
$R = New-Result $false 'None' $null $null $null
if ($Detailed) { return $R } else { return $false }
}