MSIX.Functions.ps1

# =============================================================================
# Public package-operation functions
# -----------------------------------------------------------------------------
# Originally embedded in MSIX.psm1. Extracted in v0.13 so the root .psm1 only
# contains the dot-source loader + Export-ModuleMember, matching the same
# convention as every other MSIX.*.ps1 sub-module.
# =============================================================================

#region --- Public: Package information -------------------------------------

function Get-MsixInfo {
    <#
    .SYNOPSIS
        Returns identity, publisher, signing, and (optionally) application details
        for an MSIX package without fully unpacking it.
 
    .DESCRIPTION
        Extracts AppxManifest.xml in memory and combines it with the
        Authenticode signature info. Use this for quick triage / inventory
        scenarios — it does not unzip the entire package.
 
        For full unpacking and analysis use Get-MsixCompatibilityReport or
        Invoke-MsixInvestigation.
 
    .PARAMETER PackagePath
        Path to the .msix / .appx file. Accepts pipeline input.
 
    .PARAMETER Detailed
        Also returns the raw Application XML elements via
        Get-MsixManifestApplications, attached as an `Applications` note property.
 
    .EXAMPLE
        # Quick summary of one package
        Get-MsixInfo -PackagePath app.msix
 
    .EXAMPLE
        # Inventory a folder of packages
        Get-ChildItem 'C:\packages\*.msix' | Get-MsixInfo |
            Select-Object Name, Version, Publisher, Signed
 
    .OUTPUTS
        [pscustomobject] with Name, DisplayName, Publisher, PublisherDisplayName,
        Version, ProcessorArchitecture, Description, Signed (status),
        SignedBy, Thumbprint, TimestampCertificate. With -Detailed,
        also includes an Applications collection.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$PackagePath,
        [switch]$Detailed
    )

    PROCESS {
        $fileinfo  = Get-Item $PackagePath
        [xml]$appinfo = Get-MsixManifest -Path $fileinfo.FullName
        $signinfo     = Get-AuthenticodeSignature -FilePath $fileinfo

        $result = [pscustomobject]@{
            Name                   = $appinfo.Package.Identity.Name
            DisplayName            = $appinfo.Package.Properties.DisplayName
            Publisher              = $appinfo.Package.Identity.Publisher
            PublisherDisplayName   = $appinfo.Package.Properties.PublisherDisplayName
            Version                = $appinfo.Package.Identity.Version
            ProcessorArchitecture  = $appinfo.Package.Identity.ProcessorArchitecture
            Description            = $appinfo.Package.Properties.Description
            Signed                 = $signinfo.Status
            SignedBy               = $signinfo.SignerCertificate.Subject
            Thumbprint             = $signinfo.SignerCertificate.Thumbprint
            TimestampCertificate   = $signinfo.TimeStamperCertificate
        }

        if ($Detailed) {
            $result | Add-Member -NotePropertyName Applications `
                                 -NotePropertyValue @(Get-MsixManifestApplications -Manifest $appinfo)
        }

        return $result
    }
}

#endregion


#region --- Public: Package debugging ---------------------------------------

function Invoke-MsixCommand {
    <#
    .SYNOPSIS
        Launches a command inside the MSIX container of an installed package.
 
    .DESCRIPTION
        Thin wrapper around Invoke-CommandInDesktopPackage that resolves the
        PackageFamilyName + AppId from a partial package name. Useful for
        interactive debugging — you can run cmd.exe, powershell.exe or any
        diagnostic tool with the package's VFS / virtual registry mappings
        active.
 
        Throws when the name matches zero or more than one installed package.
        Pass -PackageName as the full PackageFullName to disambiguate.
 
    .PARAMETER PackageName
        Full or partial package name (wildcards accepted). Matched first with
        Get-AppxPackage -Name; falls back to a substring search.
 
    .PARAMETER Command
        Command to run inside the container. Defaults to cmd.exe.
 
    .PARAMETER AppId
        Application Id to use. If omitted, the first app in the manifest is
        used; a warning is emitted when the package declares multiple apps.
 
    .EXAMPLE
        # Open a cmd shell inside the Notepad++ package
        Invoke-MsixCommand -PackageName 'Notepad++'
 
    .EXAMPLE
        # Launch PowerShell inside the container — handy for inspecting
        # virtualized registry/file paths
        Invoke-MsixCommand -PackageName 'Notepad++' -Command 'powershell.exe'
 
    .EXAMPLE
        # Pin to a specific Application when the package declares multiple
        Invoke-MsixCommand -PackageName 'Contoso' -AppId 'App2' -Command 'regedit.exe'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$PackageName,
        [string]$Command = 'cmd.exe',
        [string]$AppId
    )
    PROCESS {
        try {
            $appx = Get-AppxPackage -Name $PackageName -ErrorAction Stop
        } catch {
            $appx = Get-AppxPackage | Where-Object { $_.Name -like "*$PackageName*" }
        }
        if (@($appx).Count -gt 1) { throw "Multiple packages match '$PackageName'. Use the full package name." }
        if (-not $appx)           { throw "No installed package matches '$PackageName'." }

        if (-not $AppId) {
            $manifest = Get-AppPackageManifest -Package $appx.PackageFullName
            $apps     = @($manifest.Package.Applications.Application)
            if ($apps.Count -gt 1) { Write-Warning "Multiple apps in package; using first: $($apps[0].Id)" }
            $AppId = $apps[0].Id
        }

        if ($PSCmdlet.ShouldProcess($appx.PackageFamilyName, "Invoke-CommandInDesktopPackage")) {
            Invoke-CommandInDesktopPackage -PackageFamilyName $appx.PackageFamilyName `
                                           -AppId $AppId `
                                           -Command $Command `
                                           -PreventBreakaway
        }
    }
}
Set-Alias -Name Invoke-MsixCmd  -Value Invoke-MsixCommand
Set-Alias -Name start-MsixCmd   -Value Invoke-MsixCommand

