Private/Get-Win32DetectionRules.ps1

function Get-Win32DetectionRules {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProjectPath
    )

    # ── Tattoo detection (preferred) ──────────────────────────────────────────────
    # The generated deploy script writes HKLM:\SOFTWARE\<Author>\<Vendor>\<Name>\Version at install
    # (see Set-PSADTDataDrivenScript). Detect on that value so Intune confirms the app is installed
    # AND at the correct version. This is independent of the sandbox capture, so it also covers hard
    # (manual-install) apps. The condition mirrors the deploy-script guard exactly, so the key path
    # here is identical to what the device writes.
    $cfg = Get-Win32ToolkitAppConfig -ProjectPath $ProjectPath
    $app = if ($cfg.PSObject.Properties.Name -contains 'App') { $cfg.App } else { $null }
    # DisplayName is the tattoo/detection name (populated even for MSI Zero-Config, where Name is empty);
    # fall back to Name for projects generated before DisplayName existed.
    $detName = if ($app) { if ($app.PSObject.Properties['DisplayName'] -and $app.DisplayName) { $app.DisplayName } else { $app.Name } } else { $null }
    if ($app -and $app.ScriptAuthor -and $app.Vendor -and $detName -and $app.Version) {
        # Only emit the tattoo rule if the deploy script actually writes the tattoo — otherwise Intune
        # would detect on a key the device never creates. Projects generated before the tattoo (no
        # tattoo block) fall through to the capture-based rules below.
        $deployScript = Join-Path $ProjectPath 'Invoke-AppDeployToolkit.ps1'
        $hasTattoo = (Test-Path -LiteralPath $deployScript) -and
                     ((Get-Content -LiteralPath $deployScript -Raw) -match [regex]::Escape('win32-toolkit install tattoo - records'))
        if ($hasTattoo) {
            $tattooKey = "HKEY_LOCAL_MACHINE\SOFTWARE\$($app.ScriptAuthor)\$($app.Vendor)\$detName"
            Write-Verbose " Detection rule (Registry version): $tattooKey\Version = $($app.Version)"
            return @(
                [ordered]@{
                    '@odata.type'          = '#microsoft.graph.win32LobAppRegistryDetection'
                    'keyPath'              = $tattooKey
                    'valueName'            = 'Version'
                    'detectionType'        = 'version'
                    'operator'             = 'equal'
                    'detectionValue'       = "$($app.Version)"
                    'check32BitOn64System' = $false
                }
            )
        }
        Write-Warning ' Tattoo values are present but the deploy script has no install tattoo — regenerate this project to enable version detection. Falling back to capture-based rules.'
    }
    elseif ($app) {
        $missing = @()
        if (-not $app.ScriptAuthor) { $missing += 'App.ScriptAuthor (regenerate with an org template)' }
        if (-not $app.Vendor)       { $missing += 'App.Vendor (winget Publisher / manual -Publisher)' }
        if (-not $detName)          { $missing += 'App.DisplayName/Name (regenerate to populate)' }
        if (-not $app.Version)      { $missing += 'App.Version' }
        if ($missing.Count) {
            Write-Warning " Tattoo/version detection unavailable — missing $($missing -join ', '). Using capture-based detection."
        }
    }

    # ── Capture-based fallback (MSI Zero-Config / apps without a tattoo) ────────────
    # Newest capture wins (shared selector) — the old first-in-name-order pick could build the
    # detection rule from a STALE capture of a previous version.
    $jsonFile = Get-LatestInstallationCapture -ProjectPath $ProjectPath
    if (-not $jsonFile) {
        Write-Warning ' No InstallationChanges_*.json capture found — no detection rules generated.'
        return @()
    }

    try {
        $data = Get-Content $jsonFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
    }
    catch {
        Write-Warning "Failed to parse $($jsonFile.Name): $($_.Exception.Message)"
        return @()
    }

    # ── Registry detection (preferred) ────────────────────────────────────────────
    # Each NewRegistryKeys item has .Path and .Values properties
    $regKeys = @($data.NewRegistryKeys)
    if ($regKeys.Count -gt 0) {
        # Prefer Uninstall key — most reliable indicator of app presence
        $candidate = $regKeys | Where-Object { $_.Path -match 'Uninstall' } | Select-Object -First 1
        if (-not $candidate) {
            $candidate = $regKeys |
                Where-Object { $_.Path -match 'HKEY_LOCAL_MACHINE\\SOFTWARE' } |
                Select-Object -First 1
        }
        if (-not $candidate) {
            $candidate = $regKeys | Select-Object -First 1
        }

        if ($candidate) {
            # Normalize common registry path abbreviations to full form
            $keyPath = $candidate.Path
            $keyPath = $keyPath -replace '^HKLM\\', 'HKEY_LOCAL_MACHINE\'
            $keyPath = $keyPath -replace '^HKCU\\', 'HKEY_CURRENT_USER\'
            $keyPath = $keyPath -replace '^HKCR\\', 'HKEY_CLASSES_ROOT\'

            Write-Verbose " Detection rule (Registry): $keyPath"
            return @(
                [ordered]@{
                    '@odata.type'          = '#microsoft.graph.win32LobAppRegistryDetection'
                    'keyPath'              = $keyPath
                    'valueName'            = $null
                    'detectionType'        = 'exists'
                    'check32BitOn64System' = $false
                    'operator'             = 'notConfigured'
                    'detectionValue'       = $null
                }
            )
        }
    }

    # ── File system fallback ───────────────────────────────────────────────────────
    $filePaths = @()
    if ($data.NewFiles)     { $filePaths += @($data.NewFiles) }
    if ($data.NewFilePaths) { $filePaths += @($data.NewFilePaths) }

    # Flatten to strings in case items are objects
    $filePaths = $filePaths | ForEach-Object {
        if ($_ -is [string]) { $_ } elseif ($_.Path) { $_.Path } else { $_.ToString() }
    }

    # Choose the best single MACHINE-WIDE file candidate. An app under C:\Tools, C:\ProgramData, D:\Apps,
    # etc. is a valid detection target — the old hard filter to '^C:\Program Files' returned @() for those,
    # leaving Intune with NO detection rule. Rank so Program Files (the most stable location) wins when
    # present, then any other machine-wide absolute path, dropping obvious capture noise.
    #
    # Per-user profile paths are deliberately EXCLUDED (ranked unusable): Intune evaluates detection as
    # SYSTEM on the device, which cannot see another user's profile, and the capture host's account name
    # (e.g. the Windows Sandbox 'WDAGUtilityAccount') is baked into the literal captured path and won't exist
    # on the target. A per-user file rule can therefore NEVER match — worse than no rule, because it makes
    # Intune reinstall the app on every evaluation cycle. For a genuinely per-user app we return @() and the
    # operator adds a detection rule by hand, exactly as before this broadening.
    $rankPath = {
        param($p)
        if ($p -match '^[A-Za-z]:\\Users\\')                 { return 99 } # per-user profile — SYSTEM can't see it
        if ($p -match '^[A-Za-z]:\\Program Files \(x86\)\\') { return 0 }
        if ($p -match '^[A-Za-z]:\\Program Files\\')         { return 1 }
        if ($p -match '^[A-Za-z]:\\')                        { return 2 }  # any other machine-wide absolute path
        if ($p -match '^\\\\')                               { return 3 }  # UNC
        return 99                                                          # relative / unusable
    }
    $noise = '\\(Temp|tmp|Windows\\Temp|Windows\\Installer|\$Recycle\.Bin|INetCache|WinSxS)\\|\\Temp\\'
    $candidate = $filePaths |
        Where-Object { $_ } |
        Where-Object { (& $rankPath $_) -lt 99 } |          # must be an absolute path
        Where-Object { $_ -notmatch $noise } |              # drop temp / installer scratch
        Sort-Object -Property @{ Expression = { & $rankPath $_ } }, @{ Expression = { $_.Length } } |
        Select-Object -First 1

    if ($candidate) {
        $folder   = Split-Path $candidate -Parent
        $fileName = Split-Path $candidate -Leaf
        Write-Verbose " Detection rule (File): $candidate"
        return @(
            [ordered]@{
                '@odata.type'          = '#microsoft.graph.win32LobAppFileSystemDetection'
                'path'                 = $folder
                'fileOrFolderName'     = $fileName
                'detectionType'        = 'exists'
                'check32BitOn64System' = $false
                'operator'             = 'notConfigured'
                'detectionValue'       = $null
            }
        )
    }

    Write-Warning ' No suitable detection rule candidates found.'
    return @()
}