Private/New-IntuneRequirementScript.ps1

function New-IntuneRequirementScript {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [string]$ProjectPath,
        [string]$JsonFilePath
    )

    try {
        # Parse JSON using exact logic from Create-IntuneRequirement.ps1
        Write-Verbose "Parsing JSON data..."
        $jsonContent = Get-Content -Path $JsonFilePath -Raw -Encoding UTF8 | ConvertFrom-Json
        
        # Extract application info using exact logic
        $appEntries = @()
        $productCodes = @()
        
        # Handle different JSON structures
        if ($jsonContent.Values -and $jsonContent.Values.PSObject.Properties) {
            # Direct Values structure
            foreach ($prop in $jsonContent.Values.PSObject.Properties) {
                $entry = $prop.Value
                if ($entry.DisplayName) {
                    $appEntries += $entry
                    if ($entry.UninstallString -and $entry.UninstallString -match '\{[A-F0-9-]{36}\}') {
                        $productCodes += $matches[0]
                    }
                }
            }
        } elseif ($jsonContent.NewRegistryKeys) {
            # NewRegistryKeys structure - search for Values within registry keys
            foreach ($regItem in $jsonContent.NewRegistryKeys) {
                if ($regItem.Values -and $regItem.Values.DisplayName) {
                    $entry = $regItem.Values
                    $appEntries += $entry
                    if ($entry.UninstallString -and $entry.UninstallString -match '\{[A-F0-9-]{36}\}') {
                        $productCodes += $matches[0]
                    }
                    if ($regItem.Path -and $regItem.Path -match '\{[A-F0-9-]{36}\}') {
                        $productCodes += $matches[0]
                    }
                }
            }
        } elseif ($jsonContent.NewPrograms -and $jsonContent.NewPrograms.Count -gt 0) {
            # NewPrograms array structure
            foreach ($prog in $jsonContent.NewPrograms) {
                if ($prog.DisplayName) {
                    $appEntries += $prog
                    if ($prog.UninstallString -and $prog.UninstallString -match '\{[A-F0-9-]{36}\}') {
                        $productCodes += $matches[0]
                    }
                }
            }
        }

        # If no entries found from JSON, fall back to the YAML manifest in the Files folder
        $appName = $null; $appVersion = $null; $publisher = $null
        if ($appEntries.Count -gt 0) {
            $mainApp    = $appEntries[0]
            $appName    = $mainApp.DisplayName
            $appVersion = $mainApp.DisplayVersion
            $publisher  = $mainApp.Publisher
            Write-Verbose "Extracted info for: $appName"
            if ($appVersion) { Write-Verbose " Version: $appVersion" }
            if ($publisher)  { Write-Verbose " Publisher: $publisher" }
            Write-Verbose " Registry Entries: $($appEntries.Count)"
            if ($productCodes.Count -gt 0) { Write-Verbose " Product Codes: $($productCodes.Count)" }
        } else {
            # Fallback: read the winget manifest via the shared resolver.
            # This used to hand-pick the alphabetically-FIRST *.yaml, which is the *.installer.yaml — and the
            # installer manifest carries no PackageName/Publisher (they live in the version/locale manifest).
            # So $appName stayed $null, the function returned $false, and NO requirement script was written at
            # all whenever the capture produced no program entries. Get-YAMLInstallerInfo reads the right file
            # for each field (and with an explicit encoding).
            $yamlInfo = Get-YAMLInstallerInfo -FilesPath (Join-Path $ProjectPath 'Files')
            if ($yamlInfo) {
                if ($yamlInfo.PackageName)    { $appName    = $yamlInfo.PackageName }
                if ($yamlInfo.PackageVersion) { $appVersion = $yamlInfo.PackageVersion }
                if ($yamlInfo.Publisher)      { $publisher  = $yamlInfo.Publisher }
                if ($yamlInfo.ProductCode)    { $productCodes += $yamlInfo.ProductCode }
                if ($appName) {
                    Write-Warning "JSON had no program entries — using the winget manifest as fallback"
                    Write-Verbose " App: $appName Version: $appVersion"
                    if ($productCodes.Count -gt 0) { Write-Verbose " Product Code: $($productCodes[0])" }
                }
            }
            if (-not $appName) {
                Write-Warning "No application entries found in JSON file and no YAML fallback available"
                return $false
            }
        }
        
        # Generate requirement script using exact logic from original
        Write-Verbose "Generating requirement script..."

        # Untrusted values (DisplayName/version from the capture JSON or YAML) are emitted
        # into single-quoted literals in the generated requirement script — escape them
        # (ConvertTo-PSSingleQuoted), and keep only strict-GUID product codes.
        #
        # Matching is on the FULL DisplayName (exact equality) and the MSI product code — never on the
        # first token of the name. The old `-like "*$($appName.Split(' ')[0])*"` made "Microsoft Teams"
        # match ANY Add/Remove-Programs entry containing "Microsoft" (Edge, Office, Visual C++ …), i.e. a
        # false-positive "installed". Same discipline as Get-Win32ToolkitRequirementRule.
        $appNameSq    = ConvertTo-PSSingleQuoted $appName
        $appVersionSq = ConvertTo-PSSingleQuoted $appVersion
        $productCodes = @($productCodes | Where-Object { Test-Win32ToolkitProductCode $_ })

        # A COMMENT is a code context too. ConvertTo-PSSingleQuoted protects a single-quoted LITERAL; it does
        # nothing for text emitted inside <# ... #>. A DisplayName containing '#>' would CLOSE the comment block
        # and everything after it would become top-level code in a script Intune runs as SYSTEM. Neutralise the
        # comment terminator and flatten newlines before the name goes anywhere near the header.
        $appNameCmt = (($appName -replace '#>', '#_>') -replace '[\r\n]+', ' ').Trim()

        $requirementScript = @"
<#
.SYNOPSIS
    Intune Win32 App Requirement Script for $appNameCmt
.DESCRIPTION
    Checks if $appNameCmt is installed on the device.
    Generated automatically from InstallationChanges data on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
.NOTES
    This script should return exit code 0 if the requirement is met (app is installed)
    and exit code 1 if the requirement is not met (app is not installed or wrong version)
#>
 
try {
    `$appFound = `$false
    `$installedVersion = `$null
    `$requiredVersion = '$appVersionSq'
     
    # Registry paths to check for installed applications
    `$registryPaths = @(
        'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
        'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
        'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
    )
     
"@


        # Add product code checks if available
        if ($productCodes.Count -gt 0) {
            $requirementScript += @"
    # Check specific product codes first
    `$productCodes = @(
"@

            foreach ($pc in $productCodes) {
                $requirementScript += " '$(ConvertTo-PSSingleQuoted $pc)'`n"
            }
            $requirementScript += @"
    )
     
    foreach (`$productCode in `$productCodes) {
        # The product code IS the identity - an Uninstall key under it means THIS product is installed.
        # (-LiteralPath: never treat the key path as a wildcard pattern.)
        `$msiPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\`$productCode"
        if (Test-Path -LiteralPath `$msiPath) {
            `$msiApp = Get-ItemProperty -LiteralPath `$msiPath -ErrorAction SilentlyContinue
            if (`$msiApp) {
                `$appFound = `$true
                `$installedVersion = `$msiApp.DisplayVersion
                Write-Host "Found via MSI Product Code: `$(`$msiApp.DisplayName) v`$installedVersion"
                break
            }
        }
    }
     
"@

        }

        # Add registry search for application name
        $requirementScript += @"
    # Search by application name if not found via product code.
    # EXACT DisplayName equality on the FULL name - a substring/first-token match would report e.g.
    # 'Microsoft Edge' as a hit for 'Microsoft Teams'. Equality also needs no wildcard escaping, so a
    # name containing [ ] * ? still matches literally.
    if (-not `$appFound) {
        foreach (`$regPath in `$registryPaths) {
            try {
                `$apps = Get-ItemProperty -Path `$regPath -ErrorAction SilentlyContinue | Where-Object {
                    `$_.DisplayName -eq '$appNameSq'
                }
 
                foreach (`$app in `$apps) {
                    if (`$app.DisplayName) {
                        `$appFound = `$true
                        `$installedVersion = `$app.DisplayVersion
                        Write-Host "Found application: `$(`$app.DisplayName)"
                        if (`$installedVersion) {
                            Write-Host "Installed Version: `$installedVersion"
                        }
"@


        # Add version comparison logic if we have a version to compare
        if ($appVersion) {
            $requirementScript += @"
                         
                        # Compare versions if available
                        if (`$installedVersion -and `$requiredVersion) {
                            try {
                                `$installedVer = [System.Version]`$installedVersion
                                `$requiredVer = [System.Version]`$requiredVersion
                                 
                                if (`$installedVer -ge `$requiredVer) {
                                    Write-Host "Version requirement met: `$installedVersion >= `$requiredVersion"
                                    exit 0
                                } else {
                                    Write-Host "Version requirement NOT met: `$installedVersion < `$requiredVersion"
                                    exit 1
                                }
                            } catch {
                                # Version parsing failed, assume requirement is met if app is found
                                Write-Host "Version comparison failed, but application is installed"
                                exit 0
                            }
                        } else {
                            # No version info, just check if installed
                            Write-Host "Application found (no version comparison)"
                            exit 0
                        }
"@

        } else {
            $requirementScript += @"
                         
                        # No specific version required, app is installed
                        Write-Host "Application found"
                        exit 0
"@

        }

        $requirementScript += @"
                        break
                    }
                }
            } catch {
                # Continue checking other registry paths
                continue
            }
        }
    }
     
    # Final check - if app was found but no version comparison was done
    if (`$appFound) {
        Write-Host "Application is installed"
        exit 0
    } else {
        Write-Host "Application not found - requirement NOT met"
        exit 1
    }
     
} catch {
    Write-Host "Error during requirement check: `$(`$_.Exception.Message)"
    exit 1
}
"@


        # Save requirement script using exact logic from original
        $supportFilesPath = Join-Path $ProjectPath "SupportFiles"
        if (-not (Test-Path $supportFilesPath)) {
            New-Item -Path $supportFilesPath -ItemType Directory -Force | Out-Null
            Write-Verbose "Created SupportFiles directory"
        }
        
        $defaultPath = Join-Path $supportFilesPath "RequirementScript.ps1"

        # UTF-8 WITH BOM: Intune runs requirement scripts with Windows PowerShell 5.1, which decodes a
        # BOM-less file as ANSI — a non-ASCII DisplayName ('Café', 'Nagüi') would mojibake and the
        # DisplayName comparison would silently never match. PS7's Set-Content -Encoding UTF8 writes NO
        # BOM, so write the bytes ourselves (same as Get-Win32ToolkitRequirementRule).
        [System.IO.File]::WriteAllText($defaultPath, ($requirementScript + "`r`n"), (New-Object System.Text.UTF8Encoding($true)))
        Write-Host "`n✓ SUCCESS: Requirement script saved to:" -ForegroundColor Green
        Write-Host " $defaultPath" -ForegroundColor White
        
        Write-Host "`nUsage Instructions:" -ForegroundColor Cyan
        Write-Host "1. Copy the generated PowerShell script content" -ForegroundColor White
        Write-Host "2. In Intune, go to your Win32 app > Requirements" -ForegroundColor White
        Write-Host "3. Add requirement rule: 'Script'" -ForegroundColor White
        Write-Host "4. Paste the script content" -ForegroundColor White
        Write-Host "5. Set 'Run script as 32-bit process': No" -ForegroundColor White
        Write-Host "6. Set 'Enforce script signature check': No" -ForegroundColor White
        
        return $true
    }
    catch {
        Write-Warning "Failed to create requirement script: $($_.Exception.Message)"
        return $false
    }
}