MSIX.Pipeline.ps1

function Invoke-MsixPipeline {
    <#
    .SYNOPSIS
        Runs a full unpack → validate → modify → repack → sign pipeline on an MSIX,
        signing only ONCE at the very end (no per-stage re-signs).
 
    .DESCRIPTION
        All modifications happen in an isolated GUID workspace. Behaviour:
 
          - With -OutputPath: original file is preserved; pipeline result is
            written there. Use this for staged/dry-run runs.
          - Without -OutputPath: file is overwritten in-place after success.
 
        Stages run in order, each touching the SAME workspace, and signing is
        deferred until everything is repacked. This avoids the previous
        per-stage resign that wasted time and risked publisher drift.
 
    .PARAMETER PackagePath
        Path to the .msix file. Overwritten in place unless -OutputPath is
        also supplied.
 
    .PARAMETER OutputPath
        Optional output path. Defaults to overwriting -PackagePath.
 
    .PARAMETER Config
        Hashtable controlling pipeline stages. Supported keys:
 
          Publisher [string] New publisher DN. Omit to skip.
 
          PSF [hashtable] Keys:
            Fixups [hashtable[]]
            PsfSourcePath [string]
            WorkingDirectory [string]
            AppOptions [hashtable[]]
            AdditionalFiles [string[]]
 
          AppIsolation [hashtable] Keys:
            Capabilities [string[]] Add Win32 isolation capabilities
 
          Signing [hashtable] Keys:
            Pfx [string]
            PfxPassword [SecureString]
            TimestampUrl [string]
            Skip [bool] Skip signing entirely (default: false)
            UnsignedOutputPath [string] When signing fails, copy the
                                                unsigned scratch package here so
                                                the operator can manually re-sign.
                                                The original target is never
                                                overwritten when signing fails.
 
    .OUTPUTS
        [System.IO.FileInfo] for the final signed package, or $null in
        WhatIf preview mode.
 
    .EXAMPLE
        # Publisher rename only — minimal config
        Invoke-MsixPipeline -PackagePath app.msix -Config @{
            Publisher = 'CN=Contoso, O=Contoso, C=NL'
            Signing = @{ Pfx = 'cert.pfx'; PfxPassword = $pw }
        }
 
    .EXAMPLE
        # PSF injection only (file redirection)
        $fixup = New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log'
        Invoke-MsixPipeline -PackagePath app.msix -Config @{
            PSF = @{ Fixups = @($fixup) }
            Signing = @{ Pfx = 'cert.pfx'; PfxPassword = $pw }
        }
 
    .EXAMPLE
        # Full pipeline: Publisher rename + PSF + Signing + UnsignedOutputPath
        # (preserves the unsigned package if signing fails)
        $fixup = New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log'
        Invoke-MsixPipeline -PackagePath app.msix -OutputPath app-fixed.msix `
            -Config @{
                Publisher = 'CN=Contoso, O=Contoso, C=NL'
                PSF = @{ Fixups = @($fixup) }
                Signing = @{
                    Pfx = 'cert.pfx'
                    PfxPassword = $pw
                    UnsignedOutputPath = 'C:\drop\app-unsigned.msix'
                }
            }
 
    .EXAMPLE
        # Preview mode: -WhatIf still runs unpack/edit/pack so you can inspect
        # the would-be result; signing and the final Move-Item are skipped.
        Invoke-MsixPipeline -WhatIf -PackagePath app.msix -Config $cfg
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$PackagePath,
        [Parameter(Mandatory)]
        [hashtable]$Config,
        [string]$OutputPath
    )

    $toolsRoot = Get-MsixToolsRoot
    $fileinfo  = Get-Item -LiteralPath $PackagePath -ErrorAction Stop
    $workspace = New-MsixWorkspace $fileinfo.BaseName
    $target    = if ($OutputPath) { $OutputPath } else { $fileinfo.FullName }

    # Compute WhatIf semantics once. In WhatIf mode the unpack + edit + pack
    # stages still run (so the user can preview the modified package), but the
    # destructive signing + final Move-Item to $target are skipped. If
    # Config.Signing.UnsignedOutputPath is set, the scratch package is copied
    # there so the user can inspect what would have been produced.
    $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Run MSIX pipeline')

    try {
        Write-MsixLog Info "=== MSIX Pipeline: $($fileinfo.Name) -> $target ==="
        if ($isWhatIf) {
            Write-MsixLog Info '[WhatIf] Preview mode: unpack/edit/pack will run; signing and final replacement will be skipped.'
        }

        # ── Unpack into workspace ────────────────────────────────────────
        Write-MsixLog Info 'Stage: Unpack'
        $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o')
        Assert-MsixProcessSuccess $r 'MakeAppx unpack'

        # ── Validate ─────────────────────────────────────────────────────
        Write-MsixLog Info 'Stage: Validate'
        $null = Test-MsixManifest "$workspace\AppxManifest.xml"
        [xml]$manifest = Get-MsixManifest "$workspace\AppxManifest.xml"
        $manifestDirty = $false

        # ── Publisher update ─────────────────────────────────────────────
        if ($Config.Publisher) {
            $oldPublisher = $manifest.Package.Identity.Publisher
            if ($oldPublisher -cne $Config.Publisher) {
                Set-MsixManifestPublisher -Manifest $manifest -Publisher $Config.Publisher | Out-Null
                $manifestDirty = $true
                Write-MsixLog Info "Publisher: $oldPublisher → $($Config.Publisher)"
            } else {
                Write-MsixLog Info 'Publisher unchanged (already matches)'
            }
        }

        # ── App Isolation ────────────────────────────────────────────────
        if ($Config.AppIsolation -and $Config.AppIsolation.Capabilities) {
            Add-MsixManifestNamespace $manifest 'rescap'
            Set-MsixManifestMaxVersionTested $manifest -MinBuild 26100
            $rescapUri = Get-MsixManifestNamespaceUri 'rescap'
            $capsNode  = $manifest.Package.Capabilities
            if (-not $capsNode) {
                $capsNode = $manifest.CreateElement('Capabilities', $manifest.Package.NamespaceURI)
                $null     = $manifest.Package.AppendChild($capsNode)
            }
            foreach ($cap in @($Config.AppIsolation.Capabilities)) {
                $existing = $capsNode.ChildNodes | Where-Object { $_.LocalName -eq 'Capability' -and $_.Name -eq $cap }
                if (-not $existing) {
                    $node = $manifest.CreateElement('rescap:Capability', $rescapUri)
                    $node.SetAttribute('Name', $cap)
                    $null = $capsNode.AppendChild($node)
                    Write-MsixLog Info "Capability added: $cap"
                    $manifestDirty = $true
                }
            }
        }

        if ($manifestDirty) {
            Save-MsixManifest $manifest "$workspace\AppxManifest.xml"
        }

        # ── Repack to SCRATCH (never to $target until sign succeeds) ─────
        # Atomic pack-then-sign: original target is preserved if signing fails.
        $scratchExt = [System.IO.Path]::GetExtension($target)
        if (-not $scratchExt) { $scratchExt = '.msix' }
        $scratch = Join-Path $env:TEMP ("msix-pipeline-{0}-{1}{2}" -f `
            $fileinfo.BaseName, ([guid]::NewGuid().ToString('N').Substring(0,8)), $scratchExt)

        $needsPsf      = [bool]$Config.PSF
        $packSucceeded = $false
        $signSucceeded = $false

        try {
            if ($needsPsf) {
                Write-MsixLog Info 'Stage: PSF injection'
                $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack', '/p', $scratch, '/d', $workspace, '/o')
                Assert-MsixProcessSuccess $r 'MakeAppx pack (pre-PSF scratch)'

                $psfArgs = @{
                    PackagePath  = $scratch
                    Fixups       = $Config.PSF.Fixups
                    SkipSigning  = $true        # we sign once at the end
                }
                foreach ($k in 'PsfSourcePath','WorkingDirectory','AppOptions','AdditionalFiles') {
                    if ($Config.PSF.ContainsKey($k)) { $psfArgs[$k] = $Config.PSF[$k] }
                }
                Add-MsixPsfV2 @psfArgs
            } else {
                Write-MsixLog Info 'Stage: Repack'
                $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack', '/p', $scratch, '/d', $workspace, '/o')
                Assert-MsixProcessSuccess $r 'MakeAppx pack'
            }
            $packSucceeded = $true

            if ($isWhatIf) {
                Write-MsixLog Info "[WhatIf] Would replace '$target' with pipeline output. Signing and Move-Item skipped."
                if ($Config.Signing -and $Config.Signing.UnsignedOutputPath) {
                    Copy-Item -LiteralPath $scratch -Destination $Config.Signing.UnsignedOutputPath -Force -ErrorAction Stop
                    Write-MsixLog Info "[WhatIf] Preview package copied to: $($Config.Signing.UnsignedOutputPath)"
                }
                return $null
            }

            # ── Sign (once, at the end, AT THE SCRATCH PATH) ──────────────
            $skipSign = $Config.Signing -and $Config.Signing.Skip
            if ($Config.Signing -and -not $skipSign) {
                Write-MsixLog Info 'Stage: Sign (final)'
                $signArgs = @{ PackagePath = $scratch }
                foreach ($k in 'Pfx','PfxPassword','TimestampUrl','Signer',
                              'TrustedSigningAccount','TrustedSigningProfile',
                              'TrustedSigningEndpoint','TrustedSigningClientDll',
                              'KeyVaultUrl','KeyVaultCertificate','KeyVaultTenantId',
                              'KeyVaultClientId','KeyVaultClientSecret') {
                    if ($Config.Signing.ContainsKey($k)) { $signArgs[$k] = $Config.Signing[$k] }
                }
                Invoke-MsixSigning @signArgs
            } elseif (-not $Config.Signing) {
                Write-MsixLog Info 'No Signing block in config; output is unsigned.'
            } else {
                Write-MsixLog Info 'Signing.Skip=true; output is unsigned.'
            }
            $signSucceeded = $true

            # ── Atomic move: only NOW does the target change ─────────────
            Move-Item -LiteralPath $scratch -Destination $target -Force
            Write-MsixLog Info "=== Pipeline complete: $target ==="
            return Get-Item -LiteralPath $target -ErrorAction Stop

        } catch {
            if ($packSucceeded -and -not $signSucceeded -and `
                $Config.Signing -and $Config.Signing.UnsignedOutputPath) {
                try {
                    Copy-Item -LiteralPath $scratch -Destination $Config.Signing.UnsignedOutputPath -Force -ErrorAction Stop
                    Write-MsixLog Warning "Signing failed. Unsigned package preserved at: $($Config.Signing.UnsignedOutputPath)"
                } catch {
                    Write-MsixLog Error "Signing failed AND unsigned-output copy to '$($Config.Signing.UnsignedOutputPath)' failed: $_"
                }
            } elseif ($packSucceeded -and -not $signSucceeded) {
                Write-MsixLog Warning "Signing failed. Original target '$target' is unchanged. Set Config.Signing.UnsignedOutputPath to preserve the unsigned package next time."
            }
            throw
        } finally {
            if (Test-Path -LiteralPath $scratch) {
                Remove-Item -LiteralPath $scratch -Force -ErrorAction SilentlyContinue
            }
        }

    } finally {
        Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue
    }
}