MSIX.ContextMenu.ps1
|
function Add-MsixLegacyContextMenu { <# .SYNOPSIS Adds a legacy IContextMenu shell extension to an MSIX package. .DESCRIPTION Wraps a COM IContextMenu / IDropTarget shell-extension DLL so it surfaces under MSIX containerisation. This is the TMEditX-verified pattern that the Windows shell actually wires up at runtime. Min OS: Windows 10 1809 (build 17763) for the desktop4 windows.fileExplorerContextMenus extension. MaxVersionTested is bumped automatically. Adds to AppxManifest.xml inside the Application's Extensions node: - com:Extension (windows.comServer) — wraps the DLL as a SurrogateServer so it runs in dllhost.exe under the MSIX isolation boundary. - desktop4:Extension (windows.fileExplorerContextMenus) — a desktop4:FileExplorerContextMenus block containing desktop5:ItemType / desktop5:Verb entries that reference the CLSID. The verb Id defaults to 'ContextMenuHandlers' for -MenuType ContextMenu and 'DragDropHandlers' for -MenuType DragDrop. NOTE: An earlier version of this cmdlet emitted desktop9 elements (windows.fileExplorerClassicContextMenuHandler). That schema turned out NOT to be the right shape for COM-based shell extensions — desktop4 + desktop5 handles both legacy IContextMenu and modern IExplorerCommand depending on which interface(s) the CLSID's COM class implements. .PARAMETER PackagePath Path to the .msix file to modify. .PARAMETER AppId Id of the Application element to attach the extensions to. Defaults to the first Application in the manifest. .PARAMETER ShellExtDll Package-relative VFS path to the COM server DLL (e.g. VFS\ProgramFilesX64\App\ShellExt.dll). MSIX folder-variable prefixes ([{ProgramFilesX64}] etc.) are resolved automatically. .PARAMETER Clsid GUID of the COM class, with or without curly braces (e.g. '{XXXXXXXX-...}'). .PARAMETER DisplayName Friendly display name for the COM surrogate server. .PARAMETER FileTypes Array of file-type targets. Use '*' for all files, '.ext' for a specific extension, 'Directory' for folders, 'Drive' for drives. Defaults to @('*'). .PARAMETER MenuType 'ContextMenu' (right-click) or 'DragDrop' (drag-and-drop handler). .PARAMETER OutputPath Write the modified package here instead of overwriting -PackagePath. .PARAMETER SkipSigning Do not sign the resulting package. .PARAMETER Pfx Signing certificate file. Omit to use automatic store selection. .PARAMETER PfxPassword SecureString password for -Pfx. .PARAMETER UnsignedOutputPath When signing fails, copy the unsigned repacked package here so the operator can manually re-sign. The original is never overwritten on a failed sign. .EXAMPLE # Wrap a classic COM shell extension (e.g. NppShell.dll) so it # surfaces in File Explorer's context menu for all file types. $pw = Read-Host -AsSecureString Add-MsixLegacyContextMenu -PackagePath app.msix ` -ShellExtDll 'VFS\ProgramFilesX64\App\ShellExt.dll' ` -Clsid '{D7E6F1A2-3B4C-4D5E-9F00-112233445566}' ` -DisplayName 'My Context Menu' ` -FileTypes '*', '.log', 'Directory' ` -Pfx cert.pfx -PfxPassword $pw .EXAMPLE # Drag-and-drop handler variant — same schema (desktop4 + desktop5), # the Verb Id changes to 'DragDropHandlers'. Add-MsixLegacyContextMenu -PackagePath app.msix ` -ShellExtDll '[{ProgramFilesX64}]\App\ShellExt.dll' ` -Clsid 'D7E6F1A2-3B4C-4D5E-9F00-112233445566' ` -DisplayName 'Drop Handler' ` -MenuType DragDrop ` -FileTypes 'Directory' .EXAMPLE # Preview only: WhatIf still runs unpack/edit/pack so you can inspect # the result; signing and target replacement are skipped. Add-MsixLegacyContextMenu -WhatIf -PackagePath app.msix ` -ShellExtDll 'VFS\ProgramFilesX64\App\ShellExt.dll' ` -Clsid '{D7E6F1A2-3B4C-4D5E-9F00-112233445566}' ` -DisplayName 'Preview' ` -UnsignedOutputPath 'C:\drop\app-preview.msix' .NOTES Add-MsixFileExplorerContextMenu is the leaner companion when the COM class is already declared elsewhere — it emits only the desktop4 verb declaration, no COM surrogate registration. #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'AppId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$AppId, [Parameter(Mandatory)] [string]$ShellExtDll, [Parameter(Mandatory)] [ValidatePattern( '^(\{)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(\})?$', ErrorMessage = 'CLSID must be a GUID like 12345678-1234-1234-1234-123456789abc (curly braces optional).' )] [string]$Clsid, [Parameter(Mandatory)] [string]$DisplayName, [ValidateScript({ foreach ($t in $_) { if ($t -notmatch '^(\*|\.[a-zA-Z0-9][a-zA-Z0-9_.-]{0,31}|Directory|Drive)$') { throw "Invalid file type: '$t'. Allowed: '*', '.ext' (alphanumeric/underscore/dot/hyphen, max 32 chars after dot), 'Directory', 'Drive'." } } $true })] [string[]]$FileTypes = @('*'), [ValidateSet('ContextMenu', 'DragDrop')] [string]$MenuType = 'ContextMenu', [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Add Legacy Context Menu') # com:Class/@Id and desktop5:Verb/@Clsid both use the bare GUID format # (no curly braces): xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Normalise CLSID: strip surrounding braces and lower-case. TMEditX-style # lowercase matches the format Windows actually persists into HKCR\CLSID\… # entries; the AppX schema is case-insensitive but staying lower-case keeps # diff output stable when re-running this cmdlet. $ClsidBare = $Clsid.Trim().Trim('{', '}').ToLowerInvariant() # Resolve MSIX folder-variable prefixes ([{ProgramFilesX64}]\...) to VFS paths. # Callers may pass the raw registry path; normalise it defensively. $varMap = @{ 'ProgramFilesX64' = 'VFS\ProgramFilesX64' 'ProgramFilesX86' = 'VFS\ProgramFiles(x86)' 'ProgramFiles6432' = 'VFS\ProgramFilesX64' 'System' = 'VFS\SystemX64' 'SystemX86' = 'VFS\System' 'Windows' = 'VFS\Windows' 'CommonAppData' = 'VFS\ProgramData' 'AppData' = 'VFS\AppData\Roaming' 'LocalAppData' = 'VFS\AppData\Local' } foreach ($var in $varMap.Keys) { if ($ShellExtDll -match ('^\[\{' + [regex]::Escape($var) + '\}\](.+)$')) { $ShellExtDll = $varMap[$var] + '\' + $Matches[1].TrimStart('\') break } } _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'Add Legacy Context Menu' -Mutate { param([xml]$manifest) # Required namespaces. # The TMEditX-verified working pattern for shell-extension context # menus in MSIX uses the desktop4 + desktop5 schemas, NOT desktop9: # # <com:Extension Category="windows.comServer"> ← Application # <com:ComServer><com:SurrogateServer> # <com:Class Id="..." Path="..." /> # </com:SurrogateServer></com:ComServer> # </com:Extension> # <desktop4:Extension Category="windows.fileExplorerContextMenus"> ← Application # <desktop4:FileExplorerContextMenus> # <desktop5:ItemType Type="*"> # <desktop5:Verb Id="ContextMenuHandlers" Clsid="..." /> # </desktop5:ItemType> # </desktop4:FileExplorerContextMenus> # </desktop4:Extension> # # desktop9:fileExplorerClassicContextMenuHandler turns out NOT to be # the right schema for COM-based shell extensions — the desktop4/5 # pair handles both legacy (IContextMenu) and modern (IExplorerCommand) # via the same path because the CLSID's COM class implements whichever # interface(s) it supports. Add-MsixManifestNamespace $manifest 'com' Add-MsixManifestNamespace $manifest 'desktop4' Add-MsixManifestNamespace $manifest 'desktop5' # desktop4:windows.fileExplorerContextMenus requires Win10 1809 (17763). Set-MsixManifestMaxVersionTested $manifest -MinBuild 17763 # ── Locate the target Application ───────────────────────────────── $apps = @($manifest.Package.Applications.Application) $app = if ($AppId) { $apps | Where-Object { $_.GetAttribute('Id') -eq $AppId } | Select-Object -First 1 } else { $apps | Select-Object -First 1 } if (-not $app) { throw "Application '$AppId' not found in the manifest." } # ── Idempotency: skip if this CLSID is already declared ────────── $existingClass = $manifest.SelectSingleNode("//*[local-name()='Class' and @Id='$ClsidBare']") if ($existingClass) { Write-MsixLog Info "COM class $ClsidBare already declared in manifest — skipping Add-MsixLegacyContextMenu." return } # ── Application-level Extensions node ──────────────────────────── # Everything in this cmdlet lives at Applications/Application/Extensions — # the COM declaration AND the desktop4 verb. This is the placement # TMEditX uses and that Explorer actually wires up at runtime. $appExt = $app.SelectSingleNode('*[local-name()="Extensions"]') if (-not $appExt) { $appExt = $manifest.CreateElement('Extensions', $manifest.Package.NamespaceURI) $null = $app.AppendChild($appExt) } # ── COM SurrogateServer (bare 'com' namespace, Application level) ── $comUri = Get-MsixManifestNamespaceUri 'com' $comExt = $manifest.CreateElement('com:Extension', $comUri) $comExt.SetAttribute('Category', 'windows.comServer') $comServer = $manifest.CreateElement('com:ComServer', $comUri) $surrogate = $manifest.CreateElement('com:SurrogateServer', $comUri) $surrogate.SetAttribute('DisplayName', $DisplayName) $class = $manifest.CreateElement('com:Class', $comUri) $class.SetAttribute('Id', $ClsidBare) # ST_GUID — no braces $class.SetAttribute('Path', $ShellExtDll) $class.SetAttribute('ThreadingModel', 'STA') $null = $surrogate.AppendChild($class) $null = $comServer.AppendChild($surrogate) $null = $comExt.AppendChild($comServer) $null = $appExt.AppendChild($comExt) # ── desktop4 + desktop5 verb declaration ───────────────────────── # desktop4:Extension wraps desktop4:FileExplorerContextMenus, which # in turn wraps desktop5:ItemType / desktop5:Verb (desktop5 is the # ItemType/Verb namespace — desktop4 is the outer container). # For -MenuType DragDrop we use the same schema; the COM class # decides whether it implements drag/drop or context menu interfaces. $d4Uri = Get-MsixManifestNamespaceUri 'desktop4' $d5Uri = Get-MsixManifestNamespaceUri 'desktop5' $d4Ext = $manifest.CreateElement('desktop4:Extension', $d4Uri) $d4Ext.SetAttribute('Category', 'windows.fileExplorerContextMenus') $menus = $manifest.CreateElement('desktop4:FileExplorerContextMenus', $d4Uri) # The Verb Id is the registry key name historically used for shellex # COM handlers. We default to 'ContextMenuHandlers' (matching TMEditX) # for ContextMenu, and 'DragDropHandlers' for DragDrop. $verbId = switch ($MenuType) { 'ContextMenu' { 'ContextMenuHandlers' } 'DragDrop' { 'DragDropHandlers' } } foreach ($type in $FileTypes) { $itemType = $manifest.CreateElement('desktop5:ItemType', $d5Uri) $itemType.SetAttribute('Type', $type) $verbNode = $manifest.CreateElement('desktop5:Verb', $d5Uri) $verbNode.SetAttribute('Id', $verbId) $verbNode.SetAttribute('Clsid', $ClsidBare) $null = $itemType.AppendChild($verbNode) $null = $menus.AppendChild($itemType) } $null = $d4Ext.AppendChild($menus) $null = $appExt.AppendChild($d4Ext) } } function Add-MsixFileExplorerContextMenu { <# .SYNOPSIS Adds a modern IExplorerCommand-based context menu to an Application in an MSIX package. .DESCRIPTION Uses desktop4:FileExplorerContextMenus, which is the recommended approach for new shell extensions (not legacy IContextMenu COM servers). Added at the Application level. Min OS: Windows 10 build 17134 (1803). MaxVersionTested is bumped automatically. .PARAMETER PackagePath Path to the .msix file to modify. .PARAMETER AppId The Id attribute of the Application element to extend. .PARAMETER VerbId Short identifier for the verb (e.g. 'open', 'edit', 'convert'). .PARAMETER VerbClsid GUID of the IExplorerCommand COM class implementing the verb. .PARAMETER FileTypes File-type targets. Use '*' for all files, '.ext' for specific extensions, 'Directory' for folders. .PARAMETER OutputPath Write the modified package here instead of overwriting -PackagePath. .PARAMETER SkipSigning Do not sign the resulting package. .PARAMETER Pfx Signing certificate file. Omit to use automatic store selection. .PARAMETER PfxPassword SecureString password for -Pfx. .PARAMETER UnsignedOutputPath When signing fails, copy the unsigned repacked package here so the operator can manually re-sign. .EXAMPLE # MODERN IExplorerCommand (desktop4) — pick THIS cmdlet for new # shell extensions. Works on Win10 1803 (build 17134) and above. $pw = Read-Host -AsSecureString Add-MsixFileExplorerContextMenu -PackagePath app.msix -AppId 'App' ` -VerbId 'open' -VerbClsid '{A1B2C3D4-E5F6-4789-ABCD-EF0123456789}' ` -FileTypes '.log', '.txt' ` -Pfx cert.pfx -PfxPassword $pw .EXAMPLE # Multiple verbs in one package: call once per verb Add-MsixFileExplorerContextMenu -PackagePath app.msix -AppId 'App' ` -VerbId 'convert' -VerbClsid 'A1B2C3D4-E5F6-4789-ABCD-EF0123456789' ` -FileTypes 'Directory' -SkipSigning .NOTES For shipping an existing IContextMenu COM shell-extension DLL, prefer Add-MsixLegacyContextMenu — it emits the matching com:Extension/SurrogateServer block alongside the desktop4 verb so the CLSID is fully registered in one call. #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Parameters are captured by the -Mutate scriptblock passed to _MsixMutateManifest.')] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'AppId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$AppId, [Parameter(Mandatory)] [ValidatePattern( '^[A-Za-z_][A-Za-z0-9_.-]*$', ErrorMessage = 'VerbId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.' )] [string]$VerbId, [Parameter(Mandatory)] [ValidatePattern( '^(\{)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(\})?$', ErrorMessage = 'CLSID must be a GUID like 12345678-1234-1234-1234-123456789abc (curly braces optional).' )] [string]$VerbClsid, [Parameter(Mandatory)] [ValidateScript({ foreach ($t in $_) { if ($t -notmatch '^(\*|\.[a-zA-Z0-9][a-zA-Z0-9_.-]{0,31}|Directory|Drive)$') { throw "Invalid file type: '$t'. Allowed: '*', '.ext' (alphanumeric/underscore/dot/hyphen, max 32 chars after dot), 'Directory', 'Drive'." } } $true })] [string[]]$FileTypes, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Add File Explorer Context Menu') # CLSID is case-insensitive in the schema but we normalise to lower-case # (matching TMEditX style and the persisted HKCR\CLSID\... format). $verbClsid = $verbClsid.Trim().Trim('{', '}').ToLowerInvariant() _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath ` -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -WhatIfPreview:$isWhatIf ` -Activity 'Add File Explorer Context Menu' -Mutate { param([xml]$manifest) # desktop4 wraps the Extension/FileExplorerContextMenus container; # desktop5 provides the ItemType/Verb children (TMEditX-verified # working pattern). desktop4 alone (using desktop4:ItemType/Verb) # also exists but desktop5 is the newer, preferred form. Add-MsixManifestNamespace $manifest 'desktop4' Add-MsixManifestNamespace $manifest 'desktop5' Set-MsixManifestMaxVersionTested $manifest -MinBuild 17763 # Locate the Application (windows.fileExplorerContextMenus lives at # Applications/Application/Extensions per the TMEditX-verified # working manifest — the schema permits Package level too but the # Application-level form is what Explorer actually wires up). $app = Get-MsixManifestApplication -Manifest $manifest -AppId $AppId $extNode = $app.SelectSingleNode('*[local-name()="Extensions"]') if (-not $extNode) { $extNode = $manifest.CreateElement('Extensions', $manifest.Package.NamespaceURI) $null = $app.AppendChild($extNode) } $d4Uri = Get-MsixManifestNamespaceUri 'desktop4' $d5Uri = Get-MsixManifestNamespaceUri 'desktop5' $d4Ext = $manifest.CreateElement('desktop4:Extension', $d4Uri) $d4Ext.SetAttribute('Category', 'windows.fileExplorerContextMenus') $ctxMenus = $manifest.CreateElement('desktop4:FileExplorerContextMenus', $d4Uri) foreach ($ft in $FileTypes) { $itemType = $manifest.CreateElement('desktop5:ItemType', $d5Uri) $itemType.SetAttribute('Type', $ft) $verbNode = $manifest.CreateElement('desktop5:Verb', $d5Uri) $verbNode.SetAttribute('Id', $VerbId) $verbNode.SetAttribute('Clsid', $verbClsid) $null = $itemType.AppendChild($verbNode) $null = $ctxMenus.AppendChild($itemType) } $null = $d4Ext.AppendChild($ctxMenus) $null = $extNode.AppendChild($d4Ext) } } |