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: the archive is first extracted into a temp staging folder and every .exe / .dll inside is Authenticode-verified against the trusted- publisher allowlist ($script:MsixTrustedPublishers — msixmgr is signed by 'CN=Microsoft Corporation,') BEFORE anything is copied into $Destination. A failed verification rolls the install back (the destination folder is removed if this cmdlet created it). .PARAMETER Destination Where to extract. Defaults to "(Get-MsixToolsRoot)\msixmgr". .PARAMETER Force Re-download even if msixmgr is already installed. .OUTPUTS [pscustomobject] with Path, Updated, and (on fresh install) Source URL. .EXAMPLE # Install msixmgr so New-MsixAppAttachImage can produce VHDX / CIM images. Install-MsixMgr .EXAMPLE # Force a re-download (msixmgr updates infrequently). Install-MsixMgr -Force #> [CmdletBinding(SupportsShouldProcess)] param( [string]$Destination, [switch]$Force ) 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 # H1: verify Authenticode signer before installing into $Destination. # NOTE: As of 2026-05-20 the msixmgr ZIP from aka.ms/msixmgr contains # unsigned binaries (msixmgr.exe, msix.dll) and test-signed DLLs # (ApplyACLs.dll, CreateCIM.dll, WVDUtilities.dll signed by # "Microsoft Testing PCA 2010" — not a trusted production CA). # This causes the verification below to throw in high-assurance environments. # Tracked upstream: https://github.com/microsoft/msix-packaging/issues/710 # When that issue is resolved and Microsoft ships production-signed binaries, # remove this comment. Until then the verification intentionally blocks use. _MsixVerifyAuthenticodeFolder -Folder $stage -ToolName 'msixmgr' 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 ) 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 } 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 } 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 } } |