Private/Update-PSADTProcessesToClose.ps1

function Update-PSADTProcessesToClose {
    <#
    .SYNOPSIS
        Writes the ProcessesToClose section of SupportFiles\AppConfig.json from sandbox capture data.
    .DESCRIPTION
        Detects user-launchable executables installed by the app (App Paths registry keys, then a
        DisplayIcon fallback, then EXEs directly under InstallLocation), validates each name
        (Test-Win32ToolkitProcessName), and records them as DATA in AppConfig.json. The data-driven
        deploy script reads them into AppProcessesToClose at runtime — no code is generated.
 
        See knowledge-base/designs/data-driven-generation.md.
    .PARAMETER ProjectPath
        Full path to the PSADT project folder.
    .PARAMETER JsonFilePath
        Full path to the InstallationChanges_*.json capture file.
    #>

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

        [Parameter(Mandatory)]
        [string]$JsonFilePath
    )

    try {
        $data = Get-Content -Path $JsonFilePath -Raw -Encoding UTF8 | ConvertFrom-Json

        $candidates     = [System.Collections.Generic.List[string]]::new()
        $excludePattern = 'uninstall|uninst|setup|install|update|patch|redist'

        # InstallLocation (used as scope filter for the file fallback).
        $installLocation = $null
        foreach ($regKey in $data.NewRegistryKeys) {
            if ($regKey.Path -like '*Uninstall*' -and $regKey.Values -and $regKey.Values.InstallLocation) {
                $installLocation = $regKey.Values.InstallLocation.TrimEnd('\')
                break
            }
        }

        # Source 1 (preferred): App Paths registry keys.
        foreach ($regKey in $data.NewRegistryKeys) {
            if ($regKey.Path -like '*App Paths*' -and $regKey.KeyName -like '*.exe') {
                $procName = [System.IO.Path]::GetFileNameWithoutExtension($regKey.KeyName)
                if ($procName -and $procName -notmatch $excludePattern -and (Test-Win32ToolkitProcessName $procName) -and $procName -notin $candidates) {
                    $candidates.Add($procName)
                }
            }
        }

        # Source 2 (fallback): DisplayIcon from the Uninstall key.
        if ($candidates.Count -eq 0) {
            foreach ($regKey in $data.NewRegistryKeys) {
                if ($regKey.Path -like '*Uninstall*' -and $regKey.Values -and $regKey.Values.DisplayIcon) {
                    $iconPath = $regKey.Values.DisplayIcon -replace ',\d+$', ''
                    $procName = [System.IO.Path]::GetFileNameWithoutExtension($iconPath)
                    if ($procName -and $procName -notmatch $excludePattern -and (Test-Win32ToolkitProcessName $procName) -and $procName -notin $candidates) {
                        $candidates.Add($procName)
                    }
                    break
                }
            }
        }

        # Source 3 (fallback): EXE files directly under InstallLocation.
        if ($candidates.Count -eq 0 -and $installLocation) {
            foreach ($file in $data.NewFiles) {
                if ($file.Type -eq 'File' -and
                    $file.Path -like '*.exe' -and
                    $file.Path.StartsWith($installLocation, [System.StringComparison]::OrdinalIgnoreCase)) {
                    $fileDir = [System.IO.Path]::GetDirectoryName($file.Path)
                    if ($fileDir -ieq $installLocation) {
                        $procName = [System.IO.Path]::GetFileNameWithoutExtension($file.Path)
                        if ($procName -and $procName -notmatch $excludePattern -and (Test-Win32ToolkitProcessName $procName) -and $procName -notin $candidates) {
                            $candidates.Add($procName)
                        }
                    }
                }
            }
        }

        $cfg = Get-Win32ToolkitAppConfig -ProjectPath $ProjectPath

        # MERGE, never clobber. This runs at finalize, AFTER configure may already have written a
        # manifest-derived list (Update-PSADTMsixProcessesToClose): all three sources above are classic
        # Win32 artifacts that an MSIX never writes, so for a package this pass finds nothing — and an
        # unconditional overwrite would have thrown away the known-good 'pwsh' and shipped @().
        # Union also means a capture that misses can never destroy data another source proved.
        $existing = @()
        if ($cfg.PSObject.Properties.Name -contains 'ProcessesToClose') {
            $existing = @($cfg.ProcessesToClose | Where-Object { $_ })
        }
        $merged = [System.Collections.Generic.List[string]]::new()
        foreach ($p in @($existing) + @($candidates)) {
            if ($p -and $p -notin $merged) { $merged.Add($p) }
        }
        $sorted = @($merged | Sort-Object)

        $cfg | Add-Member -NotePropertyName ProcessesToClose -NotePropertyValue $sorted -Force
        Set-Win32ToolkitAppConfig -ProjectPath $ProjectPath -Config $cfg | Out-Null

        $added = @($candidates | Where-Object { $_ -notin $existing })
        if ($sorted.Count -gt 0) {
            $suffix = if ($existing.Count -gt 0 -and $added.Count -eq 0) { ' (unchanged — the capture added nothing)' } else { '' }
            Write-Host "✓ ProcessesToClose data written: $($sorted -join ', ')$suffix" -ForegroundColor Green
        } else {
            Write-Warning 'ProcessesToClose: no user-launchable processes detected, wrote @()'
        }
        return $true
    }
    catch {
        Write-Warning "Failed to write ProcessesToClose data: $($_.Exception.Message)"
        return $false
    }
}