Public/Set-MSIXApplicationVisualElements.ps1

function Set-MSIXApplicationVisualElements {
<#
.SYNOPSIS
    Sets or removes visual element attributes (and optionally generates new
    asset images) on Application entries in AppxManifest.xml.

.DESCRIPTION
    Modifies the uap3:VisualElements element for one or more Application entries.
    Only parameters that are explicitly passed are acted upon:

      - Passed with a value -> attribute is created or updated
      - Passed as $null -> attribute is removed
      - Not passed at all -> attribute is left unchanged

    When VisualGroup is set the function ensures the element uses the uap3
    namespace (required for VisualGroup support). If the existing element is
    uap:VisualElements it is replaced by uap3:VisualElements while preserving
    all existing attributes and child nodes. The uap3 namespace declaration and
    IgnorableNamespaces entry are added to the root Package element automatically.

    Three ways to update the application logos:

      1. Use existing assets: -AssetId 'MyApp'
         Sets all six logo attributes to Assets\MyApp-*.png. Useful when the
         PNGs were generated by a separate New-MSIXAssetFrom call earlier.

      2. Generate new assets: -SourcePath 'C:\art\app.ico' [-AssetId 'MyApp']
         Calls New-MSIXAssetFrom internally to produce the six standard PNGs
         in Assets\, then wires them into the manifest. AssetId is derived
         from the source filename when not given. -IconIndex (for .exe/.dll)
         and -SetAsPackageLogo are forwarded to New-MSIXAssetFrom.

      3. Set individual logo paths: -Square150x150Logo, -Square44x44Logo, ...
         Each may be a path (set), $null (remove), or omitted (unchanged).
         Per-logo overrides win over AssetId-derived paths. The DefaultTile
         child element is created on demand for tile-only logos.

    The function can be used standalone or via pipeline from Get-MSIXApplications:

        # All applications
        Set-MSIXApplicationVisualElements -MSIXFolderPath $folder -VisualGroup "LibreOffice"

        # Only selected applications
        Get-MSIXApplications -MSIXFolder $folder |
            Where-Object { $_.Id -ne "StartCenter" } |
            Set-MSIXApplicationVisualElements -VisualGroup "LibreOffice"

    The manifest is saved once per folder after all pipeline objects are
    processed. New assets (when -SourcePath is given) are generated once per
    folder, not once per application.

.PARAMETER MSIXFolderPath
    Path to the unpacked MSIX package directory containing AppxManifest.xml.
    Accepts pipeline input by property name (supplied automatically by
    Get-MSIXApplications).

.PARAMETER Id
    Application Id to update. Accepted from pipeline. When omitted all
    applications in the folder are updated.

.PARAMETER DisplayName
    Friendly display name shown to the user. Pass $null to remove the attribute.

.PARAMETER Description
    Short description of the application. Pass $null to remove the attribute.

.PARAMETER BackgroundColor
    Tile background color: a hex value preceded by "#" (e.g. "#336699") or a
    named color (e.g. "transparent"). Pass $null to remove the attribute.

.PARAMETER AppListEntry
    Controls visibility in the Start menu All Apps list.
    "default" = visible (normal), "none" = hidden (e.g. for helper processes).
    Pass $null to remove the attribute.

.PARAMETER VisualGroup
    Start Menu folder name (Windows 11). All applications with the same value
    are grouped under one folder. Pass $null to remove the attribute.
    Must not contain backslashes or spaces.

.PARAMETER AssetId
    Filename prefix for the six standard MSIX logos. When set (and no per-logo
    override is given for that slot), the corresponding logo attribute is
    written as Assets\<AssetId>-<Logo>.png. When -SourcePath is also set, the
    AssetId is forwarded to New-MSIXAssetFrom; if neither AssetId nor
    SourcePath is set, the existing logo attributes are left untouched.

.PARAMETER SourcePath
    Optional source file (.exe / .dll / .ico / .png / .jpg / .bmp / .gif) used
    to generate fresh asset PNGs via New-MSIXAssetFrom before the manifest is
    updated. Generation happens once per folder (not per application). When
    -AssetId is omitted, the AssetId is derived from the source filename.

.PARAMETER IconIndex
    Icon index inside the PE file (only meaningful when -SourcePath is .exe or
    .dll). Forwarded to New-MSIXAssetFrom. Default 0.

.PARAMETER SetAsPackageLogo
    Forwarded to New-MSIXAssetFrom: also overwrites Assets\StoreLogo.png so the
    package-level Logo (shown by AppInstaller) matches the generated icon.
    Only meaningful in combination with -SourcePath.

.PARAMETER Square150x150Logo
    Per-logo override. Path (e.g. 'Assets\custom-150.png'), $null to remove,
    or omitted to leave unchanged. Wins over AssetId-derived path.

.PARAMETER Square44x44Logo
    Per-logo override (see Square150x150Logo).

.PARAMETER Square71x71Logo
    Per-logo override on uap:DefaultTile (see Square150x150Logo).

.PARAMETER Square310x310Logo
    Per-logo override on uap:DefaultTile (see Square150x150Logo).

.PARAMETER Wide310x150Logo
    Per-logo override on uap:DefaultTile (see Square150x150Logo).

.EXAMPLE
    Set-MSIXApplicationVisualElements -MSIXFolderPath "C:\MSIXTemp\LibreOffice" -VisualGroup "LibreOffice"

.EXAMPLE
    # Generate icons from an .ico and apply them to one application
    Set-MSIXApplicationVisualElements -MSIXFolderPath $folder -Id 'MyApp' `
        -SourcePath 'C:\art\app.ico' -AssetId 'MyApp' -SetAsPackageLogo

.EXAMPLE
    # Pipe: generate icons once and wire them into ALL applications
    Get-MSIXApplications -MSIXFolder $folder |
        Set-MSIXApplicationVisualElements `
            -SourcePath "$env:windir\System32\imageres.dll" -IconIndex 144 `
            -AssetId 'PsfTestApp' -BackgroundColor 'transparent'

.EXAMPLE
    # Pipe: only flip the logos to existing Assets\Foo-*.png on selected apps
    Get-MSIXApplications -MSIXFolder $folder |
        Where-Object { $_.Id -like 'Foo*' } |
        Set-MSIXApplicationVisualElements -AssetId 'Foo'

.EXAMPLE
    # Override a single logo and remove another
    Set-MSIXApplicationVisualElements -MSIXFolderPath $folder `
        -Square44x44Logo 'Assets\custom-44.png' `
        -Wide310x150Logo $null

.NOTES
    VisualGroup requires Windows 11.
    https://learn.microsoft.com/en-us/uwp/schemas/appxpackage/uapmanifestschema/element-uap3-visualelements
    Andreas Nick, 2026
#>

    [CmdletBinding(DefaultParameterSetName = 'Direct')]
    Param(
        [Parameter(ParameterSetName = 'Direct',   Mandatory = $true)]
        [Parameter(ParameterSetName = 'Pipeline', Mandatory = $true,
            ValueFromPipelineByPropertyName = $true)]
        [System.IO.DirectoryInfo] $MSIXFolderPath,

        [Parameter(ParameterSetName = 'Pipeline',
            ValueFromPipelineByPropertyName = $true)]
        [String] $Id,

        [Parameter()][AllowNull()][AllowEmptyString()]
        [String] $DisplayName,

        [Parameter()][AllowNull()][AllowEmptyString()]
        [String] $Description,

        [Parameter()][AllowNull()][AllowEmptyString()]
        [ArgumentCompleter({
            'transparent', 'aliceBlue', 'antiqueWhite', 'aqua', 'aquamarine',
            'azure', 'beige', 'bisque', 'black', 'blanchedAlmond', 'blue', 'red'
        })]
        [String] $BackgroundColor,

        [Parameter()][AllowNull()][AllowEmptyString()]
        [ValidateSet("default", "none")]
        [String] $AppListEntry,

        [Parameter()][AllowNull()][AllowEmptyString()]
        [String] $VisualGroup,

        [Parameter()]
        [String] $AssetId,

        [Parameter()]
        [System.IO.FileInfo] $SourcePath,

        [Parameter()]
        [int] $IconIndex = 0,

        [Parameter()]
        [switch] $SetAsPackageLogo,

        [Parameter()][AllowNull()][AllowEmptyString()]
        [String] $Square150x150Logo,

        [Parameter()][AllowNull()][AllowEmptyString()]
        [String] $Square44x44Logo,

        [Parameter()][AllowNull()][AllowEmptyString()]
        [String] $Square71x71Logo,

        [Parameter()][AllowNull()][AllowEmptyString()]
        [String] $Square310x310Logo,

        [Parameter()][AllowNull()][AllowEmptyString()]
        [String] $Wide310x150Logo
    )

    begin {
        # Keyed by resolved folder path -> list of Application Ids to update (empty = all)
        $pending = @{}
    }

    process {
        $folderKey = ([System.IO.DirectoryInfo]$MSIXFolderPath).FullName

        if (-not $pending.ContainsKey($folderKey)) {
            $pending[$folderKey] = [System.Collections.Generic.List[string]]::new()
        }

        if ($PSCmdlet.ParameterSetName -eq 'Pipeline' -and $Id -ne "") {
            if (-not $pending[$folderKey].Contains($Id)) {
                $pending[$folderKey].Add($Id)
            }
        }
        # Direct parameter set: empty list signals "update all applications"
    }

    end {
        $uapNs  = "http://schemas.microsoft.com/appx/manifest/uap/windows10"
        $uap3Ns = "http://schemas.microsoft.com/appx/manifest/uap/windows10/3"

        # Top-level VisualElements attributes the user may set/remove directly
        $simpleAttrs = @('DisplayName', 'Description', 'BackgroundColor',
                         'AppListEntry', 'VisualGroup')
        $passedSimpleAttrs = $simpleAttrs |
            Where-Object { $PSBoundParameters.ContainsKey($_) }

        # Per-logo overrides: which attributes live on VisualElements vs. DefaultTile
        $veLogoNames   = @('Square150x150Logo', 'Square44x44Logo')
        $tileLogoNames = @('Square71x71Logo',   'Square310x310Logo', 'Wide310x150Logo')
        $allLogoNames  = $veLogoNames + $tileLogoNames

        $passedLogoOverrides = $allLogoNames |
            Where-Object { $PSBoundParameters.ContainsKey($_) }

        $hasAssetWork = $PSBoundParameters.ContainsKey('AssetId') -or
                        $PSBoundParameters.ContainsKey('SourcePath')

        if ($passedSimpleAttrs.Count -eq 0 -and
            $passedLogoOverrides.Count -eq 0 -and
            -not $hasAssetWork) {
            Write-Warning "No attributes specified - nothing to do."
            return
        }

        $needsUap3 = $PSBoundParameters.ContainsKey('VisualGroup') -and
                     ($null -ne $VisualGroup -and $VisualGroup -ne "")

        foreach ($folderPath in $pending.Keys) {
            $manifestPath = Join-Path $folderPath "AppxManifest.xml"
            if (-not (Test-Path $manifestPath)) {
                Write-Warning "Cannot open path: $manifestPath"
                continue
            }

            # Optionally generate the six standard PNGs once per folder, before the
            # manifest is touched. The AssetId may be derived inside New-MSIXAssetFrom
            # from the source filename when not supplied here.
            $effectiveAssetId = if ($PSBoundParameters.ContainsKey('AssetId')) { $AssetId } else { $null }

            if ($PSBoundParameters.ContainsKey('SourcePath')) {
                $genArgs = @{
                    MSIXFolder = [System.IO.DirectoryInfo]$folderPath
                    SourcePath = $SourcePath
                }
                if ($PSBoundParameters.ContainsKey('AssetId'))   { $genArgs.AssetId   = $AssetId }
                if ($PSBoundParameters.ContainsKey('IconIndex')) { $genArgs.IconIndex = $IconIndex }
                if ($SetAsPackageLogo)                            { $genArgs.SetAsPackageLogo = $true }

                $assetResult = New-MSIXAssetFrom @genArgs
                if ($null -eq $assetResult) {
                    Write-Warning "Asset generation failed for folder '$folderPath' - skipping logo updates here."
                    continue
                }
                $effectiveAssetId = $assetResult.AssetId
                Write-Verbose "Generated assets with AssetId '$effectiveAssetId' in '$folderPath'."
            }

            # Resolve the final value per logo slot.
            # $null = remove attribute
            # '' = remove attribute (consistent with simple-attr behaviour)
            # non-empty path = set attribute
            # not in table = leave attribute unchanged
            $finalLogos = @{}
            if ($effectiveAssetId) {
                foreach ($logo in $allLogoNames) {
                    $finalLogos[$logo] = "Assets\$effectiveAssetId-$logo.png"
                }
            }
            foreach ($logo in $passedLogoOverrides) {
                $finalLogos[$logo] = $PSBoundParameters[$logo]
            }

            $manifest = New-Object xml
            $manifest.Load($manifestPath)

            $nsmgr = New-Object System.Xml.XmlNamespaceManager($manifest.NameTable)
            $null = $nsmgr.AddNamespace("default", "http://schemas.microsoft.com/appx/manifest/foundation/windows10")
            $null = $nsmgr.AddNamespace("uap",  $uapNs)
            $null = $nsmgr.AddNamespace("uap3", $uap3Ns)

            if ($needsUap3) {
                $root = $manifest.DocumentElement
                if (-not $root.HasAttribute("xmlns:uap3")) {
                    $null = $root.SetAttribute("xmlns:uap3", $uap3Ns)
                }
                $ignorable = $root.GetAttribute("IgnorableNamespaces")
                if ($ignorable -notmatch "\buap3\b") {
                    $null = $root.SetAttribute("IgnorableNamespaces", "$ignorable uap3".Trim())
                }
            }

            # uap is always required for DefaultTile and the legacy logo slots
            $needsUap = ($finalLogos.Count -gt 0)
            if ($needsUap) {
                $root = $manifest.DocumentElement
                if (-not $root.HasAttribute("xmlns:uap")) {
                    $null = $root.SetAttribute("xmlns:uap", $uapNs)
                }
                $ignorable = $root.GetAttribute("IgnorableNamespaces")
                if ($ignorable -notmatch "\buap\b") {
                    $null = $root.SetAttribute("IgnorableNamespaces", "$ignorable uap".Trim())
                }
            }

            $targetIds  = $pending[$folderPath]
            $applications = $manifest.SelectNodes(
                "//default:Package/default:Applications/default:Application", $nsmgr)

            foreach ($app in $applications) {
                $appId = $app.GetAttribute("Id")

                if ($targetIds.Count -gt 0 -and $targetIds -notcontains $appId) {
                    continue
                }

                # Find existing VisualElements (uap3 preferred, fall back to uap)
                $visualElements = $app.SelectSingleNode("uap3:VisualElements", $nsmgr)
                if ($null -eq $visualElements) {
                    $visualElements = $app.SelectSingleNode("uap:VisualElements", $nsmgr)
                }

                if ($null -eq $visualElements) {
                    Write-Warning "No VisualElements found for Application '$appId' - skipping."
                    continue
                }

                # Upgrade uap -> uap3 when VisualGroup is being set
                if ($needsUap3 -and $visualElements.NamespaceURI -ne $uap3Ns) {
                    $newElement = $manifest.CreateElement("uap3:VisualElements", $uap3Ns)

                    foreach ($attr in $visualElements.Attributes) {
                        if ($attr.Prefix -eq "xmlns" -or $attr.Name -eq "xmlns") {
                            continue
                        }
                        if ($attr.NamespaceURI -ne "" -and $null -ne $attr.NamespaceURI) {
                            $newAttr = $manifest.CreateAttribute($attr.Prefix, $attr.LocalName, $attr.NamespaceURI)
                        }
                        else {
                            $newAttr = $manifest.CreateAttribute($attr.LocalName)
                        }
                        $newAttr.Value = $attr.Value
                        $null = $newElement.Attributes.Append($newAttr)
                    }

                    while ($visualElements.HasChildNodes) {
                        $null = $newElement.AppendChild($visualElements.FirstChild)
                    }

                    $null = $app.ReplaceChild($newElement, $visualElements)
                    $visualElements = $newElement
                }

                # Apply the requested simple attribute changes
                foreach ($attrName in $passedSimpleAttrs) {
                    $attrValue = $PSBoundParameters[$attrName]
                    if ($null -eq $attrValue -or $attrValue -eq "") {
                        $visualElements.RemoveAttribute($attrName)
                        Write-Verbose "Removed attribute '$attrName' from Application '$appId'."
                    }
                    else {
                        $null = $visualElements.SetAttribute($attrName, $attrValue)
                        Write-Verbose "Set '$attrName' = '$attrValue' on Application '$appId'."
                    }
                }

                # Top-level logo attributes (live directly on VisualElements)
                foreach ($logo in $veLogoNames) {
                    if (-not $finalLogos.ContainsKey($logo)) { continue }
                    $val = $finalLogos[$logo]
                    if ($null -eq $val -or $val -eq "") {
                        $visualElements.RemoveAttribute($logo)
                        Write-Verbose "Removed '$logo' from Application '$appId'."
                    }
                    else {
                        $null = $visualElements.SetAttribute($logo, $val)
                        Write-Verbose "Set '$logo' = '$val' on Application '$appId'."
                    }
                }

                # Tile-level logo attributes (live on uap:DefaultTile child element)
                $tileChangesPending = $tileLogoNames |
                    Where-Object { $finalLogos.ContainsKey($_) }
                if ($tileChangesPending.Count -gt 0) {
                    $defaultTile = $visualElements.SelectSingleNode("uap:DefaultTile", $nsmgr)

                    # Only create DefaultTile if at least one tile attribute will actually be set
                    $needsTileCreate = $null -eq $defaultTile -and
                        ($tileChangesPending | Where-Object {
                            $v = $finalLogos[$_]
                            $null -ne $v -and $v -ne ""
                        }).Count -gt 0

                    if ($needsTileCreate) {
                        $defaultTile = $manifest.CreateElement("uap:DefaultTile", $uapNs)
                        $null = $visualElements.AppendChild($defaultTile)
                        Write-Verbose "Created uap:DefaultTile for Application '$appId'."
                    }

                    if ($null -ne $defaultTile) {
                        foreach ($logo in $tileChangesPending) {
                            $val = $finalLogos[$logo]
                            if ($null -eq $val -or $val -eq "") {
                                $defaultTile.RemoveAttribute($logo)
                                Write-Verbose "Removed '$logo' from DefaultTile of '$appId'."
                            }
                            else {
                                $null = $defaultTile.SetAttribute($logo, $val)
                                Write-Verbose "Set '$logo' = '$val' on DefaultTile of '$appId'."
                            }
                        }
                    }
                }
            }

            $null = $manifest.Save($manifestPath)
        }
    }
}