Private/Get-Win32ToolkitMsixApplication.ps1

function Get-Win32ToolkitMsixApplication {
    <#
    .SYNOPSIS
        Reads the executables an MSIX/APPX package (or bundle) declares — the processes to close.
 
    .DESCRIPTION
        Host-side (PS 7.2+). An MSIX declares its apps in the manifest:
            <Applications><Application Id="App" Executable="pwsh.exe" ... /></Applications>
        That is the ONLY reliable source for an MSIX, because the capture-based detector
        (Update-PSADTProcessesToClose) looks exclusively for classic Win32 artifacts — App Paths keys,
        the Uninstall key's DisplayIcon, and EXEs under InstallLocation — and an MSIX writes NONE of
        them: it is registry-virtualized and its payload lands in %ProgramFiles%\WindowsApps\<PFN>\.
        So every MSIX silently ended up with ProcessesToClose = @(), and the install never offered to
        close the running app.
 
        Same lesson as the uninstall identity: read it from the manifest at CONFIGURE time and the
        answer is capture-independent.
 
        BUNDLES: a bundle manifest declares NO <Applications> — the apps live in the nested
        per-architecture packages. So for a bundle we open the first nested Type="application" package
        and read its manifest. Every architecture declares the same executables (that is the point of a
        bundle), so which one we pick does not matter. Nested entries are read into a MemoryStream
        first: ZipArchive entry streams are forward-only, and opening a zip-inside-a-zip needs a
        seekable stream.
 
    .PARAMETER Path
        Full path to the .msix/.appx/.msixbundle/.appxbundle file.
 
    .OUTPUTS
        [string[]] process names WITHOUT the .exe extension (e.g. 'pwsh'), or @() on any failure.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Path
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        Write-Warning "MSIX package not found: $Path"
        return @()
    }

    # Pulls Application/@Executable out of an AppxManifest.xml document, as bare process names.
    function Read-ApplicationExecutable {
        param([xml]$Manifest)
        $names = [System.Collections.Generic.List[string]]::new()
        foreach ($appNode in @($Manifest.DocumentElement.GetElementsByTagName('Application'))) {
            if ($appNode -isnot [System.Xml.XmlElement]) { continue }
            $exe = $appNode.GetAttribute('Executable')
            if ([string]::IsNullOrWhiteSpace($exe)) { continue }
            # Executable may carry a path ("bin\app.exe") — take the leaf, drop the extension.
            $leaf = $exe.Replace('/', '\').Split('\')[-1]
            $name = [System.IO.Path]::GetFileNameWithoutExtension($leaf)
            if ($name -and $name -notin $names) { $names.Add($name) }
        }
        return $names
    }

    function Read-ManifestFromEntry {
        param($Entry)
        $reader = New-Object System.IO.StreamReader($Entry.Open())
        try { [xml]$doc = $reader.ReadToEnd(); return $doc } finally { $reader.Dispose() }
    }

    $zip = $null
    try {
        $zip = [System.IO.Compression.ZipFile]::OpenRead($Path)

        # Plain package: the root manifest carries <Applications> directly.
        $root = $zip.Entries | Where-Object { $_.FullName -ieq 'AppxManifest.xml' } | Select-Object -First 1
        if ($root) {
            return @(Read-ApplicationExecutable -Manifest (Read-ManifestFromEntry -Entry $root))
        }

        # Bundle: find a nested application package via the bundle manifest, then read ITS manifest.
        $bundleEntry = $zip.Entries | Where-Object { $_.FullName -ieq 'AppxMetadata/AppxBundleManifest.xml' } | Select-Object -First 1
        if (-not $bundleEntry) {
            Write-Warning "No AppxManifest.xml or AppxBundleManifest.xml in '$([System.IO.Path]::GetFileName($Path))' — cannot read its applications."
            return @()
        }
        $bundleManifest = Read-ManifestFromEntry -Entry $bundleEntry

        # Prefer Type="application" packages; resource packages carry no apps. Some manifests omit Type
        # (application is the default), so treat a missing Type as an application too.
        $nestedNames = foreach ($p in @($bundleManifest.DocumentElement.GetElementsByTagName('Package'))) {
            if ($p -isnot [System.Xml.XmlElement]) { continue }
            $type = $p.GetAttribute('Type')
            if ($type -and $type -ine 'application') { continue }
            $fn = $p.GetAttribute('FileName')
            if ($fn) { $fn }
        }

        foreach ($nested in @($nestedNames)) {
            $entry = $zip.Entries | Where-Object { $_.FullName -ieq $nested } | Select-Object -First 1
            if (-not $entry) { continue }
            # Copy the nested package into memory — a zip inside a zip needs a SEEKABLE stream.
            $ms = New-Object System.IO.MemoryStream
            try {
                $es = $entry.Open()
                try { $es.CopyTo($ms) } finally { $es.Dispose() }
                $ms.Position = 0
                $inner = New-Object System.IO.Compression.ZipArchive($ms, [System.IO.Compression.ZipArchiveMode]::Read)
                try {
                    $innerRoot = $inner.Entries | Where-Object { $_.FullName -ieq 'AppxManifest.xml' } | Select-Object -First 1
                    if (-not $innerRoot) { continue }
                    $apps = @(Read-ApplicationExecutable -Manifest (Read-ManifestFromEntry -Entry $innerRoot))
                    if ($apps.Count -gt 0) { return $apps }   # all architectures declare the same apps
                }
                finally { $inner.Dispose() }
            }
            finally { $ms.Dispose() }
        }

        return @()
    }
    catch {
        Write-Warning "Could not read applications from '$([System.IO.Path]::GetFileName($Path))': $($_.Exception.Message)"
        return @()
    }
    finally {
        if ($zip) { $zip.Dispose() }
    }
}