#endregion


#region --- Public: Signing / publisher update ------------------------------

function Update-MsixSigner {
    <#
    .SYNOPSIS
        Re-signs an MSIX package, optionally updating the Publisher identity.
 
    .DESCRIPTION
        If -Publisher differs from the current Identity/Publisher, the manifest
        is updated, the package filename is rewritten to reflect the new
        publisher hash, and then re-signed. If the publisher already matches,
        only re-signing happens (the file is left in place).
 
        Idempotent: re-running with the same -Publisher is a no-op for the
        manifest portion (only the signing step runs again).
 
        For pure key rotation (publisher stays the same), call
        Invoke-MsixSigning directly instead — it skips the unpack/repack cycle.
 
    .PARAMETER PackagePath
        Path to the .msix / .appx file to re-sign. Accepts pipeline input.
 
    .PARAMETER Publisher
        New Identity/Publisher distinguished name, e.g. 'CN=Contoso, O=Contoso, C=US'.
        Must match the Subject of the signing certificate. Omit to keep the
        existing value.
 
    .PARAMETER Pfx
        Path to the signing certificate (.pfx).
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .EXAMPLE
        # Change publisher and sign with a dev cert
        $pw = Read-Host -AsSecureString
        Update-MsixSigner -PackagePath app.msix `
            -Publisher 'CN=Contoso, O=Contoso, C=US' `
            -Pfx cert.pfx -PfxPassword $pw
 
    .EXAMPLE
        # Just re-sign (publisher unchanged)
        Update-MsixSigner -PackagePath app.msix -Pfx cert.pfx -PfxPassword $pw
 
    .OUTPUTS
        None. Writes the (possibly renamed) signed package to disk and logs
        the final path via Write-MsixLog.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$PackagePath,
        [string]$Publisher,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    PROCESS {
        $toolsRoot = Get-MsixToolsRoot
        $fileinfo  = Get-Item $PackagePath
        $workspace = New-MsixWorkspace $fileinfo.BaseName

        try {
            Write-MsixLog Info "Unpacking: $($fileinfo.FullName)"
            $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o')
            Assert-MsixProcessSuccess $r 'MakeAppx unpack'

            [xml]$appinfo = Get-MsixManifest "$workspace\AppxManifest.xml"

            $outputPath = $fileinfo.FullName

            if ($Publisher -and $appinfo.Package.Identity.Publisher -cne $Publisher) {
                $oldPublisherId = Get-MsixPublisherId $appinfo.Package.Identity.Publisher
                $newPublisherId = Get-MsixPublisherId $Publisher

                $appinfo.Package.Identity.Publisher = $Publisher
                Save-MsixManifest $appinfo "$workspace\AppxManifest.xml"

                $outputPath = $fileinfo.FullName -replace [regex]::Escape($oldPublisherId), $newPublisherId
                Write-MsixLog Info "Output path: $outputPath"
            } else {
                Write-MsixLog Info "Publisher unchanged; repacking with same identity"
            }

            $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack', '/p', $outputPath, '/d', $workspace, '/o')
            Assert-MsixProcessSuccess $r 'MakeAppx pack'

            Invoke-MsixSigning -PackagePath $outputPath -Pfx $Pfx -PfxPassword $PfxPassword
            Write-MsixLog Info "Done: $outputPath"

        } finally {
            Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}

#endregion


#region --- Public: PSF (legacy JSON helper, kept for compatibility) --------

function New-MsixPsfJson {
    <#
    .SYNOPSIS
        Generates PSF config.json content from an AppxManifest and fixup parameters.
 
    .DESCRIPTION
        Legacy helper that emits a flat config.json string for a single fixup
        type. Produces incorrect output for multi-application packages — kept
        only for compatibility with first-generation scripts.
 
        For new code, build the config with the typed helpers
        (New-MsixPsfFileRedirectionConfig, New-MsixPsfRegLegacyConfig, etc.)
        and pass them to Add-MsixPsfV2 -Fixups.
 
    .PARAMETER AppxManifest
        Path to the AppxManifest.xml that describes the applications.
 
    .PARAMETER Fixup
        Which fixup to emit. One of: FileRedirectionFixup, TraceFixup,
        WaitForDebuggerFixup, DynamicLibraryFixup, EnvVarFixup,
        KernelTraceControl, RegLegacyFixups.
 
    .PARAMETER Patterns
        Pattern strings — interpretation depends on -Fixup.
 
    .PARAMETER Hive
        HKCU or HKLM. Only meaningful for RegLegacyFixups.
 
    .PARAMETER Access
        Access level for RegLegacyFixups (FULL2RW, FULL2R, Full2MaxAllowed,
        RW2R, RW2MaxAllowed).
 
    .PARAMETER Base
        Base path for FileRedirectionFixup.
 
    .EXAMPLE
        # Legacy: emit a FileRedirection config.json for the first app
        New-MsixPsfJson -AppxManifest .\AppxManifest.xml `
            -Fixup FileRedirectionFixup -Base 'logs' -Patterns '.*\.log'
 
    .OUTPUTS
        [string] JSON document. Emits a deprecation warning on every call.
 
    .NOTES
        Prefer the typed helpers (New-MsixPsfFileRedirectionConfig, etc.) combined
        with New-MsixPsfConfig for new scripts. This function is retained for
        backward compatibility with v1 scripts.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions',
        '',
        Justification = 'This compatibility helper only returns JSON and does not change system state.'
    )]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$AppxManifest,
        [Parameter(Mandatory)]
        [ValidateSet('FileRedirectionFixup','TraceFixup','WaitForDebuggerFixup',
                     'DynamicLibraryFixup','EnvVarFixup','KernelTraceControl','RegLegacyFixups')]
        [string]$Fixup,
        [string[]]$Patterns,
        [ValidateSet('HKCU','HKLM')]
        [string]$Hive,
        [ValidateSet('FULL2RW','FULL2R','Full2MaxAllowed','RW2R','RW2MaxAllowed')]
        [string]$Access,
        [string]$Base
    )

    Write-Warning 'New-MsixPsfJson is obsolete and produces incorrect output for multi-app packages. Use New-MsixPsfConfig with typed builders (New-MsixPsfFileRedirectionConfig, etc.) and Add-MsixPsfV2 instead.'
    [xml]$appinfo  = _MsixLoadXmlSecure -Path (Get-Item $AppxManifest).FullName
    $apps          = @($appinfo.Package.Applications.Application)

    $appEntries = foreach ($app in $apps) {
        [pscustomobject]@{
            id         = $app.Id
            executable = $app.Executable.Replace('\', '/')
        }
    }

    $fixupConfig = switch ($Fixup) {
        'FileRedirectionFixup' { New-MsixPsfFileRedirectionConfig -Base $Base -Patterns $Patterns }
        'RegLegacyFixups'      { New-MsixPsfRegLegacyConfig       -Hive $Hive -Access $Access -Patterns $Patterns }
        default {
            @{ dll = "$Fixup.dll" }
        }
    }

    $lastApp = $apps[-1]
    $exeName = $lastApp.Executable.Split('\')[-1] -replace '\.exe$', ''

    return @{
        applications = [array]$appEntries
        processes    = [array]@{
            executable = $exeName
            fixups     = [array]$fixupConfig
        }
    } | ConvertTo-Json -Depth 15
}

#endregion


#region --- Public: App aliases ---------------------------------------------

function Add-MsixAlias {
    <#
    .SYNOPSIS
        Adds AppExecutionAlias extensions to applications in an MSIX package.
 
    .DESCRIPTION
        Adds a windows.appExecutionAlias extension for each targeted application.
        The alias name matches the application's executable leaf name (or, when
        the executable is a PsfLauncher, the app Id with a `.exe` suffix).
        Idempotent: skips apps that already have an alias declared.
 
        Suggestions come from Get-MsixAliasCandidate, which also feeds the
        `AppExecutionAlias` finding in Get-MsixHeuristicFinding.
 
    .PARAMETER PackagePath
        Path to the .msix file to modify.
 
    .PARAMETER AppIds
        Application IDs to add aliases for. If omitted and -All is not set,
        aliases are added to all applications.
 
    .PARAMETER All
        Add aliases to all applications in the package.
 
    .PARAMETER OutputPath
        If set, write the modified package here instead of overwriting -PackagePath.
 
    .PARAMETER SkipSigning
        Do not sign the resulting package. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate (.pfx) path. Ignored when -SkipSigning is set.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .PARAMETER UnsignedOutputPath
        When signing fails, copy the unsigned scratch package here so the
        caller can inspect or hand-sign it. The original target is left
        intact regardless.
 
    .EXAMPLE
        # Add aliases to every app and sign in one shot (idempotent)
        Add-MsixAlias -PackagePath app.msix -All `
            -Pfx cert.pfx -PfxPassword $pw
 
    .EXAMPLE
        # Test/dev: add aliases for two specific apps, no signing
        Add-MsixAlias -PackagePath app.msix `
            -AppIds 'App','App2' -SkipSigning -OutputPath app-alias.msix
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$PackagePath,
        [string[]]$AppIds,
        [switch]$All,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword,
        [string]$UnsignedOutputPath
    )

    PROCESS {
        $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Add AppExecutionAlias')

        $targetAll = $All
        $targetAppIds = $AppIds

        _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath `
            -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword `
            -UnsignedOutputPath $UnsignedOutputPath `
            -WhatIfPreview:$isWhatIf `
            -Activity 'Add AppExecutionAlias' -Mutate {
            param([xml]$manifest)

            Add-MsixManifestNamespace $manifest 'uap3'
            Add-MsixManifestNamespace $manifest 'desktop'

            $uap3Uri    = Get-MsixManifestNamespaceUri 'uap3'
            $desktopUri = Get-MsixManifestNamespaceUri 'desktop'

            $targets = @($manifest.Package.Applications.Application)
            if (-not $targetAll -and $targetAppIds) {
                $targets = $targets | Where-Object { $targetAppIds -contains $_.Id }
            }

            foreach ($app in $targets) {
                # Reliable duplicate check: walk child nodes of Extensions
                $existingAliasExt = @($app.Extensions.Extension) |
                    Where-Object { $_.Category -eq 'windows.appExecutionAlias' }
                if ($existingAliasExt) {
                    Write-MsixLog Warning "AppExecutionAlias already present for $($app.Id); skipping"
                    continue
                }

                # Determine the alias name from the real executable
                $executable = $app.Executable.Replace('\', '/')
                if ($executable -match 'PsfLauncher') {
                    # Note: workspace is not available inside _MsixMutateManifest callback;
                    # fall back to guessing the alias from the app Id
                    $aliasName = "$($app.Id.ToLower()).exe"
                } else {
                    $aliasName = $executable.Split('/')[-1]
                }

                # Build: <uap3:Extension Category="windows.appExecutionAlias">
                # <uap3:AppExecutionAlias>
                # <desktop:ExecutionAlias Alias="myapp.exe" />
                # </uap3:AppExecutionAlias>
                # </uap3:Extension>
                $uap3Ext = $manifest.CreateElement('uap3:Extension', $uap3Uri)
                $uap3Ext.SetAttribute('Category', 'windows.appExecutionAlias')

                $aliasEl   = $manifest.CreateElement('uap3:AppExecutionAlias', $uap3Uri)
                $deskAlias = $manifest.CreateElement('desktop:ExecutionAlias', $desktopUri)
                $deskAlias.SetAttribute('Alias', $aliasName)

                $null = $aliasEl.AppendChild($deskAlias)
                $null = $uap3Ext.AppendChild($aliasEl)

                # Get or create Application/Extensions node (use captured ref, not property re-access)
                $extNode = $app.SelectSingleNode('*[local-name()="Extensions"]')
                if (-not $extNode) {
                    $extNode = $manifest.CreateElement('Extensions', $manifest.Package.NamespaceURI)
                    $null    = $app.AppendChild($extNode)
                }
                $null = $extNode.AppendChild($uap3Ext)

                Write-MsixLog Info "AppExecutionAlias added for $($app.Id): $aliasName"
            }
        }
    }
}

#endregion


#region --- Public: Start menu ----------------------------------------------

function Remove-MsixStartMenuEntry {
    <#
    .SYNOPSIS
        Sets AppListEntry=none on selected (or all) applications, hiding them
        from the Start menu.
 
    .DESCRIPTION
        Useful for helper / background-only apps that should not appear in the
        user's Start menu. Idempotent: re-running on apps already hidden logs
        an info line and leaves the manifest unchanged.
 
    .PARAMETER PackagePath
        Path to the .msix file.
 
    .PARAMETER AppIds
        Application IDs to hide. Omit or use -All for every app.
 
    .PARAMETER All
        Hide all applications.
 
    .PARAMETER OutputPath
        Write the modified package here instead of overwriting -PackagePath.
 
    .PARAMETER SkipSigning
        Do not sign the resulting package. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate (.pfx) path.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .PARAMETER UnsignedOutputPath
        When signing fails, copy the unsigned scratch package here so the
        caller can inspect or hand-sign it. The original target is left intact.
 
    .EXAMPLE
        # Hide every app from Start and re-sign (idempotent)
        Remove-MsixStartMenuEntry -PackagePath app.msix -All `
            -Pfx cert.pfx -PfxPassword $pw
 
    .EXAMPLE
        # Hide just the Helper app, skip signing for now
        Remove-MsixStartMenuEntry -PackagePath app.msix `
            -AppIds 'Helper' -SkipSigning
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$PackagePath,
        [string[]]$AppIds,
        [switch]$All,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword,
        [string]$UnsignedOutputPath
    )

    PROCESS {
        $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Remove Start menu entry')

        $targetAll = $All
        $targetAppIds = $AppIds

        _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath `
            -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword `
            -UnsignedOutputPath $UnsignedOutputPath `
            -WhatIfPreview:$isWhatIf `
            -Activity 'Remove Start menu entry' -Mutate {
            param([xml]$manifest)

            $targets = @($manifest.Package.Applications.Application)
            if (-not $targetAll -and $targetAppIds) {
                $targets = $targets | Where-Object { $targetAppIds -contains $_.Id }
            }

            foreach ($app in $targets) {
                $ve = $app.SelectSingleNode('*[local-name()="VisualElements"]')
                if (-not $ve) { continue }
                if ($ve.GetAttribute('AppListEntry') -eq 'none') {
                    Write-MsixLog Info "$($app.Id) already hidden from Start menu; skipping"
                    continue
                }
                $ve.SetAttribute('AppListEntry', 'none')
                Write-MsixLog Info "Start menu entry removed: $($app.Id)"
            }
        }
    }
}


function Add-MsixStartMenuFolder {
    <#
    .SYNOPSIS
        Sets a VisualGroup (Start menu folder) on all applications in a package.
 
    .DESCRIPTION
        Sets the VisualGroup attribute on every Application/VisualElements
        node. All apps in the package will be grouped under the supplied
        folder name in the Start menu. Idempotent — re-running with the
        same FolderName produces the same manifest.
 
    .PARAMETER PackagePath
        Path to the .msix file.
 
    .PARAMETER FolderName
        Name of the Start menu folder / group.
 
    .PARAMETER OutputPath
        Write the modified package here instead of overwriting -PackagePath.
 
    .PARAMETER SkipSigning
        Do not sign the resulting package. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate (.pfx) path.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .PARAMETER UnsignedOutputPath
        When signing fails, copy the unsigned scratch package here so the
        caller can inspect or hand-sign it.
 
    .EXAMPLE
        # Group all apps under 'Contoso Apps' and sign (idempotent)
        Add-MsixStartMenuFolder -PackagePath app.msix `
            -FolderName 'Contoso Apps' -Pfx cert.pfx -PfxPassword $pw
 
    .EXAMPLE
        # Dev: change folder, skip signing
        Add-MsixStartMenuFolder -PackagePath app.msix `
            -FolderName 'Tools' -SkipSigning
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$PackagePath,
        [Parameter(Mandatory)]
        [string]$FolderName,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword,
        [string]$UnsignedOutputPath
    )

    PROCESS {
        $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, "Set VisualGroup '$FolderName'")

        _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath `
            -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword `
            -UnsignedOutputPath $UnsignedOutputPath `
            -WhatIfPreview:$isWhatIf `
            -Activity "Set VisualGroup '$FolderName'" -Mutate {
            param([xml]$manifest)

            foreach ($app in @($manifest.Package.Applications.Application)) {
                $ve = $app.SelectSingleNode('*[local-name()="VisualElements"]')
                if (-not $ve) { continue }
                $ve.SetAttribute('VisualGroup', $FolderName)
                Write-MsixLog Info "VisualGroup '$FolderName' set on $($app.Id)"
            }
        }
    }
}

#endregion