MSIX.AppAttach.ps1

# =============================================================================
# MSIX App Attach
# -----------------------------------------------------------------------------
# Generates VHDX or CIM images from an .msix using msixmgr.exe so the package
# can be attached as a layered image in Azure Virtual Desktop / Windows 365 /
# any App Attach scenario.
#
# Reference:
# https://learn.microsoft.com/azure/virtual-desktop/app-attach-msixmgr
# https://learn.microsoft.com/azure/virtual-desktop/app-attach-overview
# =============================================================================

# Microsoft hosts the latest msixmgrSetup.zip behind an aka.ms redirect.
# Drop a marker file alongside the binary so Update-MsixMgr can age it out.
$script:MsixMgrZipUrl = 'https://aka.ms/msixmgr'

function Install-MsixMgr {
    <#
    .SYNOPSIS
        Downloads and extracts the latest msixmgr.exe from Microsoft, ready for
        New-MsixAppAttachImage.
 
    .DESCRIPTION
        Pulls https://aka.ms/msixmgr (Microsoft's stable redirect to the latest
        msixmgrSetup.zip), unpacks under "$ToolsRoot\msixmgr", and exports
        $env:MSIX_MSIXMGR_PATH so subsequent calls find it.
 
        SECURITY / msixmgr signature status: the upstream archive ships
        with binaries that fail Authenticode verification against any
        production-Microsoft allowlist — msixmgr.exe and msix.dll are
        unsigned, and ApplyACLs.dll / CreateCIM.dll / WVDUtilities.dll are
        signed by 'CN=Microsoft Testing PCA 2010' (test CA, not in the
        Windows trusted-root store). Filed upstream:
        https://github.com/microsoft/msix-packaging/issues/710 — at time of
        writing the issue is open and the repo appears inactive.
 
        Because the verification check fails on every install today, this
        cmdlet defaults to SKIPPING Authenticode verification for msixmgr
        specifically. Pass -VerifyAuthenticode to opt back in (e.g. once
        Microsoft ships production-signed binaries upstream). The skip
        applies ONLY to msixmgr — every other downloaded toolchain binary
        (PSF, Procmon, DebugView, SDK BuildTools) is still verified.
 
    .PARAMETER Destination
        Where to extract. Defaults to "(Get-MsixToolsRoot)\msixmgr".
 
    .PARAMETER Force
        Re-download even if msixmgr is already installed.
 
    .PARAMETER VerifyAuthenticode
        Opt in to Authenticode verification of the msixmgr archive contents.
        Default: $false (skip). Currently failing upstream — see DESCRIPTION
        / microsoft/msix-packaging#710. Other toolchain downloaders verify
        unconditionally.
 
    .OUTPUTS
        [pscustomobject] with Path, Updated, and (on fresh install) Source URL.
 
    .EXAMPLE
        # Install msixmgr so New-MsixAppAttachImage can produce VHDX / CIM images.
        # Authenticode verification is skipped by default — see DESCRIPTION
        # for why (upstream signing is broken).
        Install-MsixMgr
 
    .EXAMPLE
        # Opt back in to verification (will throw today until upstream
        # microsoft/msix-packaging#710 ships production-signed binaries).
        Install-MsixMgr -VerifyAuthenticode
 
    .EXAMPLE
        # Force a re-download (msixmgr updates infrequently).
        Install-MsixMgr -Force
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Destination,
        [switch]$Force,
        [switch]$VerifyAuthenticode
    )

    if (-not $Destination) { $Destination = Join-Path (Get-MsixToolsRoot) 'msixmgr' }

    $marker = Join-Path $Destination 'msixmgr.installed'
    if ((Test-Path $marker) -and -not $Force) {
        Write-MsixLog Info "msixmgr already installed at $Destination. Use -Force to reinstall."
        return [pscustomobject]@{ Path = $Destination; Updated = $false }
    }

    $tmp = Join-Path $env:TEMP "msixmgr-$([guid]::NewGuid().ToString('N').Substring(0,8))"
    New-Item $tmp -ItemType Directory -Force | Out-Null
    $zip = Join-Path $tmp 'msixmgrSetup.zip'

    if ($PSCmdlet.ShouldProcess($Destination, 'Install msixmgr')) {
        $destinationExisted = Test-Path $Destination
        # Stage extraction into a temp folder so a bad signature doesn't pollute Destination.
        $stage = Join-Path $tmp 'extracted'
        try {
            Write-MsixLog Info "Downloading $script:MsixMgrZipUrl"
            $oldPref = $ProgressPreference
            $ProgressPreference = 'SilentlyContinue'
            try {
                Invoke-WebRequest -Uri $script:MsixMgrZipUrl -OutFile $zip -UseBasicParsing -ErrorAction Stop
            } finally {
                $ProgressPreference = $oldPref
            }

            New-Item $stage -ItemType Directory -Force | Out-Null
            Expand-Archive -LiteralPath $zip -DestinationPath $stage -Force

            # Authenticode verification of the msixmgr archive.
            # The upstream archive at aka.ms/msixmgr ships unsigned binaries
            # (msixmgr.exe, msix.dll) and binaries signed by 'Microsoft
            # Testing PCA 2010' (ApplyACLs.dll, CreateCIM.dll, WVDUtilities.dll)
            # — neither passes verification against the production-Microsoft
            # allowlist. Filed upstream as microsoft/msix-packaging#710; the
            # repo has been inactive. We default to skipping verification
            # ONLY for msixmgr and surface that loudly via Write-Warning so
            # operators in high-assurance environments can decide to manually
            # vet the bits. Pass -VerifyAuthenticode to opt back in.
            if ($VerifyAuthenticode) {
                _MsixVerifyAuthenticodeFolder -Folder $stage -ToolName 'msixmgr'
            } else {
                Write-Warning 'Skipping Authenticode verification for msixmgr (upstream signing is broken: microsoft/msix-packaging#710). Pass -VerifyAuthenticode to re-enable. This skip applies ONLY to msixmgr — every other downloaded toolchain binary is still verified.'
            }

            New-Item $Destination -ItemType Directory -Force | Out-Null
            Copy-Item (Join-Path $stage '*') $Destination -Recurse -Force
            (Get-Date -Format o) | Set-Content $marker -Encoding ascii

            $exe = Get-ChildItem $Destination -Recurse -Filter 'msixmgr.exe' -ErrorAction SilentlyContinue | Select-Object -First 1
            if ($exe) {
                $env:MSIX_MSIXMGR_PATH = $exe.FullName
                Write-MsixLog Info "msixmgr installed: $($exe.FullName)"
            } else {
                Write-MsixLog Warning "msixmgr.exe not found after extraction; check $Destination"
            }
        } catch {
            Write-MsixLog Error "msixmgr install rolled back: $_"
            if (-not $destinationExisted) {
                Remove-Item -LiteralPath $Destination -Recurse -Force -ErrorAction SilentlyContinue
            }
            throw
        } finally {
            Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    return [pscustomobject]@{
        Path    = $Destination
        Updated = $true
        Source  = $script:MsixMgrZipUrl
    }
}


function Update-MsixMgr {
    <#
    .SYNOPSIS
        Refreshes msixmgr if the local copy is older than -MaxAgeDays
        (default 60). Microsoft updates msixmgr infrequently.
 
    .DESCRIPTION
        Age-based updater. Re-runs Install-MsixMgr -Force only when the cached
        marker is older than -MaxAgeDays; otherwise reports the existing
        install. Falls back to a fresh install if nothing is cached.
 
    .PARAMETER Destination
        Cache folder. Defaults to "(Get-MsixToolsRoot)\msixmgr".
 
    .PARAMETER MaxAgeDays
        Refresh threshold in days. Default 60.
 
    .OUTPUTS
        [pscustomobject] from Install-MsixMgr or a no-op summary.
 
    .EXAMPLE
        # Keep msixmgr fresh on a CI agent.
        Update-MsixMgr
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Destination,
        [int]$MaxAgeDays = 60,
        # Pass-through to Install-MsixMgr; see that cmdlet's help for why
        # Authenticode verification is opt-in for msixmgr specifically
        # (upstream signing is broken — microsoft/msix-packaging#710).
        [switch]$VerifyAuthenticode
    )
    if (-not $Destination) { $Destination = Join-Path (Get-MsixToolsRoot) 'msixmgr' }
    $marker = Join-Path $Destination 'msixmgr.installed'

    if (-not (Test-Path $marker)) {
        if ($PSCmdlet.ShouldProcess($Destination, 'Install missing msixmgr')) {
            return Install-MsixMgr -Destination $Destination -VerifyAuthenticode:$VerifyAuthenticode
        }
        return
    }
    $stamp = [datetime](Get-Content $marker -Raw).Trim()
    $age   = (Get-Date) - $stamp
    if ($age.TotalDays -gt $MaxAgeDays) {
        Write-MsixLog Info "msixmgr is $([int]$age.TotalDays) days old; refreshing."
        if ($PSCmdlet.ShouldProcess($Destination, 'Refresh msixmgr')) {
            return Install-MsixMgr -Destination $Destination -Force -VerifyAuthenticode:$VerifyAuthenticode
        }
        return
    }
    Write-MsixLog Info "msixmgr is fresh ($([int]$age.TotalDays) days old; threshold $MaxAgeDays)."
    return [pscustomobject]@{ Path = $Destination; Updated = $false }
}


