Modules/businessdev.ALbuild.Core/Public/Expand-BcAppFile.ps1

function Expand-BcAppFile {
    <#
    .SYNOPSIS
        Extracts a Business Central .app/.runtime.app package and reads its manifest.
 
    .DESCRIPTION
        A BC .app file is a ZIP archive preceded by a small binary runtime header. This function
        locates the ZIP payload (by its local-file-header signature), extracts it, and parses the
        embedded NavxManifest.xml to return the app's identity and dependencies - all without a
        running container. Namespace/casing differences are tolerated by matching element
        local-names.
 
    .PARAMETER Path
        Path to the .app or .runtime.app file.
 
    .PARAMETER DestinationPath
        Optional folder to extract into. Defaults to a unique temp folder (returned as
        ExtractedPath); the caller owns cleanup.
 
    .EXAMPLE
        $info = Expand-BcAppFile -Path .\Publisher_App_1.0.0.0.app
        $info.Id; $info.Application; $info.Dependencies
 
    .OUTPUTS
        PSCustomObject with Id, Name, Publisher, Version, Application, Platform, Dependencies,
        ManifestPath, ExtractedPath, Manifest (xml).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [string] $DestinationPath
    )

    process {
        if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
            throw "App file not found: '$Path'."
        }

        $tempZip = $null
        try {
            $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Path).ProviderPath)

            # Find the ZIP local-file-header signature: 0x50 0x4B 0x03 0x04 ("PK..").
            # Use Array.IndexOf to locate candidate 0x50 bytes (native scan) rather than a
            # per-element PowerShell loop, which is slow over large packages.
            $zipStart = -1
            $from = 0
            while (($candidate = [System.Array]::IndexOf($bytes, [byte]0x50, $from)) -ge 0 -and $candidate -le ($bytes.Length - 4)) {
                if ($bytes[$candidate + 1] -eq 0x4B -and $bytes[$candidate + 2] -eq 0x03 -and $bytes[$candidate + 3] -eq 0x04) {
                    $zipStart = $candidate
                    break
                }
                $from = $candidate + 1
            }
            if ($zipStart -lt 0) {
                throw "'$Path' does not contain a ZIP payload; it may not be a valid .app file."
            }

            # Write the ZIP payload from the offset without allocating a second large array.
            $tempZip = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.Guid]::NewGuid().ToString() + '.zip')
            $zipStream = [System.IO.File]::Open($tempZip, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write)
            try { $zipStream.Write($bytes, $zipStart, $bytes.Length - $zipStart) }
            finally { $zipStream.Dispose() }

            if (-not $DestinationPath) {
                $DestinationPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'ALbuild-app-' + [System.Guid]::NewGuid().ToString())
            }
            if (-not (Test-Path -LiteralPath $DestinationPath)) {
                New-Item -Path $DestinationPath -ItemType Directory -Force | Out-Null
            }

            Expand-Archive -LiteralPath $tempZip -DestinationPath $DestinationPath -Force

            $manifestFile = Get-ChildItem -LiteralPath $DestinationPath -Filter 'NavxManifest.xml' -Recurse -File |
                Select-Object -First 1
            if (-not $manifestFile) {
                throw "NavxManifest.xml was not found inside '$Path'."
            }

            [xml] $manifest = Get-Content -LiteralPath $manifestFile.FullName -Raw -Encoding UTF8

            $appNode = $manifest.SelectSingleNode("//*[local-name()='App']")
            if (-not $appNode) {
                throw "NavxManifest.xml in '$Path' has no <App> element."
            }

            $getAttr = {
                param($node, [string] $name)
                if ($null -eq $node -or $null -eq $node.Attributes) { return $null }
                foreach ($attr in $node.Attributes) {
                    if ($attr.Name -ieq $name) { return $attr.Value }
                }
                return $null
            }

            $dependencies = @()
            foreach ($dep in $manifest.SelectNodes("//*[local-name()='Dependency']")) {
                $minVersion = & $getAttr $dep 'MinVersion'
                if (-not $minVersion) { $minVersion = & $getAttr $dep 'Version' }
                $dependencies += [PSCustomObject]@{
                    Id        = & $getAttr $dep 'Id'
                    Name      = & $getAttr $dep 'Name'
                    Publisher = & $getAttr $dep 'Publisher'
                    Version   = $minVersion
                }
            }

            return [PSCustomObject]@{
                Id            = & $getAttr $appNode 'Id'
                Name          = & $getAttr $appNode 'Name'
                Publisher     = & $getAttr $appNode 'Publisher'
                Version       = & $getAttr $appNode 'Version'
                Application   = & $getAttr $appNode 'Application'
                Platform      = & $getAttr $appNode 'Platform'
                Dependencies  = $dependencies
                ManifestPath  = $manifestFile.FullName
                ExtractedPath = $DestinationPath
                Manifest      = $manifest
            }
        }
        finally {
            if ($tempZip -and (Test-Path -LiteralPath $tempZip)) {
                Remove-Item -LiteralPath $tempZip -Force -ErrorAction SilentlyContinue
            }
        }
    }
}