MSIX.ManifestExtensions.ps1
|
# ============================================================================= # Manifest-only fixers # ----------------------------------------------------------------------------- # Cmdlets that solve compatibility problems by ADDING the right element to # AppxManifest.xml — no PSF DLL injection required. These are the modern # alternatives to (and where applicable, complements of) PSF. # # Coverage: # # Properties-level (under <Package><Properties> — NOT <Extensions>): # Set-MsixFileSystemWriteVirtualization desktop6 (Win10 19041+) ← flag + virtualization:ExcludedDirectories # Set-MsixRegistryWriteVirtualization desktop6 (Win10 19041+) ← flag + virtualization:ExcludedKeys # # Package-level extensions (under <Package><Extensions>): # Set-MsixInstalledLocationVirtualization uap10 (Win10 19041+) ← schema: Package-level only # Add-MsixFontExtension uap4 (Win10 14393+) ← schema: Package-level only # # Application-level extensions (under <Application><Extensions>): # Add-MsixLoaderSearchPathOverride uap6 (Win10 17134+) # Add-MsixComServerExtension com (always) # Add-MsixFirewallRule desktop2 (Win10 15063+) # Add-MsixProtocolHandler uap (always) # Add-MsixFileTypeAssociation uap (always) # Add-MsixStartupTask uap5 (Win10 15063+) # # Each cmdlet: # - Adds the required namespace declarations (idempotent) # - Bumps MaxVersionTested when a feature requires a newer build # - Repacks and (unless -SkipSigning) re-signs the package # - Supports -OutputPath for non-destructive runs # ============================================================================= #region Private helper ------------------------------------------------------- function _MsixMutateManifest { <# Shared unpack/edit/repack/sign cycle. The $Mutate scriptblock receives the parsed [xml] manifest and is expected to mutate it in place. Atomic pack-then-sign: the new package is always built in a scratch location in $env:TEMP. Only after signing succeeds is it moved to $target. If signing fails, the original $target file is left untouched. -UnsignedOutputPath When supplied AND signing fails, the unsigned scratch package is copied to this path before being cleaned up — so the caller can inspect it or sign it manually. -SaveManifestTo When specified, the mutated AppxManifest.xml is copied to this path BEFORE MakeAppx packs. Useful for diagnosing schema validation failures: you can inspect the exact XML that MakeAppx rejected without digging into %TEMP%. -WhatIfPreview When set, runs the unpack + transform + pack stages so the user can preview what the modified package would look like, but SKIPS the destructive final steps (signing and the Move-Item that replaces the target). If -UnsignedOutputPath is supplied, the unsigned scratch package is copied there so the user can inspect it. The original target file is never touched. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [scriptblock]$Mutate, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$Activity = 'Mutate manifest', [string]$SaveManifestTo, [string]$UnsignedOutputPath, [switch]$WhatIfPreview ) $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item -LiteralPath $PackagePath -ErrorAction Stop $workspace = New-MsixWorkspace -PackageName $fileinfo.BaseName $r = Invoke-MsixProcess -FilePath "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o') Assert-MsixProcessSuccess -Result $r -Operation 'MakeAppx unpack' $null = Test-MsixManifest -Path "$workspace\AppxManifest.xml" [xml]$manifest = Get-MsixManifest -Path "$workspace\AppxManifest.xml" $manifest = Invoke-MsixManifestTransform -Manifest $manifest -Transform $Mutate Save-MsixManifest -Manifest $manifest -Path "$workspace\AppxManifest.xml" if ($SaveManifestTo) { Copy-Item -Path "$workspace\AppxManifest.xml" -Destination $SaveManifestTo -Force Write-MsixLog -Level Info -Message "Debug manifest saved to: $SaveManifestTo" } $target = if ($OutputPath) { $OutputPath } else { $fileinfo.FullName } $scratchExt = [System.IO.Path]::GetExtension($target) if (-not $scratchExt) { $scratchExt = '.msix' } $scratch = Join-Path -Path $env:TEMP -ChildPath ("msix-pack-{0}{1}" -f ([guid]::NewGuid().ToString('N').Substring(0,8)), $scratchExt) $packSucceeded = $false $signSucceeded = $false try { Write-MsixLog -Level Info -Message "$Activity -> $target" $r = Invoke-MsixProcess -FilePath "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack','/p',$scratch,'/d',$workspace,'/o') Assert-MsixProcessSuccess -Result $r -Operation 'MakeAppx pack' $packSucceeded = $true if ($WhatIfPreview) { Write-MsixLog -Level Info -Message "[WhatIf] Would replace '$target' with mutated package. Signing skipped." if ($UnsignedOutputPath) { Copy-Item -LiteralPath $scratch -Destination $UnsignedOutputPath -Force -ErrorAction Stop Write-MsixLog -Level Info -Message "[WhatIf] Preview package copied to: $UnsignedOutputPath" } return $null } if (-not $SkipSigning) { Invoke-MsixSigning -PackagePath $scratch -Pfx $Pfx -PfxPassword $PfxPassword } $signSucceeded = $true Move-Item -LiteralPath $scratch -Destination $target -Force return Get-Item -LiteralPath $target -ErrorAction Stop } catch { if ($packSucceeded -and -not $signSucceeded -and $UnsignedOutputPath) { try { Copy-Item -LiteralPath $scratch -Destination $UnsignedOutputPath -Force -ErrorAction Stop Write-MsixLog -Level Warning -Message "Signing failed. Unsigned package preserved at: $UnsignedOutputPath" } catch { Write-MsixLog -Level Error -Message "Signing failed AND unsigned-output copy to '$UnsignedOutputPath' failed: $_" } } elseif ($packSucceeded -and -not $signSucceeded) { Write-MsixLog -Level Warning -Message "Signing failed. Original target '$target' is unchanged. Pass -UnsignedOutputPath to preserve the unsigned package next time." } throw } finally { if (Test-Path -LiteralPath $scratch) { Remove-Item -LiteralPath $scratch -Force -ErrorAction SilentlyContinue } Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } } function _MsixGetOrCreatePackageExtensions { param([xml]$Manifest) $ext = $Manifest.Package.Extensions if (-not $ext) { $ext = $Manifest.CreateElement('Extensions', $Manifest.Package.NamespaceURI) $null = $Manifest.Package.AppendChild($ext) } return $ext } function Invoke-MsixManifestTransform { <# Pure manifest transform — no file IO, no signing. Accepts an [xml] or MSIX.ManifestDocument, runs $Transform against it, returns the mutated [xml]. Used internally by _MsixMutateManifest. #> [CmdletBinding()] param( [Parameter(Mandatory)] $Manifest, [Parameter(Mandatory)] [scriptblock] $Transform ) # Normalise to raw [xml] — callers that pass MSIX.ManifestDocument get its .Document $xml = if ($Manifest.PSTypeNames -contains 'MSIX.ManifestDocument') { $Manifest.Document } elseif ($Manifest -is [System.Xml.XmlDocument]) { $Manifest } else { # SECURITY: a raw [xml] cast on a string invokes XmlDocument.LoadXml with # the default resolver and DTD processing enabled (XXE). Route untrusted # manifest text through the hardened loader instead. _MsixLoadXmlSecure -XmlText ([string]$Manifest) } # Bind the transform to this module's session state so module-private # helpers it calls always resolve regardless of where the block was created. # Falls back to the block as-is if it is already bound to a different module. $boundTransform = $Transform if ($ExecutionContext.SessionState.Module) { try { $boundTransform = $ExecutionContext.SessionState.Module.NewBoundScriptBlock($Transform) } catch { $boundTransform = $Transform } } & $boundTransform $xml return $xml } function _MsixGetOrCreateApplicationExtensions { # Returns the Application XmlElement, creating its <Extensions> child if absent. # When $AppId is empty, defaults to the first Application in the manifest. param([xml]$Manifest, [string]$AppId) $app = Get-MsixManifestApplication -Manifest $Manifest -AppId $AppId if (-not $app) { $apps = @(Get-MsixManifestApplication -Manifest $Manifest) if ($AppId) { throw "Application '$AppId' not found. Available: $(($apps | ForEach-Object { $_.GetAttribute('Id') }) -join ', ')" } else { throw 'No Application elements found in the manifest.' } } # Use SelectSingleNode (namespace-agnostic) so property access quirks do not bite us. if (-not $app.SelectSingleNode('*[local-name()="Extensions"]')) { $extNode = $Manifest.CreateElement('Extensions', $Manifest.Package.NamespaceURI) $null = $app.AppendChild($extNode) } return $app } #endregion #region File / registry write virtualization (desktop6) --------------------- function Set-MsixFileSystemWriteVirtualization { <# .SYNOPSIS Disables (default) or enables filesystem write virtualization for the package, with optional excluded directories. .DESCRIPTION Sets <desktop6:FileSystemWriteVirtualization> inside <Properties> to 'disabled' by default (MSIX enables write virtualization out of the box; for most converted Win32 apps the right fix is to disable it so writes reach the real file system). Pass -Enable to write 'enabled' instead. When -ExcludedDirectories is supplied (default: LocalAppData + RoamingAppData), a <virtualization:FileSystemWriteVirtualization> element is also written in <Properties> — matching the structure produced by the MSIX Packaging Tool. The excluded dirs are always written alongside the disabled/enabled flag (the commercial tool does both together). Also adds rescap:Capability Name="unvirtualizedResources" automatically (required by the MSIX schema whenever this element is present). Requires Windows 10 build 19041+. MaxVersionTested is bumped automatically. NOTE: Goes in <Properties>, NOT in <Extensions>. The MSIX schema does NOT accept 'windows.filesystemwritevirtualization' as an Extensions Category. .PARAMETER PackagePath .msix file to mutate. .PARAMETER Enable Write 'enabled' instead of the default 'disabled'. .PARAMETER ExcludedDirectories Paths excluded from virtualization. Defaults to LocalAppData and RoamingAppData — the same defaults as the MSIX Packaging Tool. Use KnownFolder tokens (e.g. '$(KnownFolder:LocalAppData)') or VFS-relative paths. Pass @() to suppress excluded-dirs entirely. .PARAMETER OutputPath / SkipSigning / Pfx / PfxPassword See Add-MsixPsfV2. .EXAMPLE # Disable write virtualization (the standard MSIX-conversion fix): Set-MsixFileSystemWriteVirtualization -PackagePath app.msix -Pfx cert.pfx -PfxPassword 'P@ss' .EXAMPLE # Disable with a custom extra exclusion: Set-MsixFileSystemWriteVirtualization -PackagePath app.msix ` -ExcludedDirectories '$(KnownFolder:LocalAppData)','$(KnownFolder:RoamingAppData)','VFS/ProgramFilesX64/App/Cache' ` -Pfx cert.pfx -PfxPassword 'P@ss' #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [switch]$Enable, [string[]]$ExcludedDirectories = @('$(KnownFolder:LocalAppData)', '$(KnownFolder:RoamingAppData)'), [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Set FileSystemWriteVirtualization') _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'desktop6:FileSystemWriteVirtualization' -Mutate { param([xml]$M) Add-MsixManifestNamespace -Manifest $M -Prefix 'desktop6' Add-MsixManifestNamespace -Manifest $M -Prefix 'rescap' Set-MsixManifestMaxVersionTested -Manifest $M -MinBuild 19041 $props = $M.Package.Properties $d6 = Get-MsixManifestNamespaceUri -Prefix 'desktop6' # ── desktop6 flag element (idempotent) ───────────────────────────── $flag = $props.SelectSingleNode( '*[local-name()="FileSystemWriteVirtualization" and ' + 'namespace-uri()="' + $d6 + '"]') if (-not $flag) { $flag = $M.CreateElement('desktop6:FileSystemWriteVirtualization', $d6) $null = $props.AppendChild($flag) } $flag.InnerText = if ($Enable) { 'enabled' } else { 'disabled' } Write-MsixLog -Level Info -Message "desktop6:FileSystemWriteVirtualization set to '$($flag.InnerText)'." # ── virtualization:ExcludedDirectories ──────────────────────────── # Always written alongside the flag (matches commercial tool behaviour). $virtUri = Get-MsixManifestNamespaceUri -Prefix 'virtualization' $virtNode = $props.SelectSingleNode( '*[local-name()="FileSystemWriteVirtualization" and ' + 'namespace-uri()="' + $virtUri + '"]') if ($virtNode) { $null = $props.RemoveChild($virtNode) } # SECURITY/CORRECTNESS (issue #81): virtualization:ExcludedDirectory only # accepts a KnownFolder token of the form $(KnownFolder:Name) optionally # followed by \subpath — it is the MSIX schema's documented pattern # \$\([kK][nN][oO][wW][nN][fF][oO][lL][dD][eE][rR]:[A-Za-z0-9]{1,32}\)(\\.+)? # An install-relative / VFS path such as 'VFS/ProgramFilesX64/App/Lang' # CANNOT be expressed here and makes MakeAppx fail schema validation. # Normalise separators, drop invalid entries with a clear warning, and # only emit the element when at least one valid token remains — so this # function can never produce a manifest MakeAppx rejects. $knownFolderRx = [regex]'^\$\(KnownFolder:[A-Za-z0-9]{1,32}\)(\\.+)?$' $validDirs = [System.Collections.Generic.List[string]]::new() foreach ($dir in $ExcludedDirectories) { # ExcludedDirectory uses backslash separators inside the subpath. $norm = ([string]$dir).Replace('/', '\') if ($knownFolderRx.IsMatch($norm)) { if (-not $validDirs.Contains($norm)) { $validDirs.Add($norm) } } else { Write-MsixLog -Level Warning -Message "Skipping ExcludedDirectory '$dir': virtualization:ExcludedDirectory only accepts a `$(KnownFolder:Name)[\subpath] token, not an install-relative/VFS path. Use the PSF FileRedirection route (Add-MsixPsfV2 / -LegacyPluginFix) for install-directory passthrough." } } if ($validDirs.Count -gt 0) { Add-MsixManifestNamespace -Manifest $M -Prefix 'virtualization' $virtNode = $M.CreateElement('virtualization:FileSystemWriteVirtualization', $virtUri) $dirs = $M.CreateElement('virtualization:ExcludedDirectories', $virtUri) foreach ($dir in $validDirs) { $entry = $M.CreateElement('virtualization:ExcludedDirectory', $virtUri) $entry.InnerText = $dir $null = $dirs.AppendChild($entry) } $null = $virtNode.AppendChild($dirs) $null = $props.AppendChild($virtNode) Write-MsixLog -Level Info -Message "virtualization:FileSystemWriteVirtualization: $($validDirs.Count) excluded dir(s)." } elseif ($ExcludedDirectories.Count -gt 0) { Write-MsixLog -Level Warning -Message 'No valid KnownFolder ExcludedDirectory tokens remained; emitting the disabled flag only (no virtualization:ExcludedDirectories element).' } # ── unvirtualizedResources capability (required by the schema) ───── $rescapUri = Get-MsixManifestNamespaceUri -Prefix 'rescap' $capsNode = $M.Package.Capabilities if (-not $capsNode) { $capsNode = $M.CreateElement('Capabilities', $M.Package.NamespaceURI) $null = $M.Package.AppendChild($capsNode) } $alreadyCap = $capsNode.ChildNodes | Where-Object { $_.LocalName -eq 'Capability' -and $_.Name -eq 'unvirtualizedResources' } if (-not $alreadyCap) { $cap = $M.CreateElement('rescap:Capability', $rescapUri) $cap.SetAttribute('Name', 'unvirtualizedResources') $null = $capsNode.AppendChild($cap) Write-MsixLog -Level Info -Message "Capability added: unvirtualizedResources" } } } function Set-MsixRegistryWriteVirtualization { <# .SYNOPSIS Disables (default) or enables registry write virtualization for the package, with optional excluded registry keys. .DESCRIPTION Sets <desktop6:RegistryWriteVirtualization> inside <Properties> to 'disabled' by default (MSIX enables registry write virtualization out of the box; for most converted Win32 apps the fix is to disable it). Pass -Enable to write 'enabled' instead. When -ExcludedKeys is supplied, a <virtualization:RegistryWriteVirtualization> element is also written in <Properties> with the excluded key list. Also adds rescap:Capability Name="unvirtualizedResources" automatically. Requires Windows 10 build 19041+. NOTE: Goes in <Properties>, NOT in <Extensions>. .PARAMETER Enable Write 'enabled' instead of the default 'disabled'. .PARAMETER ExcludedKeys Registry key paths that should be passed through to the host registry instead of being virtualized. Only HKEY_CURRENT_USER\* paths are valid — HKLM exclusions are not supported by the schema and will throw. Maximum 512 chars per key path. Duplicates (case-insensitive) are collapsed. No defaults — omit to skip the virtualization:RegistryWriteVirtualization section entirely. .EXAMPLE Set-MsixRegistryWriteVirtualization -PackagePath app.msix -Pfx cert.pfx -PfxPassword 'P@ss' .EXAMPLE # Selectively pass through specific HKCU subkeys to the host registry Set-MsixRegistryWriteVirtualization -PackagePath app.msix ` -ExcludedKeys 'HKEY_CURRENT_USER\SOFTWARE\Contoso','HKEY_CURRENT_USER\SOFTWARE\Contoso\v2' ` -Pfx cert.pfx -PfxPassword $pw #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [switch]$Enable, [string[]]$ExcludedKeys = @(), [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Set RegistryWriteVirtualization') _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'desktop6:RegistryWriteVirtualization' -Mutate { param([xml]$M) # ── validate -ExcludedKeys before any mutation ───────────────────── $validatedKeys = @() if ($ExcludedKeys.Count -gt 0) { foreach ($key in $ExcludedKeys) { if ([string]::IsNullOrWhiteSpace($key)) { throw "ExcludedKeys entries may not be empty or whitespace." } if ($key -notmatch '^HKEY_CURRENT_USER\\') { throw "ExcludedKeys may only contain HKEY_CURRENT_USER paths. Got: '$key'" } if ($key.Length -gt 512) { throw "ExcludedKeys entry exceeds 512 chars ($($key.Length)): '$key'" } } # Case-insensitive dedupe, preserve first-seen order. $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($key in $ExcludedKeys) { if ($seen.Add($key)) { $validatedKeys += $key } } } Add-MsixManifestNamespace -Manifest $M -Prefix 'desktop6' Add-MsixManifestNamespace -Manifest $M -Prefix 'rescap' Set-MsixManifestMaxVersionTested -Manifest $M -MinBuild 19041 $props = $M.Package.Properties $d6 = Get-MsixManifestNamespaceUri -Prefix 'desktop6' # ── desktop6 flag element (idempotent) ───────────────────────────── $flag = $props.SelectSingleNode( '*[local-name()="RegistryWriteVirtualization" and ' + 'namespace-uri()="' + $d6 + '"]') if (-not $flag) { $flag = $M.CreateElement('desktop6:RegistryWriteVirtualization', $d6) $null = $props.AppendChild($flag) } $flag.InnerText = if ($Enable) { 'enabled' } else { 'disabled' } Write-MsixLog -Level Info -Message "desktop6:RegistryWriteVirtualization set to '$($flag.InnerText)'." # ── virtualization:ExcludedKeys (optional) ───────────────────────── $virtUri = Get-MsixManifestNamespaceUri -Prefix 'virtualization' $virtNode = $props.SelectSingleNode( '*[local-name()="RegistryWriteVirtualization" and ' + 'namespace-uri()="' + $virtUri + '"]') if ($virtNode) { $null = $props.RemoveChild($virtNode) } if ($validatedKeys.Count -gt 0) { Add-MsixManifestNamespace -Manifest $M -Prefix 'virtualization' $virtNode = $M.CreateElement('virtualization:RegistryWriteVirtualization', $virtUri) $keys = $M.CreateElement('virtualization:ExcludedKeys', $virtUri) foreach ($k in $validatedKeys) { $entry = $M.CreateElement('virtualization:ExcludedKey', $virtUri) $entry.SetAttribute('Key', $k) $null = $keys.AppendChild($entry) } $null = $virtNode.AppendChild($keys) $null = $props.AppendChild($virtNode) Write-MsixLog -Level Info -Message "virtualization:RegistryWriteVirtualization: $($validatedKeys.Count) excluded key(s)." } # ── unvirtualizedResources capability (required by the schema) ───── $rescapUri = Get-MsixManifestNamespaceUri -Prefix 'rescap' $capsNode = $M.Package.Capabilities if (-not $capsNode) { $capsNode = $M.CreateElement('Capabilities', $M.Package.NamespaceURI) $null = $M.Package.AppendChild($capsNode) } $alreadyCap = $capsNode.ChildNodes | Where-Object { $_.LocalName -eq 'Capability' -and $_.Name -eq 'unvirtualizedResources' } if (-not $alreadyCap) { $cap = $M.CreateElement('rescap:Capability', $rescapUri) $cap.SetAttribute('Name', 'unvirtualizedResources') $null = $capsNode.AppendChild($cap) Write-MsixLog -Level Info -Message "Capability added: unvirtualizedResources" } } } function Set-MsixInstalledLocationVirtualization { <# .SYNOPSIS Adds (or removes) the uap10:InstalledLocationVirtualization extension, making writes to the install dir survive at a per-user location with explicit update-time policy. .DESCRIPTION Smarter than Set-MsixFileSystemWriteVirtualization for cases where you need explicit control over what happens to user-modified, deleted, and added files when the package is updated. Min OS: Windows 10 2004 (build 19041+). MaxVersionTested is bumped automatically. .PARAMETER ModifiedItems / DeletedItems / AddedItems Each accepts 'keep' or 'reset'. Defaults match TMEditX: ModifiedItems=keep, DeletedItems=reset, AddedItems=keep. .EXAMPLE Set-MsixInstalledLocationVirtualization -PackagePath app.msix ` -DeletedItems keep -Pfx cert.pfx -PfxPassword 'P@ss' #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [ValidateSet('keep','reset')] [string]$ModifiedItems = 'keep', [ValidateSet('keep','reset')] [string]$DeletedItems = 'reset', [ValidateSet('keep','reset')] [string]$AddedItems = 'keep', [switch]$Disable, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Set InstalledLocationVirtualization') _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'uap10:InstalledLocationVirtualization' -Mutate { param([xml]$M) Add-MsixManifestNamespace -Manifest $M -Prefix 'uap10' Set-MsixManifestMaxVersionTested -Manifest $M -MinBuild 19041 $pkgExt = _MsixGetOrCreatePackageExtensions -Manifest $M $cat = 'windows.installedLocationVirtualization' $existing = $pkgExt.ChildNodes | Where-Object { $_.LocalName -eq 'Extension' -and $_.Category -eq $cat } foreach ($e in @($existing)) { $null = $pkgExt.RemoveChild($e) } if ($Disable) { Write-MsixLog -Level Info -Message 'InstalledLocationVirtualization disabled.' return } $u10 = Get-MsixManifestNamespaceUri -Prefix 'uap10' $ext = $M.CreateElement('uap10:Extension', $u10) $ext.SetAttribute('Category', $cat) $body = $M.CreateElement('uap10:InstalledLocationVirtualization', $u10) $upd = $M.CreateElement('uap10:UpdateActions', $u10) $upd.SetAttribute('ModifiedItems', $ModifiedItems) $upd.SetAttribute('DeletedItems', $DeletedItems) $upd.SetAttribute('AddedItems', $AddedItems) $null = $body.AppendChild($upd) $null = $ext.AppendChild($body) $null = $pkgExt.AppendChild($ext) Write-MsixLog -Level Info -Message "uap10:InstalledLocationVirtualization added (Mod=$ModifiedItems, Del=$DeletedItems, Add=$AddedItems)." } } #endregion #region Loader search path override (uap6) ---------------------------------- function Add-MsixLoaderSearchPathOverride { <# .SYNOPSIS Adds package-relative directories to the DLL loader search path for a specific application — a manifest alternative to DynamicLibraryFixup for the simple "DLL not found" case. .DESCRIPTION Min OS: Windows 10 build 17134 (1803). MaxVersionTested is bumped automatically. .PARAMETER AppId Id of the Application element to extend. Defaults to the first Application in the manifest. .PARAMETER Paths Up to 5 package-relative directory paths (forward slashes). Each is added as a uap6:LoaderSearchPathEntry under the override element. .EXAMPLE Add-MsixLoaderSearchPathOverride -PackagePath app.msix ` -Paths 'VFS/ProgramFilesX64/App/lib','VFS/ProgramFilesX64/App/bin' ` -Pfx cert.pfx -PfxPassword 'P@ss' #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'AppId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$AppId, [Parameter(Mandatory)] [ValidateCount(1,5)] [string[]]$Paths, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Add LoaderSearchPathOverride') _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'uap6:LoaderSearchPathOverride' -Mutate { param([xml]$M) Add-MsixManifestNamespace -Manifest $M -Prefix 'uap6' Set-MsixManifestMaxVersionTested -Manifest $M -MinBuild 17134 $app = _MsixGetOrCreateApplicationExtensions -Manifest $M -AppId $AppId $appExt = $app.SelectSingleNode('*[local-name()="Extensions"]') # Find an existing override or create one. $existing = $appExt.ChildNodes | Where-Object { $_.LocalName -eq 'Extension' -and ($_.SelectSingleNode('*[local-name()="LoaderSearchPathOverride"]')) } | Select-Object -First 1 if ($existing) { $body = $existing.SelectSingleNode('*[local-name()="LoaderSearchPathOverride"]') } else { $u6 = Get-MsixManifestNamespaceUri -Prefix 'uap6' $ext = $M.CreateElement('uap6:Extension', $u6) $ext.SetAttribute('Category', 'windows.loaderSearchPathOverride') $body = $M.CreateElement('uap6:LoaderSearchPathOverride', $u6) $null = $ext.AppendChild($body) $null = $appExt.AppendChild($ext) } $u6 = Get-MsixManifestNamespaceUri -Prefix 'uap6' foreach ($p in $Paths) { # Idempotent: skip if same entry already present $already = $body.ChildNodes | Where-Object { $_.LocalName -eq 'LoaderSearchPathEntry' -and $_.LoaderSearchPath -eq $p } if ($already) { Write-MsixLog -Level Info -Message "LoaderSearchPathEntry already present: $p" continue } $entry = $M.CreateElement('uap6:LoaderSearchPathEntry', $u6) $entry.SetAttribute('LoaderSearchPath', $p) $null = $body.AppendChild($entry) Write-MsixLog -Level Info -Message "LoaderSearchPathEntry added: $p" } # Schema caps at 5 entries $count = ($body.ChildNodes | Where-Object LocalName -eq 'LoaderSearchPathEntry').Count if ($count -gt 5) { throw "uap6:LoaderSearchPathOverride only supports 5 entries; package now has $count." } } } #endregion #region Firewall rule (desktop2) -------------------------------------------- function Add-MsixFirewallRule { <# .SYNOPSIS Registers a Windows Firewall rule that's installed/removed alongside the MSIX package. .DESCRIPTION Adds a desktop2:FirewallRules extension under Package/Extensions. Replaces ad-hoc netsh / New-NetFirewallRule calls in installer scripts — the rule lifecycle now follows the package. Min OS: Windows 10 build 15063. .PARAMETER AppId Application ID to validate against the manifest. Firewall rules are emitted at package scope, as required by the Windows manifest schema. .PARAMETER Executable The executable subject to the rule (package-relative path). .PARAMETER Direction 'in' (inbound) or 'out' (outbound). .PARAMETER Protocol TCP, UDP, ICMPv4, ICMPv6 (case-insensitive). .PARAMETER LocalPort Single port or range (e.g. 1337, 5000-5010, '*' for any). .PARAMETER Profile domain | private | public | all (default: all). .EXAMPLE Add-MsixFirewallRule -PackagePath app.msix -AppId App ` -Executable 'VFS/ProgramFilesX64/App/server.exe' ` -Direction in -Protocol TCP -LocalPort 5000-5010 ` -Pfx cert.pfx -PfxPassword 'P@ss' .NOTES Min OS: Windows 10 1703 (build 15063+). MaxVersionTested is bumped automatically. #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'AppId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$AppId, [Parameter(Mandatory)] [string]$Executable, [Parameter(Mandatory)] [ValidateSet('in','out')] [string]$Direction, [Parameter(Mandatory)] [ValidateSet('TCP','UDP','ICMPv4','ICMPv6')] [string]$Protocol, [string]$LocalPort = '*', [ValidateSet('domain','private','public','all')] [string]$FwProfile = 'all', [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, "Add firewall rule for $AppId") _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'desktop2:FirewallRules' -Mutate { param([xml]$M) Add-MsixManifestNamespace -Manifest $M -Prefix 'desktop2' Add-MsixManifestNamespace -Manifest $M -Prefix 'rescap' Set-MsixManifestMaxVersionTested -Manifest $M -MinBuild 15063 $app = Get-MsixManifestApplication -Manifest $M -AppId $AppId if (-not $app) { throw "Application '$AppId' not found in the manifest." } $d2 = Get-MsixManifestNamespaceUri -Prefix 'desktop2' # windows.firewallRules is a package-level extension: # Package/Extensions/desktop2:Extension/desktop2:FirewallRules $pkgExt = _MsixGetOrCreatePackageExtensions -Manifest $M $rulesParent = $null foreach ($e in @($pkgExt.ChildNodes | Where-Object { $_.LocalName -eq 'Extension' -and $_.Category -eq 'windows.firewallRules' })) { $rules = $e.SelectSingleNode('*[local-name()="FirewallRules"]') if ($rules.Executable -eq $Executable) { $rulesParent = $rules; break } } if (-not $rulesParent) { $ext = $M.CreateElement('desktop2:Extension', $d2) $ext.SetAttribute('Category', 'windows.firewallRules') $rulesParent = $M.CreateElement('desktop2:FirewallRules', $d2) $rulesParent.SetAttribute('Executable', $Executable) $null = $ext.AppendChild($rulesParent) $null = $pkgExt.AppendChild($ext) } $rescapUri = Get-MsixManifestNamespaceUri -Prefix 'rescap' $capsNode = $M.Package.Capabilities if (-not $capsNode) { $capsNode = $M.CreateElement('Capabilities', $M.Package.NamespaceURI) $null = $M.Package.AppendChild($capsNode) } $hasFullTrust = $capsNode.ChildNodes | Where-Object { $_.LocalName -eq 'Capability' -and $_.GetAttribute('Name') -eq 'runFullTrust' } if (-not $hasFullTrust) { $cap = $M.CreateElement('rescap:Capability', $rescapUri) $cap.SetAttribute('Name', 'runFullTrust') $null = $capsNode.AppendChild($cap) Write-MsixLog -Level Info -Message 'Capability added: runFullTrust' } # Idempotent rule add $rule = $M.CreateElement('desktop2:Rule', $d2) $rule.SetAttribute('Direction', $Direction) $rule.SetAttribute('IPProtocol', $Protocol) $rule.SetAttribute('Profile', $FwProfile) if ($LocalPort -ne '*') { if ($LocalPort -match '^(\d+)-(\d+)$') { $rule.SetAttribute('LocalPortMin', $matches[1]) $rule.SetAttribute('LocalPortMax', $matches[2]) } else { $rule.SetAttribute('LocalPortMin', $LocalPort) $rule.SetAttribute('LocalPortMax', $LocalPort) } } $null = $rulesParent.AppendChild($rule) Write-MsixLog -Level Info -Message "FirewallRule: $Direction $Protocol $LocalPort -> $Executable" } } #endregion #region Protocol handler / FTA / Startup task ------------------------------- function Add-MsixProtocolHandler { <# .SYNOPSIS Registers a custom URL protocol (e.g. myapp://) handled by an application in the package. .PARAMETER Name Protocol scheme, no trailing colon (e.g. 'contoso'). .PARAMETER DisplayName Friendly name shown to users. .EXAMPLE Add-MsixProtocolHandler -PackagePath app.msix -AppId App ` -Name contoso -DisplayName 'Contoso Launcher' ` -Pfx cert.pfx -PfxPassword 'P@ss' #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'AppId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$AppId, [Parameter(Mandatory)] [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'Protocol Name must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen (no spaces).' )] [string]$Name, [string]$DisplayName, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, "Add protocol $Name") _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'uap:Protocol' -Mutate { param([xml]$M) Add-MsixManifestNamespace -Manifest $M -Prefix 'uap' $app = _MsixGetOrCreateApplicationExtensions -Manifest $M -AppId $AppId $appExt = $app.SelectSingleNode('*[local-name()="Extensions"]') $uap = Get-MsixManifestNamespaceUri -Prefix 'uap' # Idempotent: same Name already declared? $already = $appExt.SelectNodes('*[local-name()="Extension" and @Category="windows.protocol"]') | ForEach-Object { $_.SelectSingleNode('*[local-name()="Protocol" and @Name="' + $Name + '"]') } | Where-Object { $_ } if ($already) { Write-MsixLog -Level Info -Message "Protocol '$Name' already registered." return } $ext = $M.CreateElement('uap:Extension', $uap) $ext.SetAttribute('Category', 'windows.protocol') $proto = $M.CreateElement('uap:Protocol', $uap) $proto.SetAttribute('Name', $Name) if ($DisplayName) { $dn = $M.CreateElement('uap:DisplayName', $uap) $dn.InnerText = $DisplayName $null = $proto.AppendChild($dn) } $null = $ext.AppendChild($proto) $null = $appExt.AppendChild($ext) Write-MsixLog -Level Info -Message "Protocol added: $Name" } } function Add-MsixFileTypeAssociation { <# .SYNOPSIS Registers a file type association (ProgID-style) so opening files of the given extension(s) launches the packaged app. .PARAMETER Name Internal association name (lowercase, no spaces). e.g. 'contosodoc'. .PARAMETER FileTypes Extensions (with leading dot) — '.txt', '.csv', ... .PARAMETER DisplayName Friendly name in the Open With… dialog. .EXAMPLE Add-MsixFileTypeAssociation -PackagePath app.msix -AppId App ` -Name contosodoc -FileTypes '.cdoc','.cdocx' -DisplayName 'Contoso Document' ` -Pfx cert.pfx -PfxPassword 'P@ss' #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'AppId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$AppId, [Parameter(Mandatory)] [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'FTA Name must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$Name, [Parameter(Mandatory)] [ValidateScript({ foreach ($t in $_) { # Allow optional leading dot — function auto-prefixes '.' to bare names. if ($t -notmatch '^\.?[a-zA-Z0-9][a-zA-Z0-9_.-]{0,31}$') { throw "Invalid file type: '$t'. Allowed: '.ext' or 'ext' (alphanumeric/underscore/dot/hyphen, max 32 chars after dot)." } } $true })] [string[]]$FileTypes, [string]$DisplayName, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, "Add FTA $Name") _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'uap:FileTypeAssociation' -Mutate { param([xml]$M) Add-MsixManifestNamespace -Manifest $M -Prefix 'uap' $app = _MsixGetOrCreateApplicationExtensions -Manifest $M -AppId $AppId $appExt = $app.SelectSingleNode('*[local-name()="Extensions"]') $uap = Get-MsixManifestNamespaceUri -Prefix 'uap' $ext = $M.CreateElement('uap:Extension', $uap) $ext.SetAttribute('Category', 'windows.fileTypeAssociation') $fta = $M.CreateElement('uap:FileTypeAssociation', $uap) $fta.SetAttribute('Name', $Name.ToLower()) if ($DisplayName) { $dn = $M.CreateElement('uap:DisplayName', $uap) $dn.InnerText = $DisplayName $null = $fta.AppendChild($dn) } $supported = $M.CreateElement('uap:SupportedFileTypes', $uap) foreach ($ft in $FileTypes) { if (-not $ft.StartsWith('.')) { $ft = ".$ft" } $type = $M.CreateElement('uap:FileType', $uap) $type.InnerText = $ft.ToLower() $null = $supported.AppendChild($type) } $null = $fta.AppendChild($supported) $null = $ext.AppendChild($fta) $null = $appExt.AppendChild($ext) Write-MsixLog -Level Info -Message "FTA $Name registered for: $($FileTypes -join ', ')" } } function Add-MsixStartupTask { <# .SYNOPSIS Registers a startup task — the manifest-native, properly-firing replacement for HKLM\…\Run autostart entries (which packaged apps don't honour). .PARAMETER TaskId Unique ID for the task (alphanumeric, no spaces). .PARAMETER DisplayName Friendly name shown in Settings > Startup apps. .PARAMETER Enabled Whether the task starts enabled. Users can flip this in Settings. .PARAMETER Executable Optional override (default: the Application's Executable). .EXAMPLE Add-MsixStartupTask -PackagePath app.msix -AppId App ` -TaskId ContosoStartup -DisplayName 'Contoso' -Enabled $true ` -Pfx cert.pfx -PfxPassword 'P@ss' .NOTES Min OS: Windows 10 1703 (build 15063+). MaxVersionTested is bumped automatically. #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'AppId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$AppId, [Parameter(Mandatory)] [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'TaskId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$TaskId, [Parameter(Mandatory)] [string]$DisplayName, [bool]$Enabled = $true, [string]$Executable, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, "Add StartupTask $TaskId") _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'uap5:StartupTask' -Mutate { param([xml]$M) Add-MsixManifestNamespace -Manifest $M -Prefix 'uap5' Set-MsixManifestMaxVersionTested -Manifest $M -MinBuild 15063 $app = _MsixGetOrCreateApplicationExtensions -Manifest $M -AppId $AppId $appExt = $app.SelectSingleNode('*[local-name()="Extensions"]') $u5 = Get-MsixManifestNamespaceUri -Prefix 'uap5' # Idempotent: same TaskId? $already = $appExt.SelectNodes('*[local-name()="Extension" and @Category="windows.startupTask"]') | ForEach-Object { $_.SelectSingleNode('*[local-name()="StartupTask" and @TaskId="' + $TaskId + '"]') } | Where-Object { $_ } if ($already) { Write-MsixLog -Level Info -Message "StartupTask '$TaskId' already registered." return } $exeAttr = if ($Executable) { $Executable } else { $app.Executable } $ext = $M.CreateElement('uap5:Extension', $u5) $ext.SetAttribute('Category', 'windows.startupTask') $ext.SetAttribute('Executable', $exeAttr) $ext.SetAttribute('EntryPoint', 'Windows.FullTrustApplication') $task = $M.CreateElement('uap5:StartupTask', $u5) $task.SetAttribute('TaskId', $TaskId) $task.SetAttribute('Enabled', ([string]$Enabled).ToLower()) $task.SetAttribute('DisplayName', $DisplayName) $null = $ext.AppendChild($task) $null = $appExt.AppendChild($ext) Write-MsixLog -Level Info -Message "StartupTask added: $TaskId (Enabled=$Enabled)" } } #endregion #region Shared fonts (uap4) ------------------------------------------------- function Add-MsixFontExtension { <# .SYNOPSIS Registers font files shipped inside the package with the OS via the uap4:SharedFonts manifest extension. Once installed, other apps see the fonts too. .PARAMETER FontPaths Package-relative paths to .ttf / .otf / .ttc files (forward slashes). Use Get-MsixFontCandidate to discover them. .EXAMPLE $fonts = Get-MsixFontCandidate -PackagePath app.msix | Select-Object -ExpandProperty Path Add-MsixFontExtension -PackagePath app.msix -FontPaths $fonts -Pfx cert.pfx -PfxPassword 'P@ss' .NOTES Min OS: Windows 10 1607 (build 14393+). #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [string[]]$FontPaths, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Add SharedFonts') _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'uap4:SharedFonts' -Mutate { param([xml]$M) Add-MsixManifestNamespace -Manifest $M -Prefix 'uap4' $u4 = Get-MsixManifestNamespaceUri -Prefix 'uap4' $pkgExt = _MsixGetOrCreatePackageExtensions -Manifest $M $cat = 'windows.sharedFonts' $existing = $pkgExt.ChildNodes | Where-Object { $_.LocalName -eq 'Extension' -and $_.Category -eq $cat } | Select-Object -First 1 if (-not $existing) { $existing = $M.CreateElement('uap4:Extension', $u4) $existing.SetAttribute('Category', $cat) $body = $M.CreateElement('uap4:SharedFonts', $u4) $null = $existing.AppendChild($body) $null = $pkgExt.AppendChild($existing) } else { $body = $existing.SelectSingleNode('*[local-name()="SharedFonts"]') } $alreadyFiles = $body.ChildNodes | Where-Object { $_.LocalName -eq 'Font' } | ForEach-Object { $_.File } foreach ($p in $FontPaths) { $rel = $p.Replace('\','/') if ($alreadyFiles -contains $rel) { Write-MsixLog -Level Info -Message "Font already registered: $rel" continue } $node = $M.CreateElement('uap4:Font', $u4) $node.SetAttribute('File', $rel) $null = $body.AppendChild($node) Write-MsixLog -Level Info -Message "Font registered: $rel" } } } #endregion #region Brand metadata ------------------------------------------------------ function Set-MsixBrandMetadata { <# .SYNOPSIS Bulk-updates the user-facing identity strings (DisplayName, PublisherDisplayName, Description, Logo) under <Properties>. .DESCRIPTION The ones inside <Properties> are what users see in Settings > Apps. Per-application VisualElements (also a DisplayName / Description) are left alone unless you pass -ApplyToApplications. .PARAMETER ApplyToApplications If set, also propagate DisplayName / Description into every Application's uap:VisualElements block. .EXAMPLE Set-MsixBrandMetadata -PackagePath app.msix ` -DisplayName 'Contoso Expenses' ` -PublisherDisplayName 'Contoso Ltd' ` -Description 'Customer-facing expense tracker.' ` -Pfx cert.pfx -PfxPassword 'P@ss' #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [string]$DisplayName, [string]$PublisherDisplayName, [string]$Description, [string]$LogoPath, [switch]$ApplyToApplications, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) if (-not ($DisplayName -or $PublisherDisplayName -or $Description -or $LogoPath)) { throw 'Pass at least one of -DisplayName / -PublisherDisplayName / -Description / -LogoPath.' } $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Set brand metadata') _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'Brand metadata' -Mutate { param([xml]$M) $props = $M.Package.Properties if (-not $props) { $props = $M.CreateElement('Properties', $M.Package.NamespaceURI) $null = $M.Package.AppendChild($props) } function _SetChild($Parent, $LocalName, $Value, $Ns) { if (-not $Value) { return } $node = $Parent.SelectSingleNode("*[local-name()='$LocalName']") if (-not $node) { $node = $M.CreateElement($LocalName, $Ns) $null = $Parent.AppendChild($node) } $node.InnerText = $Value } _SetChild -Parent $props -LocalName 'DisplayName' -Value $DisplayName -Ns $M.Package.NamespaceURI _SetChild -Parent $props -LocalName 'PublisherDisplayName' -Value $PublisherDisplayName -Ns $M.Package.NamespaceURI _SetChild -Parent $props -LocalName 'Description' -Value $Description -Ns $M.Package.NamespaceURI _SetChild -Parent $props -LocalName 'Logo' -Value $LogoPath -Ns $M.Package.NamespaceURI if ($ApplyToApplications) { foreach ($app in @($M.Package.Applications.Application)) { $vis = $app.SelectSingleNode("*[local-name()='VisualElements']") if (-not $vis) { continue } if ($DisplayName) { $vis.SetAttribute('DisplayName', $DisplayName) } if ($Description) { $vis.SetAttribute('Description', $Description) } } } Write-MsixLog -Level Info -Message 'Brand metadata updated.' } } function Add-MsixShellVerbExtension { <# .SYNOPSIS Adds a shell verb to an Application so it appears in File Explorer's context menu — the manifest-native replacement for HKCR\*\shell\<verb> registry entries. .DESCRIPTION Creates uap:Extension (windows.fileTypeAssociation) with a uap3:SupportedVerbs/uap3:Verb entry. When -FileTypes is omitted, uap:SupportsAnyFileType is used, mirroring the HKCR\*\shell pattern (verb appears on all file types). Specific extensions can be listed to scope the verb to those types only. Note: uap:SupportsAnyFileType does NOT add the verb to folder/directory targets. HKCR\Directory\shell entries have no direct MSIX manifest equivalent via FTA; use Add-MsixFileExplorerContextMenu for that case. Min OS: Windows 10 1709 (build 16299+). MaxVersionTested is bumped automatically. .PARAMETER AppId Application Id to attach the extension to. .PARAMETER VerbId Short slug used as the verb identifier (no spaces, alphanumeric). Auto-derived from -VerbDisplayName if omitted. .PARAMETER VerbDisplayName Text shown in the context menu. .PARAMETER Parameters Command-line arguments appended after the app executable. Defaults to '"%1"' (the file path). .PARAMETER FileTypes Extensions the verb applies to ('.txt', '.log', ...). Omit to use uap:SupportsAnyFileType (all file types). .PARAMETER AssocName Internal FileTypeAssociation name. Defaults to a slug of -VerbId. .EXAMPLE # "Open with Notepad++" context menu item on all file types Add-MsixShellVerbExtension -PackagePath app.msix -AppId App ` -VerbDisplayName 'Open with Notepad++' -Pfx cert.pfx -PfxPassword 'P@ss' .EXAMPLE # Verb limited to specific extensions Add-MsixShellVerbExtension -PackagePath app.msix -AppId App ` -VerbId 'editlog' -VerbDisplayName 'Edit in MyApp' ` -FileTypes '.log','.txt' -Pfx cert.pfx -PfxPassword 'P@ss' #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'AppId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$AppId, [Parameter(Mandatory)] [string]$VerbDisplayName, [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'VerbId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$VerbId, [string]$Parameters = '"%1"', [string[]]$FileTypes, [string]$AssocName, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath, # Debug: copy the mutated AppxManifest.xml here BEFORE packing, # so you can inspect the exact XML that MakeAppx validates. [string]$SaveManifestTo ) if (-not $VerbId) { $VerbId = ($VerbDisplayName -replace '[^a-zA-Z0-9]', '').ToLower() if (-not $VerbId) { $VerbId = 'open' } } if (-not $AssocName) { $AssocName = $VerbId } $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, "Add shell verb '$VerbDisplayName'") _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -SaveManifestTo $SaveManifestTo ` -Activity "Add shell verb '$VerbDisplayName'" -Mutate { param([xml]$M) Add-MsixManifestNamespace -Manifest $M -Prefix 'uap' Add-MsixManifestNamespace -Manifest $M -Prefix 'uap2' Add-MsixManifestNamespace -Manifest $M -Prefix 'uap3' # uap2:SupportedVerbs + uap3:Verb require build 16299+ (Win10 1709). Set-MsixManifestMaxVersionTested -Manifest $M -MinBuild 16299 $app = _MsixGetOrCreateApplicationExtensions -Manifest $M -AppId $AppId $appExt = $app.SelectSingleNode('*[local-name()="Extensions"]') $uap = Get-MsixManifestNamespaceUri -Prefix 'uap' $uap2 = Get-MsixManifestNamespaceUri -Prefix 'uap2' $uap3 = Get-MsixManifestNamespaceUri -Prefix 'uap3' # Schema structure (per MSIX manifest spec): # <uap:Extension Category="windows.fileTypeAssociation"> ← uap namespace # <uap3:FileTypeAssociation Name="..."> ← substitution-group child # <uap:SupportedFileTypes> ... </uap:SupportedFileTypes> # <uap2:SupportedVerbs> # <uap3:Verb Id="..." Parameters="...">Label</uap3:Verb> # </uap2:SupportedVerbs> # </uap3:FileTypeAssociation> # </uap:Extension> # NOTE: the EXTENSION element must be uap:Extension; uap3:Extension does NOT # support the windows.fileTypeAssociation category and causes a schema error. $ext = $M.CreateElement('uap:Extension', $uap) $ext.SetAttribute('Category', 'windows.fileTypeAssociation') $fta = $M.CreateElement('uap3:FileTypeAssociation', $uap3) # IMPORTANT: do NOT inline the -replace expression as a method argument. # PowerShell parses SetAttribute('Name', $x -replace pat, repl) as the # 3-arg overload SetAttribute(localName, namespaceURI, value), treating # the -replace result as the namespaceURI — producing d6p1:Name="" and # a spurious xmlns:d6p1="<slug>" declaration that MakeAppx rejects. $assocSlug = $AssocName.ToLower() -replace '[^a-z0-9\-]', '' $fta.SetAttribute('Name', $assocSlug) # File-type scope $supported = $M.CreateElement('uap:SupportedFileTypes', $uap) if ($FileTypes) { foreach ($ft in $FileTypes) { if (-not $ft.StartsWith('.')) { $ft = ".$ft" } $node = $M.CreateElement('uap:FileType', $uap) $node.InnerText = $ft.ToLower() $null = $supported.AppendChild($node) } } else { # Wildcard — mirrors HKCR\*\shell\<verb> pattern $any = $M.CreateElement('uap:SupportsAnyFileType', $uap) $null = $supported.AppendChild($any) } $null = $fta.AppendChild($supported) # Verb element $verbs = $M.CreateElement('uap2:SupportedVerbs', $uap2) $verbElem = $M.CreateElement('uap3:Verb', $uap3) $verbElem.SetAttribute('Id', $VerbId) if ($Parameters) { $verbElem.SetAttribute('Parameters', $Parameters) } $verbElem.InnerText = $VerbDisplayName $null = $verbs.AppendChild($verbElem) $null = $fta.AppendChild($verbs) $null = $ext.AppendChild($fta) $null = $appExt.AppendChild($ext) $scope = if ($FileTypes) { $FileTypes -join ', ' } else { 'all file types (SupportsAnyFileType)' } Write-MsixLog -Level Info -Message "Shell verb '$VerbDisplayName' (Id=$VerbId) registered for: $scope" } } function Add-MsixComServerExtension { <# .SYNOPSIS Declares COM in-process server(s) in the manifest (com:Extension, windows.comServer) so they are activatable across the package boundary. .DESCRIPTION Adds com:InProcessServer entries for each CLSID supplied inside the Application's <Extensions> node. The DLL must exist in the package as a VFS-relative path. Idempotent — already-declared CLSIDs are silently skipped. Use this for COM servers that need to be activated by code OUTSIDE the package. COM servers self-activated by the app's own processes work via registry virtualization and generally do not need explicit declaration. .PARAMETER AppId Id of the Application element to extend. Defaults to the first Application in the manifest. .PARAMETER Servers Array of hashtables, each with: Clsid '{XXXXXXXX-...}' (required) VfsDllPath package-relative path, e.g. 'VFS\ProgramFilesX64\...' (required) ThreadingModel 'Apartment' | 'Free' | 'Both' | 'Neutral' (default: 'Apartment') .EXAMPLE Add-MsixComServerExtension -PackagePath app.msix ` -Servers @( @{ Clsid='{AAAA-...}'; VfsDllPath='VFS\ProgramFilesX64\App\com.dll' } ) -Pfx cert.pfx -PfxPassword 'P@ss' #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'AppId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$AppId, [Parameter(Mandatory)] [ValidateScript({ foreach ($srv in $_) { if (-not $srv.Clsid) { throw "Each -Servers entry must include a 'Clsid' key." } if ($srv.Clsid -notmatch '^(\{)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(\})?$') { throw "Invalid Clsid '$($srv.Clsid)': must be a GUID like 12345678-1234-1234-1234-123456789abc (curly braces optional)." } if (-not $srv.VfsDllPath) { throw "Each -Servers entry must include a 'VfsDllPath' key." } } $true })] [hashtable[]]$Servers, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Add COM server extension(s)') _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'Add COM server extension(s)' -Mutate { param([xml]$M) # Package-level windows.comServer requires the com4 schema (v10/4) — # MakeAppx hard-errors on the bare 'com' namespace at package scope: # "Extension 'windows.comServer' must be # 'http://schemas.microsoft.com/appx/manifest/com/windows10/4' # or newer on package level." Add-MsixManifestNamespace -Manifest $M -Prefix 'com4' Add-MsixManifestNamespace -Manifest $M -Prefix 'rescap' $comUri = Get-MsixManifestNamespaceUri -Prefix 'com4' # AppId is retained for backward compat / sanity check only. # com:Extension/windows.comServer declares a CLSID for system-wide # COM activation. The shell and other consumers look it up at the # PACKAGE level, never inside Applications/Application/Extensions. # Installing into Application/Extensions used to silently work for # MakeAppx but the OS never registered the shell handler at runtime # (root cause of the "legacy context menu doesn't appear" bug). if ($AppId) { $null = Get-MsixManifestApplication -Manifest $M -AppId $AppId } $appExt = _MsixGetOrCreatePackageExtensions -Manifest $M # One com4:Extension wrapping all servers $comExt = $M.CreateElement('com4:Extension', $comUri) $comExt.SetAttribute('Category', 'windows.comServer') $comServer = $M.CreateElement('com4:ComServer', $comUri) $added = 0 foreach ($srv in $Servers) { # Strip braces — manifest schema expects bare GUID (ST_GUID), no {} $clsid = $srv.Clsid.Trim().Trim('{', '}') $vfsDll = $srv.VfsDllPath $threading = if ($srv.ThreadingModel) { $srv.ThreadingModel } else { 'Apartment' } # Idempotency — skip if CLSID already declared anywhere in the manifest if ($M.SelectSingleNode("//*[local-name()='Class' and @Id='$clsid']")) { Write-MsixLog -Level Info -Message "COM class $clsid already declared; skipping." continue } $ips = $M.CreateElement('com4:InProcessServer', $comUri) $path = $M.CreateElement('com4:Path', $comUri) $path.InnerText = $vfsDll $class = $M.CreateElement('com4:Class', $comUri) $class.SetAttribute('Id', $clsid) # ST_GUID — no braces $class.SetAttribute('ThreadingModel', $threading) $null = $ips.AppendChild($path) $null = $ips.AppendChild($class) $null = $comServer.AppendChild($ips) Write-MsixLog -Level Info -Message "COM InProcessServer declared: $clsid → $vfsDll" $added++ } if ($added -gt 0) { $null = $comExt.AppendChild($comServer) $null = $appExt.AppendChild($comExt) # Auto-inject runFullTrust (required for COM servers exposed to # callers outside the package). Mirrors Add-MsixFirewallRule's # canonical pattern: idempotent — skip if already present. $rescapUri = Get-MsixManifestNamespaceUri -Prefix 'rescap' $capsNode = $M.Package.Capabilities if (-not $capsNode) { $capsNode = $M.CreateElement('Capabilities', $M.Package.NamespaceURI) $null = $M.Package.AppendChild($capsNode) } $hasFullTrust = $capsNode.ChildNodes | Where-Object { $_.LocalName -eq 'Capability' -and $_.GetAttribute('Name') -eq 'runFullTrust' } if (-not $hasFullTrust) { $cap = $M.CreateElement('rescap:Capability', $rescapUri) $cap.SetAttribute('Name', 'runFullTrust') $null = $capsNode.AppendChild($cap) Write-MsixLog -Level Info -Message 'Capability added: runFullTrust' } } else { Write-MsixLog -Level Info -Message 'No new COM servers to declare (all already present).' } } } #endregion |