Public/Convert-MSIXClassicContextMenuToVerbs.ps1


function Convert-MSIXClassicContextMenuToVerbs {
<#
.SYNOPSIS
    Converts classic COM-based context menu verbs to modern executable-based uap3 verbs.

.DESCRIPTION
    Adds verb entries to every windows.fileTypeAssociation extension in
    AppxManifest.xml. Each verb directly invokes the package application with the given
    command-line parameters - no COM server required.

    Use this as an alternative to COM-based shell extensions (windows.comServer +
    windows.fileExplorerContextMenus with Clsid verbs) when the COM surrogate cannot
    reach the host executable at runtime inside the MSIX container.

    Limitations compared to COM-based verbs:
      - Verb display text is static. Dynamic names like "Add to 'foldername.rar'" are
        not possible; use a fixed label such as "Add to archive (WinRAR)" instead.
      - Custom icons per verb are not supported (the application icon is used).
      - Only a single selected file is passed via %1 by default (see MultiSelectModel).

    The modern verbs appear in both the Windows 11 context menu (modern panel) and the
    classic context menu (via "Show more options"). They work without a COM registration
    and without a surrogate process.

    By default, windows.comServer and windows.fileExplorerContextMenus extensions are
    removed after adding the verbs, which eliminates the broken classic entries that
    appear but do not execute. Pass -KeepClassicExtensions to retain them alongside
    the new verbs.

.PARAMETER MSIXFolder
    Path to the expanded MSIX package folder (must contain AppxManifest.xml).

.PARAMETER VerbMappings
    Array of hashtables, each describing one verb to add. Required keys:
      Text - Display text shown in the context menu.
      Parameters - Command-line arguments passed to the application. Use %1 as a
                   placeholder for the selected file path (quoted by the Shell).
    Optional keys:
      Id - Verb identifier (letters and digits only, max 30 chars).
                        Auto-derived from Text if omitted.
      MultiSelectModel - 'Single' (default), 'Player', or 'Document'.

.PARAMETER KeepClassicExtensions
    When set, keeps windows.comServer and windows.fileExplorerContextMenus extensions
    after adding the new verbs. By default they are removed.

.EXAMPLE
    # WinRAR: replace COM-based verbs with simple executable verbs
    Convert-MSIXClassicContextMenuToVerbs -MSIXFolder "C:\MSIXTemp\WinRAR" `
        -VerbMappings @(
            @{ Text = 'Add to archive (WinRAR)'; Parameters = 'a "%1"' },
            @{ Text = 'Extract here'; Parameters = 'x "%1"' },
            @{ Text = 'Extract to folder'; Parameters = 'e "%1"' }
        )

.EXAMPLE
    # Keep the classic entries for comparison (do not remove them)
    Convert-MSIXClassicContextMenuToVerbs -MSIXFolder "C:\MSIXTemp\WinRAR" `
        -VerbMappings @(
            @{ Text = 'Add to archive (WinRAR)'; Parameters = 'a "%1"' }
        ) `
        -KeepClassicExtensions

.NOTES
    https://www.nick-it.de

#>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [System.IO.DirectoryInfo] $MSIXFolder,

        [Parameter(Mandatory = $true)]
        [hashtable[]] $VerbMappings,

        # When set, keeps windows.comServer and windows.fileExplorerContextMenus after adding verbs.
    [switch] $KeepClassicExtensions
    )

    process {
        $manifestPath = Join-Path $MSIXFolder 'AppxManifest.xml'
        if (-not (Test-Path $manifestPath)) {
            Write-Error "AppxManifest.xml not found in: $($MSIXFolder.FullName)"
            return
        }

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

        $nsBase = 'http://schemas.microsoft.com/appx/manifest/foundation/windows10'
        $nsUap2 = 'http://schemas.microsoft.com/appx/manifest/uap/windows10/2'
        $nsUap3 = 'http://schemas.microsoft.com/appx/manifest/uap/windows10/3'

        $nsmgr = New-Object System.Xml.XmlNamespaceManager($manifest.NameTable)
        $nsmgr.AddNamespace('ns',   $nsBase)
        $nsmgr.AddNamespace('uap2', $nsUap2)
        $nsmgr.AddNamespace('uap3', $nsUap3)

        Add-MSIXManifestNamespace -Manifest $manifest -Prefixes 'uap3'

        # Validate and normalise verb mappings
        $verbs = foreach ($map in $VerbMappings) {
            if (-not $map.ContainsKey('Text') -or -not $map.ContainsKey('Parameters')) {
                Write-Warning "VerbMapping is missing 'Text' or 'Parameters' key - skipped."
                continue
            }
            $id = if ($map.ContainsKey('Id') -and $map['Id'] -ne '') {
                $map['Id']
            }
            else {
                # Auto-derive Id: keep only letters and digits, max 30 chars
                ($map['Text'] -replace '[^A-Za-z0-9]', '') -replace '^(.{1,30}).*$', '$1'
            }
            $multiSelect = if ($map.ContainsKey('MultiSelectModel')) { $map['MultiSelectModel'] } else { 'Single' }
            [PSCustomObject]@{ Id = $id; Text = $map['Text']; Parameters = $map['Parameters']; MultiSelectModel = $multiSelect }
        }

        if ($verbs.Count -eq 0) {
            Write-Warning "No valid VerbMappings provided - nothing added."
            return
        }

        # Find all FileTypeAssociation elements
        $ftaNodes = @($manifest.SelectNodes(
            "//uap3:Extension[@Category='windows.fileTypeAssociation']/uap3:FileTypeAssociation",
            $nsmgr))

        if ($ftaNodes.Count -eq 0) {
            Write-Warning "No windows.fileTypeAssociation extensions found in AppxManifest.xml."
            return
        }

        $changed = $false
        foreach ($fta in $ftaNodes) {
            $ftaName = $fta.GetAttribute('Name')

            # Remove any uap3:SupportedVerbs that may have been added by a previous run
            # (that element is not valid in manifests with MinVersion < 10.0.22000.0).
            $uap3Sv = $fta.SelectSingleNode('uap3:SupportedVerbs', $nsmgr)
            if ($null -ne $uap3Sv) {
                $null = $fta.RemoveChild($uap3Sv)
                Write-Verbose "Removed uap3:SupportedVerbs from FileTypeAssociation '$ftaName'."
            }

            # Reuse the existing uap2:SupportedVerbs if present (MSIX PT writes this);
            # create one only when the FTA has no verb container at all.
            $verbsNode = $fta.SelectSingleNode('uap2:SupportedVerbs', $nsmgr)
            if ($null -eq $verbsNode) {
                $verbsNode = $manifest.CreateElement('uap2:SupportedVerbs', $nsUap2)
                $null = $fta.AppendChild($verbsNode)
                Write-Verbose "Created uap2:SupportedVerbs in FileTypeAssociation '$ftaName'."
            }

            # Remove any of our custom verbs from previous runs (idempotency).
            $verbIds = foreach ($v in $verbs) { $v.Id }
            foreach ($existingVerb in @($verbsNode.SelectNodes('uap3:Verb', $nsmgr))) {
                if ($verbIds -contains $existingVerb.GetAttribute('Id')) {
                    $null = $verbsNode.RemoveChild($existingVerb)
                }
            }

            if (-not $PSCmdlet.ShouldProcess("FileTypeAssociation '$ftaName'", 'Add verbs to SupportedVerbs')) {
                continue
            }

            foreach ($verb in $verbs) {
                $verbEl = $manifest.CreateElement('uap3:Verb', $nsUap3)
                $null = $verbEl.SetAttribute('Id',               $verb.Id)
                $null = $verbEl.SetAttribute('Parameters',       $verb.Parameters)
                $null = $verbEl.SetAttribute('MultiSelectModel', $verb.MultiSelectModel)
                $verbEl.InnerText = $verb.Text
                $null = $verbsNode.AppendChild($verbEl)
                Write-Verbose "Added verb '$($verb.Id)' to FileTypeAssociation '$ftaName'."
            }

            $changed = $true
        }

        if (-not $changed) { return }

        if (-not $KeepClassicExtensions) {
            $classicCategories = @(
                'windows.comServer',
                'windows.fileExplorerContextMenus',
                'windows.fileExplorerClassicContextMenuHandler',
                'windows.fileExplorerClassicDragDropContextMenuHandler'
            )
            foreach ($ext in @($manifest.SelectNodes("//*[local-name()='Extension']"))) {
                if ($classicCategories -contains $ext.GetAttribute('Category')) {
                    $null = $ext.ParentNode.RemoveChild($ext)
                    Write-Verbose "Removed classic Extension Category='$($ext.GetAttribute('Category'))'."
                }
            }

            # Remove Extensions container elements left empty by the removals above.
            # Use not(*) to match elements with no element children regardless of whitespace.
            $emptyExtensionsNodes = @($manifest.SelectNodes("//*[local-name()='Extensions' and not(*)]"))
            foreach ($node in $emptyExtensionsNodes) {
                $null = $node.ParentNode.RemoveChild($node)
                Write-Verbose "Removed empty Extensions element."
            }
        }

        $manifest.PreserveWhitespace = $false
        $manifest.Save($manifestPath)
    }
}