Private/Select-AppDetectionRule.ps1

function Select-AppDetectionRule {
    <#
    .SYNOPSIS
        Builds Intune detection rules from MSI product codes in the package and recent
        Start Menu shortcuts on this machine, letting the user pick them in a grid.
    .DESCRIPTION
        File-based candidates come from the 15 most recently modified Start Menu shortcuts
        on the packaging machine, so the app should be installed locally first. Every row
        has a Source column: 'MSI' (Files\), 'Start Menu' or 'Packager (tested)'. The packager
        row is 'Packager (stale)' with a warning when the script's SHA-256 differs from
        PackageInfo.json's ScriptHash (the script was edited after the test), and
        'Packager (not verified)' when Tests.Verified is false.
 
        When PackageInfo.json (written by PSADT-Packager) has a Detection entry, it is the
        first row. It stays valid when the file does not exist on this machine, because the
        packager tested it and the app may have been uninstalled since. Later MSI and
        shortcut rows that duplicate it are dropped.
 
        Icon: when <PSADTDirectory>\AppIcon.png exists, nothing is extracted. Otherwise the
        icon of the first selected existing file is extracted into a new temporary folder
        (PSADTIntune_<guid> under the temp path), never into the working directory. The
        caller removes that folder.
 
        If nothing is selected, a PLACEHOLDER rule is returned that must be fixed in Intune.
    .OUTPUTS
        [pscustomobject] with DetectionRules (list of rule objects) and IconPath (the
        extracted PNG in the temporary folder, or $null).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)][bool]$RequireAdmin,
        [Parameter(Mandatory)][string]$Context,
        [Parameter(Mandatory)][hashtable]$Paths
    )

    $PossibleDetections = [System.Collections.Generic.List[object]]::new()

    # Tested detection from the packager is the first row
    $packageInfo = Get-PackageInfo -PackageDirectory $Paths.PSADTDirectory
    $packagerDetection = $null
    if ($packageInfo -and $packageInfo.Detection) {
        $d = $packageInfo.Detection
        # The packager stores the script's SHA-256; a different hash means the script was edited after the test
        $packagerSource = 'Packager (tested)'
        $scriptPath = Join-Path -Path $Paths.PSADTDirectory -ChildPath 'Invoke-AppDeployToolkit.ps1'
        if ($packageInfo.ScriptHash -and (Test-Path -LiteralPath $scriptPath -PathType Leaf) -and
            (Get-FileHash -LiteralPath $scriptPath -Algorithm SHA256).Hash -ne [string]$packageInfo.ScriptHash) {
            $packagerSource = 'Packager (stale)'
            Write-Warning 'Invoke-AppDeployToolkit.ps1 changed after PackageInfo.json was written: check the Packager detection row, or retest with Test-PSADTPackage first.'
        }
        elseif ($packageInfo.Tests -and $packageInfo.Tests.Verified -eq $false) {
            $packagerSource = 'Packager (not verified)'
            Write-Warning 'PackageInfo.json says the package is not verified: check the Packager detection row.'
        }
        if ($d.Type -eq 'File' -and $d.File) {
            $packagerDetection = [PSCustomObject]@{
                'Type'         = 'File'
                'Source'       = $packagerSource
                'Property'     = [string]$d.File
                'VersionValue' = if ($d.Version) { [string]$d.Version } else { $null }
                'Path'         = [string]$d.Path
            }
        }
        elseif ($d.Type -eq 'MSI' -and $d.ProductCode) {
            $packagerDetection = [PSCustomObject]@{
                'Type'         = 'MSI'
                'Source'       = $packagerSource
                'Property'     = ([string]$d.ProductCode).Trim()
                'VersionValue' = $null
                'Path'         = $null
            }
        }
        else {
            Write-Warning "Ignoring unsupported or incomplete Detection in PackageInfo.json (Type '$($d.Type)')."
        }
    }
    if ($packagerDetection) {
        $PossibleDetections.Add($packagerDetection)
    }

    # Same rule as the packager row: MSI by product code, File by folder and file name
    function Test-DuplicateOfPackager {
        param($Candidate)
        if (-not $packagerDetection -or $Candidate.Type -ne $packagerDetection.Type) { return $false }
        if ($Candidate.Type -eq 'MSI') {
            return $Candidate.Property -eq $packagerDetection.Property
        }
        $candidateFull = Join-Path ([string]$Candidate.Path).TrimEnd('\') $Candidate.Property
        $packagerFull = Join-Path ([string]$packagerDetection.Path).TrimEnd('\') $packagerDetection.Property
        return $candidateFull -eq $packagerFull
    }

    # MSI detection
    $MSIPaths = Get-ChildItem -LiteralPath (Join-Path $Paths.PSADTDirectory 'Files') -Recurse -File -ErrorAction SilentlyContinue |
        Where-Object { $_.Extension -eq '.msi' }
    foreach ($MSIPath in $MSIPaths) {
        $Property = ([string](Get-MSIMetaData -Path $MSIPath.FullName -Property ProductCode)).Trim()
        $candidate = [PSCustomObject]@{
            'Type'         = 'MSI'
            'Source'       = 'MSI'
            'Property'     = $Property
            'VersionValue' = $null
            'Path'         = $MSIPath.FullName
        }
        if (-not (Test-DuplicateOfPackager -Candidate $candidate)) {
            $PossibleDetections.Add($candidate)
        }
    }

    # File detection via shortcuts
    if ($RequireAdmin) {
        $ShortcutSearchPath = Join-Path -Path $env:ProgramData -ChildPath 'Microsoft\Windows\Start Menu'
    }
    else {
        $ShortcutSearchPath = Join-Path -Path $env:APPDATA -ChildPath 'Microsoft\Windows\Start Menu\Programs'
    }
    $ShortcutPaths = Get-ChildItem -LiteralPath $ShortcutSearchPath -Filter '*.lnk' -Recurse -ErrorAction SilentlyContinue |
        Sort-Object LastWriteTime -Descending |
        Select-Object -First 15

    # Targets under %WINDIR% are never app files: advertised MSI shortcuts resolve to icon stubs in
    # %WINDIR%\Installer, and "Uninstall"/console shortcuts point at msiexec.exe or cmd.exe
    $WindowsPath = $env:windir.TrimEnd('\') + '\'
    $seenTargets = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    $shell = New-Object -ComObject WScript.Shell
    try {
        foreach ($ShortcutPath in $ShortcutPaths) {
            $AppPath = $shell.CreateShortcut($ShortcutPath.FullName).TargetPath
            # Shell-item shortcuts (UWP apps, Control Panel items) have no file target
            if ([string]::IsNullOrWhiteSpace($AppPath)) {
                Write-Verbose "Skipping shortcut without a file target: $($ShortcutPath.FullName)"
                continue
            }
            if ($AppPath.StartsWith($WindowsPath, [System.StringComparison]::OrdinalIgnoreCase)) {
                Write-Verbose "Skipping shortcut to a Windows system file: $($ShortcutPath.FullName) -> $AppPath"
                continue
            }
            if (-not $seenTargets.Add($AppPath)) {
                continue
            }
            if (-not (Test-Path -LiteralPath $AppPath -PathType Leaf)) {
                Write-Warning "Skipping invalid shortcut path: $AppPath"
                continue
            }
            $Name = Split-Path -Path $AppPath -Leaf
            $VersionInfo = (Get-Item -LiteralPath $AppPath).VersionInfo
            $Version = "$($VersionInfo.FileMajorPart).$($VersionInfo.FileMinorPart).$($VersionInfo.FileBuildPart).$($VersionInfo.FilePrivatePart)"
            # Files without version info report 0.0.0.0 - use an Exists rule instead
            if ($Version -notmatch '^\d+(\.\d+)+$' -or $Version -eq '0.0.0.0') {
                $Version = $null
            }
            $candidate = [PSCustomObject]@{
                'Type'         = 'File'
                'Source'       = 'Start Menu'
                'Property'     = $Name
                'VersionValue' = $Version
                'Path'         = Split-Path -Path $AppPath -Parent
            }
            if (-not (Test-DuplicateOfPackager -Candidate $candidate)) {
                $PossibleDetections.Add($candidate)
            }
        }
    }
    finally {
        [System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell) | Out-Null
        $shell = $null
    }

    # User selection
    $selected = @($PossibleDetections | Out-ConsoleGridView -Title 'Select detection rule(s)')

    $DetectionRules = [System.Collections.ArrayList]::new()

    # No selection made - warn and add placeholder
    if ($selected.Count -eq 0) {
        Write-Warning "No detection rule selected. Adding placeholder rule - you MUST update this in Intune before deploying."
        $DetectionRule = New-IntuneWin32AppDetectionRuleFile -Path 'C:\Program Files\PLACEHOLDER' -FileOrFolder 'PLACEHOLDER.exe' -DetectionType Exists -Existence -Check32BitOn64System $false
        $DetectionRules.Add($DetectionRule) | Out-Null
        return [PSCustomObject]@{ DetectionRules = $DetectionRules; IconPath = $null }
    }

    # Multiple MSI selected - error
    if (@($selected | Where-Object { $_.Type -eq 'MSI' }).Count -gt 1) {
        throw "Multiple MSI detection rules selected. Please select only one MSI detection rule."
    }

    # A package-supplied AppIcon.png wins, so nothing is extracted
    $iconNeeded = -not (Test-Path -LiteralPath (Join-Path $Paths.PSADTDirectory 'AppIcon.png') -PathType Leaf)
    $iconPath = $null
    $iconDone = $false
    $iconFolder = $null
    $completed = $false
    try {
        foreach ($item in $selected) {
            if ($item.Type -eq 'MSI') {
                $DetectionRule = New-IntuneWin32AppDetectionRuleMSI -ProductCode $item.Property
            }
            elseif ($item.Type -eq 'File') {
                # Use the first selected existing executable's icon as the app icon
                $itemFile = Join-Path $item.Path $item.Property
                if ($iconNeeded -and -not $iconDone -and (Test-Path -LiteralPath $itemFile -PathType Leaf)) {
                    try {
                        if (-not $iconFolder) {
                            $iconFolder = Join-Path ([System.IO.Path]::GetTempPath()) "PSADTIntune_$([guid]::NewGuid())"
                            New-Item -ItemType Directory -Path $iconFolder -Force | Out-Null
                        }
                        Export-AppIcon -Path $itemFile -Destination $iconFolder -Name 'AppIcon' -Format 'png' | Out-Null
                        $iconDone = $true
                        $extracted = Join-Path $iconFolder 'AppIcon.png'
                        if (Test-Path -LiteralPath $extracted -PathType Leaf) { $iconPath = $extracted }
                    }
                    catch {
                        Write-Warning "Could not extract icon from '$($item.Property)': $_"
                    }
                }
                # Replace dynamic username with %username%
                if ($Context -eq 'user') {
                    $item.Path = $item.Path -replace '(?<=^[A-Z]:\\Users\\)[^\\]+', '%username%'
                }
                $DetectionParams = @{
                    Path                 = $item.Path
                    FileOrFolder         = $item.Property
                    Check32BitOn64System = $false
                }
                if ($null -ne $item.VersionValue) {
                    $DetectionParams['Version'] = $true
                    $DetectionParams['Operator'] = 'Equal'
                    $DetectionParams['VersionValue'] = $item.VersionValue
                }
                else {
                    $DetectionParams['DetectionType'] = 'Exists'
                    $DetectionParams['Existence'] = $true
                }
                $DetectionRule = New-IntuneWin32AppDetectionRuleFile @DetectionParams
            }
            $DetectionRules.Add($DetectionRule) | Out-Null
        }
        $completed = $true
    }
    finally {
        # Nothing extracted, or the run failed: do not leave the temp folder behind
        if ($iconFolder -and (-not $iconPath -or -not $completed)) {
            Remove-Item -LiteralPath $iconFolder -Recurse -Force -ErrorAction SilentlyContinue -WhatIf:$false -Confirm:$false
        }
    }

    return [PSCustomObject]@{ DetectionRules = $DetectionRules; IconPath = $iconPath }
}