MSIX.Bundle.ps1

# =============================================================================
# .msixbundle handling (issue #125)
# -----------------------------------------------------------------------------
# Real estates ship multi-arch bundles. These helpers make bundles first-class:
# Get-MsixBundleInfo — inner-package inventory
# Expand-MsixBundle — unbundle to a folder
# New-MsixBundle — bundle a folder of .msix files (+ sign)
# Invoke-MsixBundleOperation — unbundle -> run any mutator per inner package
# -> rebundle -> sign, atomically. This gives
# every existing mutator bundle support without
# per-cmdlet changes.
# =============================================================================

function Get-MsixBundleInfo {
    <#
    .SYNOPSIS
        Lists the packages inside a .msixbundle (name, version, architecture,
        resource language) without extracting it to disk permanently.
 
    .PARAMETER BundlePath
        The .msixbundle file to inspect.
 
    .EXAMPLE
        Get-MsixBundleInfo -BundlePath .\app.msixbundle | Format-Table
 
    .OUTPUTS
        [pscustomobject] per inner package: FileName, Name, Version,
        Architecture, ResourceLanguage, IsResourcePackage, SizeBytes.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)] [string]$BundlePath
    )

    $ws = Join-Path -Path $env:TEMP -ChildPath ("msix-binfo-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
    try {
        Expand-MsixBundle -BundlePath $BundlePath -Destination $ws | Out-Null
        foreach ($pkg in (Get-ChildItem -LiteralPath $ws -File | Where-Object { $_.Extension -in '.msix', '.appx' })) {
            [xml]$m = Get-MsixManifest -Path $pkg.FullName
            $identity = $m.Package.Identity
            $lang = $null
            $resources = @($m.Package.Resources.Resource)
            if ($resources) { $lang = ($resources | ForEach-Object { $_.GetAttribute('Language') } | Where-Object { $_ }) -join ',' }
            $arch = $identity.GetAttribute('ProcessorArchitecture')
            if (-not $arch) { $arch = 'neutral' }
            [pscustomobject]@{
                FileName          = $pkg.Name
                Name              = $identity.Name
                Version           = $identity.Version
                Architecture      = $arch
                ResourceLanguage  = $lang
                IsResourcePackage = ($null -ne $identity.GetAttribute('ResourceId') -and '' -ne $identity.GetAttribute('ResourceId'))
                SizeBytes         = $pkg.Length
            }
        }
    } finally {
        if (Test-Path -LiteralPath $ws) { Remove-Item -LiteralPath $ws -Recurse -Force -ErrorAction SilentlyContinue }
    }
}


function Expand-MsixBundle {
    <#
    .SYNOPSIS
        Extracts a .msixbundle's inner packages (MakeAppx unbundle).
 
    .PARAMETER BundlePath
        The .msixbundle file.
 
    .PARAMETER Destination
        Folder to extract into (created if absent).
 
    .EXAMPLE
        Expand-MsixBundle -BundlePath .\app.msixbundle -Destination C:\work\inner
 
    .OUTPUTS
        [pscustomobject] with Destination and Packages (inner .msix file paths).
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$BundlePath,
        [Parameter(Mandatory)] [string]$Destination
    )

    $toolsRoot = Get-MsixToolsRoot
    if (-not (Test-Path -LiteralPath $Destination)) {
        New-Item -ItemType Directory -Path $Destination -Force | Out-Null
    }
    $r = Invoke-MsixProcess -FilePath "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unbundle', '/p', (Resolve-Path -LiteralPath $BundlePath).Path, '/d', $Destination, '/o')
    Assert-MsixProcessSuccess -Result $r -Operation 'MakeAppx unbundle'

    [pscustomobject]@{
        Destination = $Destination
        Packages    = @(Get-ChildItem -LiteralPath $Destination -File |
            Where-Object { $_.Extension -in '.msix', '.appx' } |
            Select-Object -ExpandProperty FullName)
    }
}


