MSIX.Manifest.ps1

function _MsixLoadXmlSecure {
    <#
    .SYNOPSIS
        Loads XML from a file or string with DTD processing prohibited and
        external entity resolution disabled. Use for ALL untrusted XML input
        (anything that came out of a user-supplied MSIX archive).
    #>

    [CmdletBinding(DefaultParameterSetName = 'Path')]
    [OutputType([xml])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Path', Position = 0)]
        [string]$Path,
        [Parameter(Mandatory, ParameterSetName = 'Text')]
        [string]$XmlText
    )

    $settings = New-Object System.Xml.XmlReaderSettings
    $settings.DtdProcessing                = [System.Xml.DtdProcessing]::Prohibit
    $settings.XmlResolver                  = $null
    $settings.MaxCharactersFromEntities    = 1048576     # 1 MB — sane upper bound
    $settings.MaxCharactersInDocument      = 268435456   # 256 MB — generous but bounded

    $doc = New-Object System.Xml.XmlDocument
    $doc.PreserveWhitespace = $true
    $doc.XmlResolver        = $null

    $stringReader = $null
    if ($PSCmdlet.ParameterSetName -eq 'Path') {
        if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
            throw "XML file not found: $Path"
        }
        $reader = [System.Xml.XmlReader]::Create($Path, $settings)
    } else {
        $stringReader = New-Object System.IO.StringReader $XmlText
        $reader       = [System.Xml.XmlReader]::Create($stringReader, $settings)
    }

    try {
        $doc.Load($reader)
    } finally {
        $reader.Dispose()
        if ($stringReader) { $stringReader.Dispose() }
    }
    return $doc
}

# Known namespace prefixes used across MSIX manifests
$script:KnownNamespaces = [ordered]@{
    uap      = 'http://schemas.microsoft.com/appx/manifest/uap/windows10'
    uap2     = 'http://schemas.microsoft.com/appx/manifest/uap/windows10/2'   # SupportedVerbs
    uap3     = 'http://schemas.microsoft.com/appx/manifest/uap/windows10/3'
    uap4     = 'http://schemas.microsoft.com/appx/manifest/uap/windows10/4'
    uap5     = 'http://schemas.microsoft.com/appx/manifest/uap/windows10/5'   # windows.startupTask
    uap6     = 'http://schemas.microsoft.com/appx/manifest/uap/windows10/6'   # LoaderSearchPathOverride
    uap10    = 'http://schemas.microsoft.com/appx/manifest/uap/windows10/10'  # InstalledLocationVirtualization
    desktop  = 'http://schemas.microsoft.com/appx/manifest/desktop/windows10'
    desktop2 = 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/2' # FirewallRules
    desktop4 = 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/4'
    desktop5 = 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/5'
    desktop6 = 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/6' # File/Registry write virtualization
    desktop9 = 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/9'
    com      = 'http://schemas.microsoft.com/appx/manifest/com/windows10'
    com2     = 'http://schemas.microsoft.com/appx/manifest/com/windows10/2'
    com3     = 'http://schemas.microsoft.com/appx/manifest/com/windows10/3'
    com4     = 'http://schemas.microsoft.com/appx/manifest/com/windows10/4'
    rescap         = 'http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities'
    virtualization = 'http://schemas.microsoft.com/appx/manifest/virtualization/windows10'
}

