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) } } } |