MSIX.SparseShell.ps1
|
# ============================================================================= # Sparse shell-extension merge # ----------------------------------------------------------------------------- # Some packages ship an inner *.msix (a "sparse" sub-package) that declares a # COM SurrogateServer shell extension. When the outer package installs into # the read-only WindowsApps store, the inner package cannot be activated — # the surrogate host (dllhost.exe) cannot traverse into the nested .msix at # runtime, and the inner manifest's Path attributes are relative to the # inner package's own root, not the outer VFS layout. # # Import-MsixSparseShellExtension lifts the inner manifest's declarations # (Extensions and any required namespaces) into the outer manifest, rewrites # every com:Class/@Path to be package-relative under the outer VFS, copies # the inner payload (DLLs, resources, etc.) alongside, optionally deletes # the now-unused inner .msix, then repacks/signs atomically. # ============================================================================= function Import-MsixSparseShellExtension { <# .SYNOPSIS Merges a nested (sparse) inner .msix package's COM shell-extension declarations into the outer package's manifest so the surrogate host can activate them post-install. .DESCRIPTION Outer/inner package pattern: outer.msix └── VFS\ProgramFilesX64\App\contextMenu\Inner.msix ← sparse └── AppxManifest.xml (declares com:Class etc.) └── ShellExt.dll The inner package is not deployable from inside the outer package — Windows cannot install a sub-package out of WindowsApps, and the COM surrogate cannot traverse the inner zip to load the DLL. The fix is to: 1. Copy the inner payload (DLLs, resources, …) into the outer VFS at the same path the inner package sat at. 2. Lift every <*:Extension> element from the inner manifest's <Package><Extensions> into the outer manifest's <Package><Extensions>. 3. Rewrite com:Class/@Path to be package-relative — prepending the outer-VFS directory the inner sat in. 4. Delete the now-unused inner .msix (unless -KeepInnerPackage). 5. Bump MaxVersionTested to 17763+ (desktop4 context menus require Win10 1809 or later). 6. Repack + atomically sign (mirrors Remove-MsixUninstallerArtifact). .PARAMETER PackagePath Outer .msix file to mutate. .PARAMETER NestedPackagePath Package-relative path to the inner .msix. When omitted, the function calls Get-MsixNestedPackageCandidate and picks the first candidate (warns if there is more than one). .PARAMETER OutputPath Write the repacked package here instead of overwriting -PackagePath. .PARAMETER SkipSigning Don't sign the repacked package. Alias: -NoSign. .PARAMETER Pfx / PfxPassword / UnsignedOutputPath Forwarded to the shared sign/move path. .PARAMETER KeepInnerPackage Preserve the inner .msix file inside the outer package. Default is to delete it because, after the merge, it has no consumer. .OUTPUTS [pscustomobject] with NestedPackagePath, BasePath, ExtensionsMerged, FilesCopied, PathsFixed, Output. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$PackagePath, [string]$NestedPackagePath, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath, [switch]$KeepInnerPackage ) $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item -LiteralPath $PackagePath -ErrorAction Stop $workspace = New-MsixWorkspace "$($fileinfo.BaseName)-sparse" $inner = Join-Path $env:TEMP ("msix-inner-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0,8))) try { # ── Unpack outer ────────────────────────────────────────────────── $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx unpack (outer)' # ── Resolve the nested package path ────────────────────────────── if (-not $NestedPackagePath) { $candidates = @(Get-MsixNestedPackageCandidate -PackagePath $PackagePath) if (-not $candidates -or $candidates.Count -eq 0) { Write-MsixLog Info 'No nested package found inside the outer .msix; nothing to merge.' return } if ($candidates.Count -gt 1) { Write-MsixLog Warning ("Multiple nested packages found ({0}). Picking the first: {1}. Pass -NestedPackagePath to be explicit." -f $candidates.Count, $candidates[0].Path) } $NestedPackagePath = $candidates[0].Path } $innerPkg = Join-Path $workspace $NestedPackagePath if (-not (Test-Path -LiteralPath $innerPkg)) { throw "Nested package not found inside outer workspace: $NestedPackagePath" } # ── Compute base path (the directory the inner package sat in) ── # NestedPackagePath looks like 'VFS\ProgramFilesX64\App\sub\Inner.msix'; # $basePath = 'VFS\ProgramFilesX64\App\sub' (no trailing separator). $basePath = Split-Path -Parent $NestedPackagePath if ($null -eq $basePath) { $basePath = '' } Write-MsixLog Info "Sparse merge: inner='$NestedPackagePath' basePath='$basePath'" # ── Unpack inner ────────────────────────────────────────────────── New-Item -ItemType Directory -Path $inner -Force | Out-Null $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $innerPkg, '/d', $inner, '/o') Assert-MsixProcessSuccess $r 'MakeAppx unpack (inner)' # ── Load both manifests via the XML-safe helper ────────────────── $outerManifestPath = Join-Path $workspace 'AppxManifest.xml' $innerManifestPath = Join-Path $inner 'AppxManifest.xml' $outerXml = Get-MsixManifest -Path $outerManifestPath $innerXml = Get-MsixManifest -Path $innerManifestPath $outerPkgEl = $outerXml.DocumentElement # <Package> $innerPkgEl = $innerXml.DocumentElement # ── Merge namespace declarations ───────────────────────────────── # For every xmlns:* on the inner <Package> not already present on the # outer <Package>, copy it across. Prefer Add-MsixManifestNamespace # when the prefix is one we know (it also updates IgnorableNamespaces). $outerExistingUris = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($a in $outerPkgEl.Attributes) { if ($a.Prefix -eq 'xmlns' -or $a.Name -eq 'xmlns') { $null = $outerExistingUris.Add($a.Value) } } foreach ($a in $innerPkgEl.Attributes) { $isNs = ($a.Prefix -eq 'xmlns') -or ($a.Name -eq 'xmlns') if (-not $isNs) { continue } if ($outerExistingUris.Contains($a.Value)) { continue } $prefix = if ($a.Prefix -eq 'xmlns') { $a.LocalName } else { $null } $known = if ($prefix) { Get-MsixManifestNamespaceUri -Prefix $prefix } else { $null } if ($prefix -and $known -and $known -eq $a.Value) { Add-MsixManifestNamespace -Manifest $outerXml -Prefix $prefix } elseif ($prefix) { # Unknown prefix — set the xmlns attribute directly $outerPkgEl.SetAttribute("xmlns:$prefix", $a.Value) Write-MsixLog Debug "Merged unknown namespace prefix '$prefix' -> $($a.Value)" } $null = $outerExistingUris.Add($a.Value) } # ── Locate (or create) outer <Package><Extensions> ─────────────── $outerExtensions = $null foreach ($child in $outerPkgEl.ChildNodes) { if ($child.LocalName -eq 'Extensions' -and $child.ParentNode -eq $outerPkgEl) { $outerExtensions = $child break } } if (-not $outerExtensions) { $outerExtensions = $outerXml.CreateElement('Extensions', $outerPkgEl.NamespaceURI) $null = $outerPkgEl.AppendChild($outerExtensions) } # ── Locate inner <Package><Extensions> ─────────────────────────── $innerExtensions = $null foreach ($child in $innerPkgEl.ChildNodes) { if ($child.LocalName -eq 'Extensions' -and $child.ParentNode -eq $innerPkgEl) { $innerExtensions = $child break } } $extensionsMerged = 0 $pathsFixed = 0 if ($innerExtensions) { foreach ($extNode in @($innerExtensions.ChildNodes)) { if ($extNode.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue } if ($extNode.LocalName -ne 'Extension') { continue } # ImportNode (deep) so foreign-namespace nodes keep their URIs. $cloned = $outerXml.ImportNode($extNode, $true) $null = $outerExtensions.AppendChild($cloned) $extensionsMerged++ # Rewrite every com:Class/@Path inside this just-imported subtree. # com:Class can live at varying depths under com:Extension — # walk the subtree. $stack = New-Object 'System.Collections.Generic.Stack[System.Xml.XmlNode]' $stack.Push($cloned) while ($stack.Count -gt 0) { $n = $stack.Pop() if ($n.NodeType -eq [System.Xml.XmlNodeType]::Element -and $n.LocalName -eq 'Class') { $pathAttr = $n.Attributes['Path'] if ($pathAttr) { $old = $pathAttr.Value if ($old) { $alreadyRelative = ( $old -match '^[A-Za-z]:[\\/]' -or $old.StartsWith('VFS\') -or $old.StartsWith('VFS/') -or $old.StartsWith('\\') ) if (-not $alreadyRelative) { $new = if ($basePath) { (Join-Path $basePath $old) } else { $old } $pathAttr.Value = $new $pathsFixed++ Write-MsixLog Info "Sparse merge: Path '$old' -> '$new'" } else { Write-MsixLog Debug "Sparse merge: leaving already-rooted Path '$old' alone" } } } } foreach ($c in $n.ChildNodes) { $stack.Push($c) } } } } # ── Copy inner payload into outer VFS ───────────────────────────── $skipNames = @( 'AppxManifest.xml', 'AppxBlockMap.xml', 'AppxSignature.p7x', '[Content_Types].xml', 'Resources.pri', 'resources.pri' ) $destRoot = if ($basePath) { (Join-Path $workspace $basePath) } else { $workspace } if (-not (Test-Path -LiteralPath $destRoot)) { New-Item -ItemType Directory -Path $destRoot -Force | Out-Null } $filesCopied = 0 foreach ($item in Get-ChildItem -LiteralPath $inner -Recurse -File -ErrorAction SilentlyContinue) { $rel = $item.FullName.Substring($inner.Length).TrimStart('\','/') # Skip top-level package metadata files if ($skipNames -contains $item.Name) { # only skip when at the inner root, not deeper $top = ($rel -split '[\\/]')[0] if ($top -eq $item.Name) { continue } } # Skip AppxMetadata\* (always under that folder name at root) if ($rel -match '^AppxMetadata[\\/]') { continue } $destPath = Join-Path $destRoot $rel $destDir = Split-Path -Parent $destPath if ($destDir -and -not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } Copy-Item -LiteralPath $item.FullName -Destination $destPath -Force $filesCopied++ } Write-MsixLog Info "Sparse merge: copied $filesCopied file(s) into '$basePath'" # ── Delete the inner .msix unless asked to keep ────────────────── if (-not $KeepInnerPackage) { if ($PSCmdlet.ShouldProcess($innerPkg, 'Remove nested .msix')) { Remove-Item -LiteralPath $innerPkg -Force -ErrorAction SilentlyContinue Write-MsixLog Info "Sparse merge: removed inner package '$NestedPackagePath'" } } # ── Bump MaxVersionTested for desktop4 context menus ───────────── Set-MsixManifestMaxVersionTested -Manifest $outerXml -MinBuild 17763 # ── Save merged outer manifest ─────────────────────────────────── Save-MsixManifest -Manifest $outerXml -Path $outerManifestPath # ── Repack — atomic scratch / sign / move ──────────────────────── $target = if ($OutputPath) { $OutputPath } else { $fileinfo.FullName } $scratch = Join-Path $env:TEMP ("msix-sparse-{0}{1}" -f ([guid]::NewGuid().ToString('N').Substring(0,8)), ([System.IO.Path]::GetExtension($target))) $packOk = $false try { $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack', '/p', $scratch, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx pack' $packOk = $true if (-not $SkipSigning) { Invoke-MsixSigning -PackagePath $scratch -Pfx $Pfx -PfxPassword $PfxPassword } Move-Item -LiteralPath $scratch -Destination $target -Force return [pscustomobject]@{ NestedPackagePath = $NestedPackagePath BasePath = $basePath ExtensionsMerged = $extensionsMerged FilesCopied = $filesCopied PathsFixed = $pathsFixed Output = $target } } catch { if ($packOk -and $UnsignedOutputPath) { Copy-Item -LiteralPath $scratch -Destination $UnsignedOutputPath -Force -ErrorAction SilentlyContinue Write-MsixLog Warning "Signing failed. Unsigned package preserved at: $UnsignedOutputPath" } throw } finally { if (Test-Path -LiteralPath $scratch) { Remove-Item -LiteralPath $scratch -Force -ErrorAction SilentlyContinue } } } finally { Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $inner) { Remove-Item -LiteralPath $inner -Recurse -Force -ErrorAction SilentlyContinue } } } |