function New-MsixManifestDocument {
    <#
    .SYNOPSIS
        Creates a test-friendly manifest document wrapper with namespace-aware
        XPath helpers.
 
    .DESCRIPTION
        This is the pure parser/navigator entry point. It accepts raw XML text,
        an existing XmlDocument, or a path that Get-MsixManifest can read. The
        returned object keeps the XmlDocument and XmlNamespaceManager together
        so tests and transform helpers can use consistent XPath without package
        unpack/repack IO.
 
    .PARAMETER Path
        Filesystem path to an XML file (typically an AppxManifest.xml).
 
    .PARAMETER XmlText
        Raw XML as a string.
 
    .PARAMETER Document
        Pre-parsed [xml] document. Use this when you already have a manifest
        loaded (e.g. from Get-MsixManifest).
 
    .OUTPUTS
        [pscustomobject] with PSTypeName MSIX.ManifestDocument, exposing
        Document, NamespaceManager, and Package properties.
 
    .EXAMPLE
        $m = New-MsixManifestDocument -XmlText $sampleManifest
        Get-MsixManifestApplication -Manifest $m -AppId App
 
    .EXAMPLE
        $m = New-MsixManifestDocument -Path 'C:\workspace\AppxManifest.xml'
    #>

    [CmdletBinding(DefaultParameterSetName = 'Path')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Path', Position = 0)]
        [string]$Path,
        [Parameter(Mandatory, ParameterSetName = 'XmlText')]
        [string]$XmlText,
        [Parameter(Mandatory, ParameterSetName = 'Document')]
        [xml]$Document
    )

    if ($PSCmdlet.ParameterSetName -eq 'Path') {
        $Document = _MsixLoadXmlSecure -Path $Path
    } elseif ($PSCmdlet.ParameterSetName -eq 'XmlText') {
        $Document = _MsixLoadXmlSecure -XmlText $XmlText
    }

    $nsMgr = New-Object System.Xml.XmlNamespaceManager($Document.NameTable)
    $nsMgr.AddNamespace('f', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10')
    foreach ($prefix in $script:KnownNamespaces.Keys) {
        $nsMgr.AddNamespace($prefix, $script:KnownNamespaces[$prefix])
    }

    return [pscustomobject]@{
        PSTypeName         = 'MSIX.ManifestDocument'
        Document           = $Document
        NamespaceManager   = $nsMgr
        Package            = $Document.DocumentElement
    }
}

function Select-MsixManifestNode {
    <#
    .SYNOPSIS
        Selects the first manifest node matching a namespace-aware XPath.
 
    .DESCRIPTION
        Uses the namespace manager attached to MSIX.ManifestDocument so XPath
        like '//uap10:Folder' resolves correctly without callers having to
        wire up prefixes themselves. Returns $null if no node matches. For
        every-match queries use Select-MsixManifestNodes.
 
    .PARAMETER Manifest
        Either an [xml] document or an MSIX.ManifestDocument wrapper.
 
    .PARAMETER XPath
        Namespace-aware XPath expression. The 'f:' prefix is bound to the
        foundation namespace; uap/uap10/desktop9/com/rescap etc. are also
        pre-registered.
 
    .OUTPUTS
        [System.Xml.XmlNode] or $null.
 
    .EXAMPLE
        Select-MsixManifestNode -Manifest $m -XPath '//f:Identity'
    #>

    [CmdletBinding()]
    [OutputType([System.Xml.XmlNode])]
    param(
        [Parameter(Mandatory)]
        $Manifest,
        [Parameter(Mandatory)]
        [string]$XPath
    )

    $manifestDocument = if ($Manifest.PSTypeNames -contains 'MSIX.ManifestDocument') {
        $Manifest
    } else {
        New-MsixManifestDocument -Document $Manifest
    }

    return $manifestDocument.Document.SelectSingleNode($XPath, $manifestDocument.NamespaceManager)
}

function Select-MsixManifestNodes {
    <#
    .SYNOPSIS
        Selects all manifest nodes matching a namespace-aware XPath.
 
    .DESCRIPTION
        Plural counterpart to Select-MsixManifestNode. Always returns an
        array (possibly empty) so callers can pipe without null checks.
 
    .PARAMETER Manifest
        Either an [xml] document or an MSIX.ManifestDocument wrapper.
 
    .PARAMETER XPath
        Namespace-aware XPath expression.
 
    .OUTPUTS
        [System.Xml.XmlNode[]] (array, possibly empty).
 
    .EXAMPLE
        Select-MsixManifestNodes -Manifest $m -XPath '//f:Capability' |
            ForEach-Object { $_.GetAttribute('Name') }
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]
        $Manifest,
        [Parameter(Mandatory)]
        [string]$XPath
    )

    $manifestDocument = if ($Manifest.PSTypeNames -contains 'MSIX.ManifestDocument') {
        $Manifest
    } else {
        New-MsixManifestDocument -Document $Manifest
    }

    return @($manifestDocument.Document.SelectNodes($XPath, $manifestDocument.NamespaceManager))
}