function Get-MsixMgrVersion {
    <#
    .SYNOPSIS
        Reports the version of msixmgr.exe currently resolved.
 
    .DESCRIPTION
        Reads file-version metadata of the resolved msixmgr.exe. Falls back to
        Resolve-MsixMgrPath when -Path is omitted.
 
    .PARAMETER Path
        Explicit path to msixmgr.exe. Defaults to Resolve-MsixMgrPath.
 
    .OUTPUTS
        [pscustomobject] with Path, Installed, Version (FileVersion).
 
    .EXAMPLE
        # Quickly verify the installed msixmgr build.
        Get-MsixMgrVersion
    #>

    [CmdletBinding()]
    param([string]$Path)

    if (-not $Path) { $Path = Resolve-MsixMgrPath }
    if (-not $Path -or -not (Test-Path $Path)) {
        return [pscustomobject]@{ Path = $Path; Installed = $false; Version = $null }
    }
    $info = Get-Item $Path
    return [pscustomobject]@{
        Path      = $info.FullName
        Installed = $true
        Version   = $info.VersionInfo.FileVersion
    }
}


function Resolve-MsixMgrPath {
    <#
    .SYNOPSIS
        Locates msixmgr.exe.
 
    .DESCRIPTION
        Resolution order:
          1. $env:MSIX_MSIXMGR_PATH (set by Install-MsixMgr).
          2. "(Get-MsixToolsRoot)\msixmgr\x64\msixmgr.exe" or its x86 sibling.
          3. "(Get-MsixToolsRoot)\Tools\msixmgr.exe" (legacy layout).
 
        Returns $null when nothing is found. Callers can then choose to invoke
        Install-MsixMgr.
 
    .OUTPUTS
        [string] full path to msixmgr.exe, or $null.
 
    .EXAMPLE
        # Resolve msixmgr before invoking it directly.
        $exe = Resolve-MsixMgrPath
        if (-not $exe) { Install-MsixMgr | Out-Null; $exe = Resolve-MsixMgrPath }
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()
    if ($env:MSIX_MSIXMGR_PATH -and (Test-Path $env:MSIX_MSIXMGR_PATH)) {
        return (Resolve-Path $env:MSIX_MSIXMGR_PATH).Path
    }
    $toolsRoot = Get-MsixToolsRoot
    foreach ($p in @(
        (Join-Path $toolsRoot 'msixmgr\x64\msixmgr.exe'),
        (Join-Path $toolsRoot 'msixmgr\x86\msixmgr.exe'),
        (Join-Path $toolsRoot 'Tools\msixmgr.exe')
    )) {
        if (Test-Path $p) { return $p }
    }
    return $null
}