function New-MsixBundle {
    <#
    .SYNOPSIS
        Bundles .msix packages into a .msixbundle (MakeAppx bundle) and signs it.
 
    .DESCRIPTION
        All inner packages must share Identity Name + Publisher + Version and
        differ by architecture / resources (MakeAppx enforces this). Pass a
        folder containing only the .msix files to bundle, or an explicit list
        (staged into a temp folder automatically).
 
    .PARAMETER SourceFolder
        Folder whose *.msix/*.appx files become the bundle.
 
    .PARAMETER PackagePaths
        Alternative to -SourceFolder: explicit .msix files to bundle.
 
    .PARAMETER OutputPath
        The .msixbundle to write.
 
    .PARAMETER BundleVersion
        Optional bundle version (defaults to the inner packages' version).
 
    .PARAMETER SkipSigning
        Skip the signing pass. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate (.pfx) path.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .EXAMPLE
        New-MsixBundle -SourceFolder C:\work\inner -OutputPath .\app.msixbundle -SkipSigning
 
    .EXAMPLE
        New-MsixBundle -PackagePaths .\app-x64.msix, .\app-arm64.msix `
            -OutputPath .\app.msixbundle -Pfx cert.pfx -PfxPassword $pw
 
    .OUTPUTS
        [pscustomobject] with BundlePath and PackageCount.
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Folder')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Folder')]
        [string]$SourceFolder,
        [Parameter(Mandatory, ParameterSetName = 'Files')]
        [string[]]$PackagePaths,
        [Parameter(Mandatory)] [string]$OutputPath,
        [ValidatePattern('^\d+\.\d+\.\d+\.\d+$')]
        [string]$BundleVersion,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    if (-not $PSCmdlet.ShouldProcess($OutputPath, 'Create bundle')) { return }

    $toolsRoot = Get-MsixToolsRoot
    $staging = $null
    try {
        if ($PSCmdlet.ParameterSetName -eq 'Files') {
            $staging = Join-Path -Path $env:TEMP -ChildPath ("msix-bundle-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
            New-Item -ItemType Directory -Path $staging -Force | Out-Null
            foreach ($p in $PackagePaths) {
                Copy-Item -LiteralPath $p -Destination $staging -Force
            }
            $dir = $staging
        } else {
            $dir = (Resolve-Path -LiteralPath $SourceFolder).Path
        }

        $bundleArgs = @('bundle', '/d', $dir, '/p', $OutputPath, '/o')
        if ($BundleVersion) { $bundleArgs += @('/bv', $BundleVersion) }
        $r = Invoke-MsixProcess -FilePath "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList $bundleArgs
        Assert-MsixProcessSuccess -Result $r -Operation 'MakeAppx bundle'

        if (-not $SkipSigning) {
            if ($Pfx) { Invoke-MsixSigning -PackagePath $OutputPath -Pfx $Pfx -PfxPassword $PfxPassword }
            else      { Invoke-MsixSigning -PackagePath $OutputPath }
        }

        $count = @(Get-ChildItem -LiteralPath $dir -File | Where-Object { $_.Extension -in '.msix', '.appx' }).Count
        Write-MsixLog -Level Info -Message "Bundle created: $OutputPath ($count package(s))."
        [pscustomobject]@{
            BundlePath   = $OutputPath
            PackageCount = $count
        }
    } finally {
        if ($staging -and (Test-Path -LiteralPath $staging)) {
            Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}


function Invoke-MsixBundleOperation {
    <#
    .SYNOPSIS
        Applies any package mutator to the packages inside a .msixbundle:
        unbundle -> operate per inner package -> rebundle -> sign, atomically.
 
    .DESCRIPTION
        This is the bridge that gives every existing mutator bundle support
        without per-cmdlet changes. The -Operation scriptblock receives one
        inner .msix path at a time and mutates it IN PLACE (use -SkipSigning
        inside the block - the rebundled .msixbundle is signed once at the end).
 
        Resource packages (language .appx) are passed through untouched unless
        -IncludeResourcePackages is set. Use -Architecture to restrict the
        operation to specific inner architectures (others pass through).
 
        The original bundle is only replaced after the rebundle (and signing,
        unless -SkipSigning) succeeded.
 
    .PARAMETER BundlePath
        The .msixbundle to operate on.
 
    .PARAMETER Operation
        Scriptblock invoked per selected inner package with the .msix path as
        its first argument, e.g.:
        { param($pkg) Add-MsixCapability -PackagePath $pkg -Names runFullTrust -SkipSigning }
 
    .PARAMETER Architecture
        Restrict the operation to these architectures (x86, x64, arm64,
        neutral). Default: all application packages.
 
    .PARAMETER IncludeResourcePackages
        Also run the operation against resource (.appx language) packages.
 
    .PARAMETER OutputPath
        Write the resulting bundle here instead of overwriting -BundlePath.
 
    .PARAMETER SkipSigning
        Skip the final signing pass. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate (.pfx) path.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .EXAMPLE
        # Add a capability to every architecture in the bundle
        Invoke-MsixBundleOperation -BundlePath .\app.msixbundle -Operation {
            param($pkg)
            Add-MsixCapability -PackagePath $pkg -Names runFullTrust -SkipSigning
        } -Pfx cert.pfx -PfxPassword $pw
 
    .EXAMPLE
        # Isolate only the x64 package
        Invoke-MsixBundleOperation -BundlePath .\app.msixbundle -Architecture x64 -Operation {
            param($pkg)
            Add-MsixAppIsolation -PackagePath $pkg -SkipSigning
        } -SkipSigning
 
    .OUTPUTS
        [pscustomobject] with BundlePath, PackagesProcessed, PackagesPassedThrough.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$BundlePath,
        [Parameter(Mandatory)] [scriptblock]$Operation,
        [ValidateSet('x86', 'x64', 'arm', 'arm64', 'neutral')]
        [string[]]$Architecture,
        [switch]$IncludeResourcePackages,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    if (-not $PSCmdlet.ShouldProcess($BundlePath, 'Bundle operation')) { return }

    $target = if ($OutputPath) { $OutputPath } else { $BundlePath }
    $ws = Join-Path -Path $env:TEMP -ChildPath ("msix-bop-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
    $scratch = Join-Path -Path $env:TEMP -ChildPath ("msix-bop-out-{0}.msixbundle" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
    try {
        $inner = Expand-MsixBundle -BundlePath $BundlePath -Destination $ws

        # The unbundle folder carries footprint files (AppxMetadata etc.);
        # rebundle needs a clean staging dir with only the packages.
        $stage = Join-Path -Path $ws -ChildPath 'rebundle'
        New-Item -ItemType Directory -Path $stage -Force | Out-Null

        $processed = 0
        $passed = 0
        # Bind the operation to this module's session state so module-private
        # helpers resolve (same pattern as _MsixMutatePackage).
        $boundOp = $Operation
        if ($ExecutionContext.SessionState.Module) {
            try { $boundOp = $ExecutionContext.SessionState.Module.NewBoundScriptBlock($Operation) } catch { $boundOp = $Operation }
        }

        foreach ($pkg in $inner.Packages) {
            [xml]$m = Get-MsixManifest -Path $pkg
            $identity = $m.Package.Identity
            $arch = $identity.GetAttribute('ProcessorArchitecture')
            if (-not $arch) { $arch = 'neutral' }
            $isResource = ($null -ne $identity.GetAttribute('ResourceId') -and '' -ne $identity.GetAttribute('ResourceId'))

            $select = $true
            if ($isResource -and -not $IncludeResourcePackages) { $select = $false }
            if ($Architecture -and $arch -notin $Architecture)  { $select = $false }

            if ($select) {
                Write-MsixLog -Level Info -Message "Bundle operation: $([IO.Path]::GetFileName($pkg)) ($arch)"
                & $boundOp $pkg
                $processed++
            } else {
                $passed++
            }
            Copy-Item -LiteralPath $pkg -Destination $stage -Force
        }

        if ($processed -eq 0) {
            Write-MsixLog -Level Warning -Message 'Bundle operation matched no inner packages; bundle left unchanged.'
            return [pscustomobject]@{
                BundlePath            = $BundlePath
                PackagesProcessed     = 0
                PackagesPassedThrough = $passed
            }
        }

        # Rebundle to scratch, sign, then atomically replace the target.
        $toolsRoot = Get-MsixToolsRoot
        $r = Invoke-MsixProcess -FilePath "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('bundle', '/d', $stage, '/p', $scratch, '/o')
        Assert-MsixProcessSuccess -Result $r -Operation 'MakeAppx bundle (rebundle)'

        if (-not $SkipSigning) {
            if ($Pfx) { Invoke-MsixSigning -PackagePath $scratch -Pfx $Pfx -PfxPassword $PfxPassword }
            else      { Invoke-MsixSigning -PackagePath $scratch }
        }

        Move-Item -LiteralPath $scratch -Destination $target -Force
        Write-MsixLog -Level Info -Message "Bundle operation complete: $target ($processed processed, $passed passed through)."
        [pscustomobject]@{
            BundlePath            = $target
            PackagesProcessed     = $processed
            PackagesPassedThrough = $passed
        }
    } finally {
        if (Test-Path -LiteralPath $ws)      { Remove-Item -LiteralPath $ws -Recurse -Force -ErrorAction SilentlyContinue }
        if (Test-Path -LiteralPath $scratch) { Remove-Item -LiteralPath $scratch -Force -ErrorAction SilentlyContinue }
    }
}