function Get-MsixManifest {
    <#
    .SYNOPSIS
        Returns the AppxManifest.xml as an [xml] document.
 
    .DESCRIPTION
        Polymorphic input:
          - .msix / .appx / .msixbundle / .appxbundle -> extracts AppxManifest.xml
            from inside the archive (no MakeAppx required, uses ZipFile)
          - any other path -> read as XML directly
            (typical use: the AppxManifest.xml of an already-unpacked workspace)
 
    .PARAMETER Path
        Path to either a packaged .msix file or an extracted AppxManifest.xml.
 
    .EXAMPLE
        $m = Get-MsixManifest -Path C:\drop\app.msix
        $m.Package.Identity.Name
 
    .EXAMPLE
        $m = Get-MsixManifest -Path C:\workspace\AppxManifest.xml
    #>

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

    if (-not (Test-Path $Path)) {
        throw "Path not found: $Path"
    }
    $item = Get-Item $Path

    if ($item.PSIsContainer) {
        # Folder — assume it's an unpacked workspace; look for AppxManifest.xml
        $candidate = Join-Path $item.FullName 'AppxManifest.xml'
        if (-not (Test-Path $candidate)) {
            throw "No AppxManifest.xml under '$($item.FullName)'."
        }
        return (_MsixLoadXmlSecure -Path $candidate)
    }

    if ($item.Extension -in '.msix', '.appx', '.msixbundle', '.appxbundle') {
        # Pull the manifest out of the archive without touching MakeAppx.
        Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
        $tmp = New-MsixWorkspace "$($item.BaseName)-mf"
        try {
            $zip = [System.IO.Compression.ZipFile]::OpenRead($item.FullName)
            try {
                $entry = $zip.Entries | Where-Object { $_.Name -eq 'AppxManifest.xml' } |
                         Select-Object -First 1
                if (-not $entry) {
                    throw "AppxManifest.xml not found inside $($item.Name) (is this a bundle? open the inner .msix instead)."
                }
                $out = Join-Path $tmp 'AppxManifest.xml'
                [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $out, $true)
                return (_MsixLoadXmlSecure -Path $out)
            } finally { $zip.Dispose() }
        } finally {
            Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    # Default: assume an XML file path.
    return (_MsixLoadXmlSecure -Path $Path)
}

function Save-MsixManifest {
    <#
    .SYNOPSIS
        Writes an [xml] manifest document back to disk.
 
    .DESCRIPTION
        Thin wrapper around [System.Xml.XmlDocument]::Save with a debug log
        line. Used after Set-MsixManifestPublisher / Set-MsixManifestIdentity
        / Add-MsixManifestNamespace mutations to persist the result for the
        repack stage.
 
    .PARAMETER Manifest
        The manifest [xml] document, normally returned by Get-MsixManifest.
 
    .PARAMETER Path
        Destination file. Overwrites silently.
 
    .EXAMPLE
        [xml]$m = Get-MsixManifest "$workspace\AppxManifest.xml"
        Set-MsixManifestPublisher -Manifest $m -Publisher 'CN=Contoso, O=Contoso, C=NL'
        Save-MsixManifest -Manifest $m -Path "$workspace\AppxManifest.xml"
    #>

    param(
        [Parameter(Mandatory)]
        [xml]$Manifest,
        [Parameter(Mandatory)]
        [string]$Path
    )
    $Manifest.Save($Path)
    Write-MsixLog Debug "Manifest saved: $Path"
}

function Add-MsixManifestNamespace {
    <#
    .SYNOPSIS
        Idempotently adds an xmlns prefix and URI to the Package element,
        and appends the prefix to IgnorableNamespaces if not already present.
 
    .DESCRIPTION
        Knows the standard MSIX prefixes (uap, uap2..uap10, desktop,
        desktop2/4/6/9, com, rescap, virtualization). Resolves the URI from
        the module's internal namespace table; throws on unknown prefixes
        with the list of supported values. Re-running with the same prefix
        is a no-op.
 
    .PARAMETER Manifest
        Manifest [xml] document being mutated in place.
 
    .PARAMETER Prefix
        One of the known short prefixes (e.g. 'desktop9', 'rescap', 'uap10').
 
    .EXAMPLE
        Add-MsixManifestNamespace -Manifest $m -Prefix 'desktop9'
        # Idempotent: safe to call again.
    #>

    param(
        [Parameter(Mandatory)]
        [xml]$Manifest,
        [Parameter(Mandatory)]
        [string]$Prefix
    )

    $uri = $script:KnownNamespaces[$Prefix]
    if (-not $uri) { throw "Unknown namespace prefix '$Prefix'. Known: $($script:KnownNamespaces.Keys -join ', ')" }

    # Already declared?
    $existing = $Manifest.Package.Attributes | Where-Object { $_.Value -eq $uri }
    if ($existing) { return }

    # Plain 2-arg SetAttribute is correct here: .NET's XmlDocument treats
    # xmlns:* attributes as namespace declarations even without the reserved
    # http://www.w3.org/2000/xmlns/ namespace URI, and Save() serialises them
    # correctly. The 3-arg overload with that URI is rejected by the DOM.
    $Manifest.Package.SetAttribute("xmlns:$Prefix", $uri)

    $ignorable = $Manifest.Package.IgnorableNamespaces
    if ($ignorable -notmatch "\b$([regex]::Escape($Prefix))\b") {
        $Manifest.Package.IgnorableNamespaces = "$ignorable $Prefix".Trim()
    }

    Write-MsixLog Debug "Namespace added: xmlns:$Prefix"
}

function Get-MsixManifestApplication {
    <#
    .SYNOPSIS
        Returns Application elements from the manifest. Single canonical
        reader for both the "one app" and "all apps" cases — singular noun
        per Get-Verb / PSUseSingularNouns convention (cf. Get-ChildItem).
 
    .DESCRIPTION
        Parameter sets:
          First (default) — returns the first Application element.
          ById — returns the single Application matching -AppId.
          All — returns every Application as an array.
 
        Accepts either an [xml] document (typical from Get-MsixManifest) or
        an MSIX.ManifestDocument wrapper (from New-MsixManifestDocument).
 
    .PARAMETER Manifest
        The manifest [xml] document or MSIX.ManifestDocument wrapper.
 
    .PARAMETER AppId
        (ById set only.) The Id attribute of the Application to return.
 
    .PARAMETER All
        (All set only.) Switch to return every Application as an array.
 
    .OUTPUTS
        [System.Xml.XmlNode] for First/ById, [System.Xml.XmlNode[]] for All.
 
    .EXAMPLE
        # First set (default) — get the primary entry-point application
        $app = Get-MsixManifestApplication -Manifest $m
 
    .EXAMPLE
        # ById set — fetch a specific Application by its Id attribute
        $tool = Get-MsixManifestApplication -Manifest $m -AppId 'ContosoTool'
 
    .EXAMPLE
        # All set — iterate every Application in the package
        Get-MsixManifestApplication -Manifest $m -All |
            ForEach-Object { $_.GetAttribute('Id') }
    #>

    [CmdletBinding(DefaultParameterSetName = 'First')]
    param(
        [Parameter(Mandatory, Position = 0)]
        $Manifest,
        [Parameter(Mandatory, ParameterSetName = 'ById')]
        [string]$AppId,
        [Parameter(Mandatory, ParameterSetName = 'All')]
        [switch]$All
    )

    $manifestDocument = if ($Manifest.PSTypeNames -contains 'MSIX.ManifestDocument') {
        $Manifest
    } else {
        New-MsixManifestDocument -Document $Manifest
    }

    if ($All) {
        $nodes = Select-MsixManifestNodes -Manifest $manifestDocument -XPath '//f:Application'

        if ($nodes -and $nodes.Count -gt 0) {
            return @($nodes)
        }

        # Fallback: namespace-agnostic XPath (handles non-standard manifests)
        $nodes = $manifestDocument.Document.SelectNodes('//*[local-name()="Application"]')
        if ($nodes -and $nodes.Count -gt 0) {
            return @($nodes)
        }

        return @()
    }

    if ($AppId) {
        $escapedId = $AppId.Replace("'", "&apos;")
        $node = $manifestDocument.Document.SelectSingleNode("//f:Application[@Id='$escapedId']", $manifestDocument.NamespaceManager)
    } else {
        $node = $manifestDocument.Document.SelectSingleNode('//f:Application[1]', $manifestDocument.NamespaceManager)
    }

    if ($node) { return $node }

    # Fallback: namespace-agnostic search for non-standard manifests
    $applicationNodes = @($manifestDocument.Document.SelectNodes('//*[local-name()="Application"]'))
    if ($AppId) {
        return $applicationNodes | Where-Object { $_.GetAttribute('Id') -eq $AppId } | Select-Object -First 1
    }
    return $applicationNodes | Select-Object -First 1
}

function Get-MsixManifestApplications {
    <#
    .SYNOPSIS
        DEPRECATED. Use Get-MsixManifestApplication -All.
        Returns every Application XmlElement from the manifest.
 
    .DESCRIPTION
        Thin wrapper retained for backward compatibility. New code should call
        Get-MsixManifestApplication -All directly. The plural noun violates
        PSUseSingularNouns; the suppression is documented inline.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
        Justification = 'Plural retained as deprecated wrapper for backward compatibility; new code uses Get-MsixManifestApplication -All.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $Manifest
    )
    Write-MsixLog Debug 'Get-MsixManifestApplications is deprecated; use Get-MsixManifestApplication -All.'
    return Get-MsixManifestApplication -Manifest $Manifest -All
}

function Set-MsixManifestPublisher {
    <#
    .SYNOPSIS
        Pure in-memory transform: updates Identity.Publisher on the manifest.
 
    .DESCRIPTION
        Testable without unpacking a package — accepts an [xml] document and
        mutates it in place. Returns the same document for pipeline use.
 
        Idempotent: writing the same Publisher twice is a no-op. To change
        Name and Version at the same time use Set-MsixManifestIdentity. For a
        higher-level recipe-driven transform (rename + capabilities + version
        bump in one pass) see Get-Help Invoke-MsixManifestTransform.
 
    .PARAMETER Manifest
        Manifest [xml] document (caller-owned; mutated in place).
 
    .PARAMETER Publisher
        New Distinguished Name. Must match the signing certificate's Subject
        exactly or MSIX install fails with 0x8007000B.
 
    .OUTPUTS
        [System.Xml.XmlDocument] — the same instance, returned for chaining.
 
    .EXAMPLE
        [xml]$m = Get-MsixManifest "$ws\AppxManifest.xml"
        Set-MsixManifestPublisher -Manifest $m -Publisher 'CN=Contoso, O=Contoso, C=NL'
        Save-MsixManifest -Manifest $m -Path "$ws\AppxManifest.xml"
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Pure in-memory transform on a caller-owned XmlDocument; no IO, no side effects outside the input object.')]
    [CmdletBinding()]
    [OutputType([System.Xml.XmlDocument])]
    param(
        [Parameter(Mandatory)] [xml]$Manifest,
        [Parameter(Mandatory)] [string]$Publisher
    )
    $Manifest.Package.Identity.Publisher = $Publisher
    return $Manifest
}

function Set-MsixManifestIdentity {
    <#
    .SYNOPSIS
        Pure in-memory transform: updates one or more Identity attributes
        (Name, Publisher, Version) on the manifest. Only the parameters you
        supply are changed.
 
    .DESCRIPTION
        Companion to Set-MsixManifestPublisher when you also need to rename
        the package or bump the version in a single mutation. Idempotent
        per-attribute. For multi-step transforms (capabilities + identity +
        MaxVersionTested) wrapped in one call see Get-Help
        Invoke-MsixManifestTransform.
 
    .PARAMETER Manifest
        Manifest [xml] document (caller-owned; mutated in place).
 
    .PARAMETER Name
        Optional new Identity.Name.
 
    .PARAMETER Publisher
        Optional new Identity.Publisher (Distinguished Name).
 
    .PARAMETER Version
        Optional new Identity.Version. Validated against the
        ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ pattern.
 
    .OUTPUTS
        [System.Xml.XmlDocument] — the same instance, returned for chaining.
 
    .EXAMPLE
        # Bump the version only
        Set-MsixManifestIdentity -Manifest $m -Version '2.0.0.0'
 
    .EXAMPLE
        # Rename + repub + version bump in one call
        Set-MsixManifestIdentity -Manifest $m -Name 'Contoso.App' `
            -Publisher 'CN=Contoso, O=Contoso, C=NL' -Version '1.2.3.4'
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Pure in-memory transform on a caller-owned XmlDocument; no IO, no side effects outside the input object.')]
    [CmdletBinding()]
    [OutputType([System.Xml.XmlDocument])]
    param(
        [Parameter(Mandatory)] [xml]$Manifest,
        [string]$Name,
        [string]$Publisher,
        [ValidatePattern('^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$',
            ErrorMessage = 'Version must be a 4-part dotted-decimal like 1.2.3.4.')]
        [string]$Version
    )
    if ($PSBoundParameters.ContainsKey('Name'))      { $Manifest.Package.Identity.Name      = $Name }
    if ($PSBoundParameters.ContainsKey('Publisher')) { $Manifest.Package.Identity.Publisher = $Publisher }
    if ($PSBoundParameters.ContainsKey('Version'))   { $Manifest.Package.Identity.Version   = $Version }
    return $Manifest
}

function Get-MsixManifestNamespaceUri {
    <#
    .SYNOPSIS
        Returns the full XML namespace URI for a known MSIX prefix.
 
    .DESCRIPTION
        Looks up the prefix in the module's namespace table (uap, uap2..uap10,
        desktop, desktop2/4/6/9, com, rescap, virtualization). Returns $null
        for unknown prefixes. Pair with Add-MsixManifestNamespace and
        XmlDocument.CreateElement when crafting prefixed elements.
 
    .PARAMETER Prefix
        Short MSIX namespace prefix (e.g. 'desktop9').
 
    .OUTPUTS
        [string] or $null when the prefix is unknown.
 
    .EXAMPLE
        $uri = Get-MsixManifestNamespaceUri 'desktop9'
        $el = $manifest.CreateElement('desktop9:Extension', $uri)
    #>

    [OutputType([string])]
    param([string]$Prefix)
    return $script:KnownNamespaces[$Prefix]
}

function Set-MsixManifestMaxVersionTested {
    <#
    .SYNOPSIS
        Ensures MaxVersionTested is at least the specified build number.
        Pass -MinBuild with the feature's required build (e.g. 19041, 22000).
 
    .DESCRIPTION
        Many manifest extensions only activate when MaxVersionTested on the
        TargetDeviceFamily element is at or above a specific build:
          17134 — desktop4 modern context menus (1803)
          19041 — desktop6 file/registry virtualization (2004)
          22000 — desktop9 legacy IContextMenu handlers (Win11 21H2)
          26100 — Win32 App Isolation rescap capabilities
 
        The function is idempotent: bumps only when the current value is
        lower than -MinBuild. Mutates in place.
 
    .PARAMETER Manifest
        Manifest [xml] document.
 
    .PARAMETER MinBuild
        Minimum Windows build number to advertise. Default 19041.
 
    .EXAMPLE
        Set-MsixManifestMaxVersionTested -Manifest $m -MinBuild 22000
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    param(
        [Parameter(Mandatory)]
        [xml]$Manifest,
        [int]$MinBuild = 19041
    )

    $tdf = $Manifest.Package.Dependencies.TargetDeviceFamily
    if (-not $tdf) { return }

    $parts = $tdf.MaxVersionTested -split '\.'
    if ($parts.Count -ge 3 -and [int]$parts[2] -lt $MinBuild) {
        $tdf.MaxVersionTested = "$($parts[0]).$($parts[1]).$MinBuild.0"
        Write-MsixLog Info "MaxVersionTested updated to $($tdf.MaxVersionTested)"
    }
}