function _MsixGetPackageInfo {
    param([string]$PackagePath)
    $m = Get-MsixManifest -Path $PackagePath
    return [pscustomobject]@{
        Name        = $m.Package.Identity.Name
        Publisher   = $m.Package.Identity.Publisher
        Version     = $m.Package.Identity.Version
        DisplayName = $m.Package.Properties.DisplayName
    }
}


function New-MsixAppAttachImage {
    <#
    .SYNOPSIS
        Builds an App Attach image (VHDX or CIM) from one or more .msix files
        using msixmgr.exe.
 
    .DESCRIPTION
        For VHDX:
          1. Creates a fixed-size VHDX (PowerShell New-VHD or fallback diskpart).
          2. Mounts and formats it NTFS.
          3. Calls `msixmgr.exe -Unpack -applyacls` to expand each .msix onto
             the mounted volume.
          4. Dismounts. The VHDX is ready to be staged on an SMB share.
 
        For CIM:
          msixmgr can create a Composite Image directly without VHD plumbing.
 
    .PARAMETER PackagePath
        One or more .msix files to include.
 
    .PARAMETER OutputPath
        .vhdx or .cim path to create.
 
    .PARAMETER FileType
        'vhdx' or 'cim'. Default: vhdx.
 
    .PARAMETER SizeGB
        Size of the VHDX. Auto-sized to the unpacked footprint + 20% if omitted.
        Ignored for CIM.
 
    .PARAMETER VolumeLabel
        Label for the formatted volume. Default: 'AppAttach'.
 
    .PARAMETER ApplyAcls
        Apply the necessary ACLs for App Attach. Default: $true.
 
    .OUTPUTS
        [System.IO.FileInfo] for the produced .vhdx or .cim file.
 
    .NOTES
        Requires elevation (Administrator) AND the Hyper-V PowerShell module
        (New-VHD, Mount-DiskImage, Initialize-Disk, Format-Volume). Install
        with:
            Enable-WindowsOptionalFeature -Online ``
                -FeatureName Microsoft-Hyper-V-Management-PowerShell
 
        -WhatIf semantics: every state-changing step (VHDX creation and each
        msixmgr unpack call) honors -WhatIf, so you can dry-run the
        per-package plan against an existing OutputPath without modifying
        anything.
 
    .EXAMPLE
        # Single-package VHDX (auto-sized) — typical App Attach scenario.
        New-MsixAppAttachImage -PackagePath app.msix `
                               -OutputPath C:\images\app.vhdx
 
    .EXAMPLE
        # Multi-package CIM — one image with several apps.
        New-MsixAppAttachImage -PackagePath app1.msix,app2.msix `
                               -OutputPath C:\images\bundle.cim -FileType cim
 
    .EXAMPLE
        # Dry-run a 5GB build to see the planned operations without creating the VHDX.
        New-MsixAppAttachImage -PackagePath app.msix `
                               -OutputPath C:\images\app.vhdx `
                               -SizeGB 5 -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string[]]$PackagePath,
        [Parameter(Mandatory)]
        [string]$OutputPath,
        [ValidateSet('vhdx','cim')]
        [string]$FileType = 'vhdx',
        [int]$SizeGB,
        [string]$VolumeLabel = 'AppAttach',
        [bool]$ApplyAcls = $true
    )

    $msixmgr = Resolve-MsixMgrPath
    if (-not $msixmgr) {
        throw "msixmgr.exe not found. Set `$env:MSIX_MSIXMGR_PATH or place it under the tools root\msixmgr\."
    }

    if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        throw 'New-MsixAppAttachImage requires elevation. Run PowerShell as Administrator.'
    }

    foreach ($p in $PackagePath) {
        if (-not (Test-Path $p)) { throw "Package not found: $p" }
    }

    if ($FileType -eq 'cim') {
        # msixmgr CIM mode handles everything in one call per package.
        # For multiple packages, we expand the first one with -create and add the rest.
        $first = $true
        foreach ($p in $PackagePath) {
            $msixMgrArgs = @('-Unpack', '-packagePath', $p, '-destination', $OutputPath, '-fileType', 'cim')
            if ($first)     { $msixMgrArgs += '-create' }
            if ($ApplyAcls) { $msixMgrArgs += '-applyacls' }
            if ($PSCmdlet.ShouldProcess($OutputPath, "Add $p to CIM")) {
                $r = Invoke-MsixProcess $msixmgr -ArgumentList $msixMgrArgs
                Assert-MsixProcessSuccess $r 'msixmgr CIM'
            }
            $first = $false
        }
        Write-MsixLog Info "App Attach CIM created: $OutputPath"
        return Get-Item $OutputPath
    }

    # ──────────── VHDX path ────────────
    if (-not (Get-Command New-VHD -ErrorAction SilentlyContinue)) {
        throw 'New-VHD not available. Install the Hyper-V PowerShell module: Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-Management-PowerShell'
    }

    if (-not $SizeGB) {
        $totalBytes = ($PackagePath | ForEach-Object { (Get-Item $_).Length } | Measure-Object -Sum).Sum
        # Unpacked is roughly 2-3x compressed; pad another 20% headroom; minimum 1 GB
        $SizeGB = [math]::Max(1, [math]::Ceiling(($totalBytes * 3 * 1.2) / 1GB))
        Write-MsixLog Info "Auto-sized VHDX: ${SizeGB} GB"
    }

    if (-not $PSCmdlet.ShouldProcess($OutputPath, "Create VHDX (${SizeGB} GB) and unpack packages")) {
        Write-MsixLog Info "[WhatIf] Would create ${SizeGB} GB VHDX at '$OutputPath' and unpack $($PackagePath.Count) package(s). No changes made."
        return $null
    }

    New-VHD -Path $OutputPath -SizeBytes ([int64]$SizeGB * 1GB) -Dynamic | Out-Null

    $disk = Mount-DiskImage -ImagePath $OutputPath -PassThru | Get-DiskImage
    $diskNum = (Get-Disk -Number $disk.Number).Number
    try {
        Initialize-Disk -Number $diskNum -PartitionStyle GPT -ErrorAction SilentlyContinue | Out-Null
        $part = New-Partition -DiskNumber $diskNum -UseMaximumSize -AssignDriveLetter
        Format-Volume -DriveLetter $part.DriveLetter -FileSystem NTFS -NewFileSystemLabel $VolumeLabel -Confirm:$false | Out-Null
        $drive = "$($part.DriveLetter):"

        foreach ($p in $PackagePath) {
            $info  = _MsixGetPackageInfo $p
            $folder = "${drive}\$($info.Name)_$($info.Version)"
            Write-MsixLog Info "Expanding $p -> $folder"
            $msixMgrArgs = @('-Unpack', '-packagePath', $p, '-destination', $folder)
            if ($ApplyAcls) { $msixMgrArgs += '-applyacls' }
            $r = Invoke-MsixProcess $msixmgr -ArgumentList $msixMgrArgs
            Assert-MsixProcessSuccess $r 'msixmgr unpack-to-vhd'
        }

    } finally {
        Dismount-DiskImage -ImagePath $OutputPath | Out-Null
    }

    Write-MsixLog Info "App Attach VHDX created: $OutputPath"
    return Get-Item $OutputPath
}


