Private/Get-Win32ToolkitRequirementRule.ps1

function Get-Win32ToolkitRequirementRule {
    <#
    .SYNOPSIS
        Builds an Intune Win32 "requirement rule" (PowerShell script) that is met only when the app is
        ALREADY installed — used by the update app so it targets only devices that already have it.
    .DESCRIPTION
        Returns a #microsoft.graph.win32LobAppPowerShellScriptRequirement object for the app described
        by SupportFiles\AppConfig.json. The embedded (base64) script is a presence check: it writes 1
        to STDOUT and exits 0 when the app is found, otherwise it exits 1. Intune treats "exit 0 +
        STDOUT = 1" (detectionType integer, operator equal, value 1) as "requirement met", and a
        non-zero exit as "not met" — so the update app is applicable only where the app already exists.
 
        Presence uses EXACT/LITERAL, version-agnostic signals only, so it never matches unrelated
        products and never misses due to wildcard characters in the name:
          1. the install tattoo key (Test-Path -LiteralPath; the primary signal for EXE apps),
          2. the MSI UpgradeCode (version-stable) via WindowsInstaller RelatedProducts — read from the
             MSI in Files\ so MSI Zero-Config apps (empty App.Name, no tattoo) are covered,
          3. any captured MSI product code (Test-Path -LiteralPath), and
          4. an exact Add/Remove-Programs DisplayName -eq the clean App.Name (or the MSI ProductName).
        A first-word substring "-like" match is deliberately NOT used (it matched e.g. every
        "Microsoft *" product). If none of a tattoo key, an UpgradeCode, or a name is available, no
        reliable cross-version signal exists and the function returns $null so the caller aborts rather
        than shipping an over- or under-matching update app.
 
        Presence (not version >=) is deliberate: the update app's detection rule already checks the
        exact version, so the requirement only needs to gate on "the app is here to be updated". A
        version-based requirement would make the update never apply (it would only be applicable once
        already up to date).
 
        Untrusted values (app name, product codes) are escaped (ConvertTo-PSSingleQuoted /
        Test-Win32ToolkitProductCode) before being spliced into the generated script, so they are data
        and never a code position — same discipline as New-IntuneRequirementScript. The plaintext script
        is also written to SupportFiles\UpdateRequirement.ps1 for transparency.
 
        See knowledge-base/06-intune-packaging-and-publishing.md.
    .PARAMETER ProjectPath
        Full path to the PSADT project folder (reads SupportFiles\AppConfig.json).
    .PARAMETER RunAsAccount
        Execution context for the requirement script on the device: 'system' (default) or 'user'.
    .PARAMETER RunAs32Bit
        Run the requirement script in a 32-bit process on 64-bit clients. Default: 64-bit (matches the
        install tattoo, which is written to the native 64-bit hive).
    .OUTPUTS
        [hashtable] the requirement rule, or $null if no reliable presence signal is available.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$ProjectPath,

        [ValidateSet('system', 'user')]
        [string]$RunAsAccount = 'system',

        [switch]$RunAs32Bit
    )

    $cfg       = Get-Win32ToolkitAppConfig -ProjectPath $ProjectPath
    $app       = if ($cfg.PSObject.Properties.Name -contains 'App')       { $cfg.App }       else { $null }
    $uninstall = if ($cfg.PSObject.Properties.Name -contains 'Uninstall') { $cfg.Uninstall } else { $null }

    # ── Collect presence signals (all optional; need at least one) ────────────────
    # DisplayName is the tattoo name (populated even for MSI Zero-Config, where Name is empty); fall back
    # to Name for projects generated before DisplayName existed.
    $appName = if ($app) { if ($app.PSObject.Properties['DisplayName'] -and $app.DisplayName) { $app.DisplayName } else { $app.Name } } else { $null }
    $tattooKey = $null
    if ($app -and $app.ScriptAuthor -and $app.Vendor -and $appName) {
        $tattooKey = "HKLM:\SOFTWARE\$($app.ScriptAuthor)\$($app.Vendor)\$appName"
    }

    $productCodes = @()
    if ($uninstall) {
        foreach ($pc in @($uninstall.ProductCodes)) { if ($pc) { $productCodes += $pc } }
        foreach ($u  in @($uninstall.Uninstallers)) { if ($u.ProductCode) { $productCodes += $u.ProductCode } }
    }
    $productCodes = @($productCodes | Where-Object { Test-Win32ToolkitProductCode $_ } | Select-Object -Unique)

    # MSI: the version-STABLE identity is the UpgradeCode. Read it (and the ProductName) from the MSI in
    # Files\, so an MSI Zero-Config app (empty App.Name, no tattoo) still gets a reliable, version-agnostic
    # presence signal (checked on-device via WindowsInstaller RelatedProducts).
    $upgradeCode = $null
    $msiName     = $null
    $installerType = if (($cfg.PSObject.Properties.Name -contains 'Installer') -and $cfg.Installer) { $cfg.Installer.Type } else { $null }
    if ($installerType -eq 'msi' -or -not ($app -and $app.Name)) {
        $msiFile = Get-ChildItem -Path (Join-Path $ProjectPath 'Files') -Filter '*.msi' -File -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($msiFile) {
            $uc = Get-Win32ToolkitMsiProperty -Path $msiFile.FullName -Property 'UpgradeCode'
            if (Test-Win32ToolkitProductCode $uc) { $upgradeCode = $uc }
            $pn = Get-Win32ToolkitMsiProperty -Path $msiFile.FullName -Property 'ProductName'
            if ($pn) { $msiName = $pn }
        }
    }

    # Version-independent product names for an EXACT (safe) Add/Remove-Programs match: App.DisplayName/Name
    # AND, for MSI, the MSI ProductName (authoritative — Windows Installer writes it verbatim as the ARP
    # DisplayName, and it often differs from the winget name, e.g. 'Notepad++' vs 'Notepad++ (64-bit x64)').
    # Matched with exact equality and never a substring wildcard, so it cannot match unrelated products.
    $cleanNames = @(@($appName; $msiName) | Where-Object { $_ } | Select-Object -Unique)
    $cleanName  = if ($cleanNames.Count) { $cleanNames[0] } else { $null }   # display/reporting name

    # Require a signal that can identify the app across versions: the tattoo key, the MSI UpgradeCode, or a
    # clean name for an exact ARP match. Product codes alone are per-version, so they never gate the rule on
    # their own. If none exist (e.g. an MSI with no UpgradeCode), abort rather than ship a bad rule.
    if (-not $tattooKey -and -not $cleanName -and -not $upgradeCode) {
        Write-Warning 'Cannot build a reliable update requirement rule (no install tattoo, MSI UpgradeCode, or app name). Use the install app, or supersedence.'
        return $null
    }

    # ── Build the presence-check script (untrusted values escaped as data; exact/literal matches only) ──
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add('# win32-toolkit update requirement: MET (exit 0 + STDOUT 1) when the app is already installed.')
    # Suppress non-terminating registry errors so nothing reaches STDERR — Intune treats ANY STDERR as
    # "requirement not met", which would wrongly reject a device that actually has the app.
    $lines.Add('$ErrorActionPreference = ''SilentlyContinue''')
    $lines.Add('$found = $false')
    if ($tattooKey) {
        # -LiteralPath so a name with [ ] * ? (e.g. "App [x64]") is matched literally, not as a wildcard.
        $lines.Add("if (-not `$found -and (Test-Path -LiteralPath '$(ConvertTo-PSSingleQuoted $tattooKey)')) { `$found = `$true }")
    }
    if ($upgradeCode) {
        # MSI UpgradeCode is stable across versions; RelatedProducts lists installed products sharing it.
        $lines.Add('if (-not $found) {')
        $lines.Add(' try {')
        $lines.Add(' $wi = New-Object -ComObject WindowsInstaller.Installer')
        $lines.Add(" if (@(`$wi.RelatedProducts('$(ConvertTo-PSSingleQuoted $upgradeCode)')).Count -gt 0) { `$found = `$true }")
        $lines.Add(' } catch { }')
        $lines.Add('}')
    }
    if ($productCodes.Count -gt 0) {
        $lines.Add('if (-not $found) {')
        $lines.Add(' foreach ($c in @(')
        foreach ($pc in $productCodes) { $lines.Add(" '$(ConvertTo-PSSingleQuoted $pc)'") }
        $lines.Add(' )) {')
        $lines.Add(' if (Test-Path -LiteralPath "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$c") { $found = $true; break }')
        $lines.Add(' if (Test-Path -LiteralPath "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\$c") { $found = $true; break }')
        $lines.Add(' }')
        $lines.Add('}')
    }
    if ($cleanNames.Count -gt 0) {
        # Exact DisplayName equality (no wildcard) against each candidate name (App.DisplayName and, for
        # MSI, the authoritative MSI ProductName) — matches the app at any version, and cannot match
        # unrelated products.
        $lines.Add('if (-not $found) {')
        $lines.Add(' $targets = @(')
        foreach ($n in $cleanNames) { $lines.Add(" '$(ConvertTo-PSSingleQuoted $n)'") }
        $lines.Add(' )')
        $lines.Add(' foreach ($p in @(')
        $lines.Add(" 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',")
        $lines.Add(" 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'")
        $lines.Add(' )) {')
        $lines.Add(' if (Get-ItemProperty -Path $p -ErrorAction SilentlyContinue | Where-Object { $targets -contains $_.DisplayName }) { $found = $true; break }')
        $lines.Add(' }')
        $lines.Add('}')
    }
    $lines.Add('if ($found) { Write-Output 1; exit 0 } else { exit 1 }')
    $scriptText = ($lines -join "`r`n") + "`r`n"

    # Save a plaintext copy for transparency and for the sandbox Update test (which runs it under
    # Windows PowerShell 5.1 — write UTF-8 WITH BOM so non-ASCII names don't mojibake; PS7's
    # Set-Content -Encoding UTF8 writes no BOM).
    try {
        $supportFiles = Join-Path $ProjectPath 'SupportFiles'
        if (-not (Test-Path -LiteralPath $supportFiles)) { New-Item -ItemType Directory -Path $supportFiles -Force | Out-Null }
        [System.IO.File]::WriteAllText((Join-Path $supportFiles 'UpdateRequirement.ps1'), $scriptText, (New-Object System.Text.UTF8Encoding($true)))
    } catch { Write-Verbose "Could not write UpdateRequirement.ps1: $($_.Exception.Message)" }

    # UTF-8 with BOM (Intune-recommended for Win32 requirement/detection scripts), then base64.
    $enc   = New-Object System.Text.UTF8Encoding($true)
    $bytes = $enc.GetPreamble() + $enc.GetBytes($scriptText)
    $b64   = [System.Convert]::ToBase64String($bytes)

    return @{
        '@odata.type'           = '#microsoft.graph.win32LobAppPowerShellScriptRequirement'
        'displayName'           = if ($cleanName) { "$cleanName is installed" } else { 'App is installed' }
        'enforceSignatureCheck' = $false
        'runAs32Bit'            = [bool]$RunAs32Bit
        'runAsAccount'          = $RunAsAccount
        'scriptContent'         = $b64
        'detectionType'         = 'integer'
        'operator'              = 'equal'
        'detectionValue'        = '1'
    }
}