function Mount-MsixAppAttachImage {
    <#
    .SYNOPSIS
        Mounts a VHDX/CIM created by New-MsixAppAttachImage so its contents can
        be inspected.
 
    .DESCRIPTION
        Wraps Mount-DiskImage + Get-Partition + Get-Volume to surface the
        sandbox-friendly mount info (drive letter, disk number) in a single
        object. Use Dismount-MsixAppAttachImage to release it.
 
    .PARAMETER ImagePath
        Path to the .vhdx or .cim file produced by New-MsixAppAttachImage.
 
    .OUTPUTS
        [pscustomobject] with ImagePath, DiskNumber, DriveLetter.
 
    .EXAMPLE
        # Inspect an image's contents from PowerShell.
        $mnt = Mount-MsixAppAttachImage -ImagePath C:\images\app.vhdx
        Get-ChildItem $mnt.DriveLetter
        Dismount-MsixAppAttachImage -ImagePath C:\images\app.vhdx
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ImagePath
    )

    if (-not (Test-Path $ImagePath)) { throw "Image not found: $ImagePath" }

    Mount-DiskImage -ImagePath $ImagePath -PassThru | Out-Null
    Start-Sleep -Milliseconds 500
    $disk = Get-DiskImage -ImagePath $ImagePath
    $vol  = Get-Partition -DiskNumber $disk.Number -ErrorAction SilentlyContinue |
            Get-Volume -ErrorAction SilentlyContinue |
            Where-Object DriveLetter | Select-Object -First 1

    return [pscustomobject]@{
        ImagePath   = $ImagePath
        DiskNumber  = $disk.Number
        DriveLetter = if ($vol) { "$($vol.DriveLetter):" } else { $null }
    }
}


function Dismount-MsixAppAttachImage {
    <#
    .SYNOPSIS
        Dismounts a VHDX/CIM previously mounted with Mount-MsixAppAttachImage.
 
    .DESCRIPTION
        Thin wrapper around Dismount-DiskImage that logs the result via
        Write-MsixLog.
 
    .PARAMETER ImagePath
        Path to the .vhdx or .cim file to dismount.
 
    .EXAMPLE
        # Release an image after inspection.
        Dismount-MsixAppAttachImage -ImagePath C:\images\app.vhdx
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ImagePath
    )
    Dismount-DiskImage -ImagePath $ImagePath -ErrorAction Stop | Out-Null
    Write-MsixLog Info "Dismounted: $ImagePath"
}


function Test-MsixAppAttachImage {
    <#
    .SYNOPSIS
        Validates an existing image: mounts it, lists the package folder(s) it
        contains, dismounts. Use as a smoke-test before publishing to a share.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ImagePath
    )
    $m = Mount-MsixAppAttachImage -ImagePath $ImagePath
    try {
        if (-not $m.DriveLetter) { throw "Image mounted without an accessible volume." }
        $packages = Get-ChildItem $m.DriveLetter -Directory -ErrorAction SilentlyContinue |
                    ForEach-Object {
                        $manifest = Join-Path $_.FullName 'AppxManifest.xml'
                        if (Test-Path $manifest) {
                            [xml]$x = _MsixLoadXmlSecure -Path $manifest
                            [pscustomobject]@{
                                Folder      = $_.Name
                                Name        = $x.Package.Identity.Name
                                Version     = $x.Package.Identity.Version
                                Publisher   = $x.Package.Identity.Publisher
                            }
                        }
                    }
        return [pscustomobject]@{
            ImagePath   = $ImagePath
            DriveLetter = $m.DriveLetter
            Packages    = $packages
        }
    } finally {
        Dismount-MsixAppAttachImage -ImagePath $ImagePath
    }
}