Public/Add-MSIXPSFFtaCom.ps1
|
function Add-MSIXPSFFtaCom { <# .SYNOPSIS Moves shell extension entries from an existing Application to a new PsfFtaCom surrogate. .DESCRIPTION Fixes shell extensions (context menu handlers, file type associations, COM surrogate servers) that do not work correctly inside an MSIX container by routing them through Tim Mangan's PsfFtaCom executable. The original MSIX manifest already contains all required extension elements (com:Extension, uap3:Extension, desktop9:Extension, namespace declarations) under an existing Application entry. This function: 1. Auto-detects the Application that contains the shell extension Extensions block. If more than one Application has Extensions, SourceAppId must be specified. 2. Creates a new Application element pointing to PsfFtaCom64.exe or PsfFtaCom32.exe with AppListEntry=none (hidden from the Start menu). VisualElements logos are copied from the source Application. 3. Moves the entire Extensions block from the source Application to the new FtaCom Application. The source Application is left without Extensions. 4. Updates all uap3:Verb Parameters in the moved Extensions to prepend the real application executable path. PsfFtaCom requires the full package-relative path as the first verb argument (e.g. "VFS\ProgramFilesX64\App\App.exe" "%1"). The original executable is read from config.json.xml when Add-MSXIXPSFShim has already run; otherwise it is taken from the source Application attribute. 5. Creates or updates config.json.xml with hasShellVerbs=true for this entry. Call Add-MSIXPsfFrameworkFiles before this function so that PsfFtaCom64.exe / PsfFtaCom32.exe are already present in the MSIX package root. Tim Mangan PSF only. Requires Set-MSIXActivePSFFramework to point to a TimMangan path. .PARAMETER MSIXFolder Path to the expanded MSIX package folder (must contain AppxManifest.xml). .PARAMETER PSFArchitektur Architecture of the PsfFtaCom executable: Auto, x64, or x86. When omitted, PSFDefaultArchitecture from module configuration is used. Auto resolves to x64. .PARAMETER SourceAppId Id of the Application whose Extensions block is moved to the new FtaCom Application. Optional — auto-detected when only one Application has an Extensions child. .PARAMETER FtaComAppId Id for the new FtaCom Application element. Optional — defaults to "<SourceAppId>PsfFtaComSixFour" (x64) or "<SourceAppId>PsfFtaComThirtyTwo" (x86). .PARAMETER TerminateChildren Written to config.json.xml. Tim Mangan PSF always writes this field. Default: $false. .EXAMPLE Add-MSIXPSFFtaCom -MSIXFolder "C:\MSIXTemp\WinRAR" .EXAMPLE Add-MSIXPSFFtaCom -MSIXFolder "C:\MSIXTemp\App" -PSFArchitektur x86 .EXAMPLE Add-MSIXPSFFtaCom -MSIXFolder "C:\MSIXTemp\App" -SourceAppId "AppHelper" -FtaComAppId "AppHelper_ShellExt" .NOTES Tim Mangan PSF only. Requires Set-MSIXActivePSFFramework to point to a TimMangan path. https://www.nick-it.de Andreas Nick, 2026 #> [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)] [System.IO.DirectoryInfo] $MSIXFolder, # Valid values: Auto | x64 | x86. When omitted, PSFDefaultArchitecture from module config is used. [ValidateSet('Auto', 'x64', 'x86')] [String] $PSFArchitektur, [String] $SourceAppId, [String] $FtaComAppId, # Extension Category values to keep in the source Application instead of moving # to the PsfFtaCom Application. Use this to leave context menu handlers or FTA # declarations in the visible application so that Windows DEH can process them. [string[]] $ExcludeCategories = @(), # Tim Mangan PSF — always written to config.json.xml. [bool] $TerminateChildren = $false ) process { if (-not ($Script:PsfBasePath -like '*TimMangan*')) { Write-Error "Add-MSIXPSFFtaCom requires Tim Mangan PSF. Use Set-MSIXActivePSFFramework to select a TimMangan PSF path." return } $manifestPath = Join-Path $MSIXFolder 'AppxManifest.xml' if (-not (Test-Path $manifestPath)) { Write-Error "AppxManifest.xml not found in: $($MSIXFolder.FullName)" return } # --- Resolve architecture --- if ($PSBoundParameters.ContainsKey('PSFArchitektur')) { $resolvedArch = $PSFArchitektur } else { $resolvedArch = $Script:MSIXForceletsConfig.PSFDefaultArchitecture } if ($resolvedArch -eq 'Auto') { $resolvedArch = 'x64' } $ftaSourceExe = if ($resolvedArch -eq 'x64') { 'PsfFtaCom64.exe' } else { 'PsfFtaCom32.exe' } $ftaArch = if ($resolvedArch -eq 'x64') { '64' } else { '32' } # PsfFtaCom exe must be present — Add-MSIXPsfFrameworkFiles copies it. if (-not (Test-Path (Join-Path $MSIXFolder.FullName $ftaSourceExe))) { Write-Warning "$ftaSourceExe not found in MSIX package root. Run Add-MSIXPsfFrameworkFiles first." } # --- Load AppxManifest --- $manifest = New-Object xml $manifest.Load($manifestPath) $nsBase = 'http://schemas.microsoft.com/appx/manifest/foundation/windows10' $nsUap = 'http://schemas.microsoft.com/appx/manifest/uap/windows10' $nsUap3 = 'http://schemas.microsoft.com/appx/manifest/uap/windows10/3' $nsmgr = New-Object System.Xml.XmlNamespaceManager($manifest.NameTable) $null = $nsmgr.AddNamespace('ns', $nsBase) $null = $nsmgr.AddNamespace('uap', $nsUap) $null = $nsmgr.AddNamespace('uap3', $nsUap3) # --- Auto-detect source Application --- if ($SourceAppId) { $sourceApp = $manifest.SelectSingleNode("//ns:Application[@Id='$SourceAppId']", $nsmgr) if ($null -eq $sourceApp) { Write-Error "Application '$SourceAppId' not found in AppxManifest.xml." return } } else { $appsWithExtensions = $manifest.SelectNodes( '//ns:Application[ns:Extensions]', $nsmgr) if ($appsWithExtensions.Count -eq 0) { Write-Error "No Application with an Extensions element found in AppxManifest.xml." return } if ($appsWithExtensions.Count -gt 1) { $ids = ($appsWithExtensions | ForEach-Object { $_.GetAttribute('Id') }) -join ', ' Write-Error "Multiple Applications with Extensions found: $ids. Specify -SourceAppId." return } $sourceApp = $appsWithExtensions.Item(0) $SourceAppId = $sourceApp.GetAttribute('Id') Write-Verbose "Auto-detected source Application: $SourceAppId" } # Strip the PsfLauncher<Letter> suffix added by Add-MSXIXPSFShim so the FtaCom # Application Id and exe name are always derived from the original base name, # not from the launcher-specific Id (e.g. "WINRAR" not "WINRARPsfLauncherA"). $baseId = $SourceAppId -replace 'PsfLauncher[A-Za-z]$', '' if (-not $FtaComAppId) { $FtaComAppId = if ($resolvedArch -eq 'x64') { "$($baseId)PsfFtaComSixFour" } else { "$($baseId)PsfFtaComThirtyTwo" } } # Rename PsfFtaCom following best practice: <BaseId>_PsfFtaCom<Arch>.exe # The default processes section adds an explicit '.*_PsfFtaCom.*' entry so the # process is visible in the config; the catch-all '.*' still injects fixup DLLs. $ftaExeName = "$($baseId)_PsfFtaCom$($ftaArch).exe" $srcFtaPath = Join-Path $MSIXFolder.FullName $ftaSourceExe $destFtaPath = Join-Path $MSIXFolder.FullName $ftaExeName if ((Test-Path $srcFtaPath) -and -not (Test-Path $destFtaPath)) { Copy-Item $srcFtaPath $destFtaPath -Force Write-Verbose "Copied $ftaSourceExe -> $ftaExeName" } elseif (Test-Path $destFtaPath) { Write-Verbose "Renamed FtaCom exe already present: $ftaExeName" } # Determine the real executable path for verb parameter updates. # config.json.xml (written by Add-MSXIXPSFShim) holds the original path even when # the manifest Executable attribute already points to PsfLauncher. $realExe = $null $configXmlPath = Join-Path $MSIXFolder 'config.json.xml' if (Test-Path $configXmlPath) { $conxmlCheck = New-Object xml $conxmlCheck.Load($configXmlPath) $exeNode = $conxmlCheck.SelectSingleNode("//application[id='$SourceAppId']/executable") if ($null -ne $exeNode) { # InnerText contains JSON-escaped double backslashes (written by Add-MSXIXPSFShim). # Normalise back to single backslash before using in XML manifest attributes. $realExe = $exeNode.InnerText -replace '\\\\', '\' } } if (-not $realExe) { $realExe = $sourceApp.GetAttribute('Executable') } Write-Verbose "Real executable for verb parameters: $realExe" # --- Build FtaCom Application if not yet present --- $existingFtaApp = $manifest.SelectSingleNode("//ns:Application[@Id='$FtaComAppId']", $nsmgr) if ($null -ne $existingFtaApp) { Write-Verbose "Application '$FtaComAppId' already exists in AppxManifest.xml — skipped." } else { $applicationsNode = $manifest.SelectSingleNode('//ns:Applications', $nsmgr) $appEl = $manifest.CreateElement('Application', $nsBase) $null = $appEl.SetAttribute('Id', $FtaComAppId) $null = $appEl.SetAttribute('Executable', $ftaExeName) $null = $appEl.SetAttribute('EntryPoint', 'Windows.FullTrustApplication') # Copy VisualElements from source Application and set AppListEntry=none. $sourceVe = $sourceApp.SelectSingleNode('uap:VisualElements', $nsmgr) if ($null -ne $sourceVe) { $veEl = $sourceVe.CloneNode($true) $null = $veEl.SetAttribute('AppListEntry', 'none') $null = $veEl.SetAttribute('DisplayName', ($ftaExeName -replace '\.exe', '')) $null = $veEl.SetAttribute('Description', 'Launcher for FTA Shell Verbs and Dummy holder for COM') $null = $appEl.AppendChild($veEl) } else { Write-Warning "No VisualElements found on '$SourceAppId'. New Application will lack VisualElements." } # Move Extensions from source Application to this new FtaCom Application. # Extensions whose Category is in ExcludeCategories are left in the source app. $sourceExtensions = $sourceApp.SelectSingleNode('ns:Extensions', $nsmgr) if ($null -ne $sourceExtensions) { if ($ExcludeCategories.Count -gt 0) { # Separate extensions: move qualifying ones, leave excluded ones in source. $ftaExtensions = $manifest.CreateElement('Extensions', $nsBase) $toMove = @($sourceExtensions.ChildNodes | Where-Object { $ExcludeCategories -notcontains $_.GetAttribute('Category') }) $toKeep = @($sourceExtensions.ChildNodes | Where-Object { $ExcludeCategories -contains $_.GetAttribute('Category') }) foreach ($node in $toMove) { $null = $ftaExtensions.AppendChild($node) } if ($toKeep.Count -eq 0) { $null = $sourceApp.RemoveChild($sourceExtensions) } else { Write-Verbose "Kept $($toKeep.Count) extension(s) in '$SourceAppId': $($toKeep | ForEach-Object { $_.GetAttribute('Category') } | Select-Object -Unique)" } $null = $appEl.AppendChild($ftaExtensions) $sourceExtensions = $ftaExtensions Write-Verbose "Moved $($toMove.Count) extension(s) from '$SourceAppId' to '$FtaComAppId'." } else { $null = $sourceApp.RemoveChild($sourceExtensions) $null = $appEl.AppendChild($sourceExtensions) Write-Verbose "Moved Extensions from '$SourceAppId' to '$FtaComAppId'." } # Update uap3:Verb Parameters — PsfFtaCom requires the full executable path # as the first argument so it knows which process to launch for the file type. if ($realExe) { $verbs = $sourceExtensions.SelectNodes('.//uap3:Verb', $nsmgr) foreach ($verb in $verbs) { # Normalize existing Parameters: sparse packages may contain double # backslashes (JSON-escape artefact). Always write back normalized form. $params = $verb.GetAttribute('Parameters') -replace '\\\\', '\' if ($params -notmatch [regex]::Escape($realExe)) { $params = "`"$realExe`" $params" } $null = $verb.SetAttribute('Parameters', $params) } if ($verbs.Count -gt 0) { Write-Verbose "Updated $($verbs.Count) verb Parameter(s) with: $realExe" } } } else { Write-Warning "No Extensions element found under Application '$SourceAppId'." } $null = $applicationsNode.AppendChild($appEl) Write-Verbose "Added Application '$FtaComAppId' to AppxManifest.xml." } $manifest.Save($manifestPath) # --- Update config.json.xml --- $configXmlPath = Join-Path $MSIXFolder 'config.json.xml' $conxml = New-Object xml if (Test-Path $configXmlPath) { $conxml.Load($configXmlPath) } else { $conxml = [xml] '<configuration><applications></applications></configuration>' } $existingEntry = $conxml.SelectSingleNode("//application/id[text()='$FtaComAppId']") if ($null -ne $existingEntry) { Write-Verbose "Entry '$FtaComAppId' already present in config.json.xml — skipped." } else { $appRoot = $conxml.SelectSingleNode('//applications') $r = $conxml.CreateElement('application') $idEl = $conxml.CreateElement('id') $idEl.InnerText = $FtaComAppId $null = $r.AppendChild($idEl) # No <executable> for FtaCom entries — PsfFtaCom does not launch a child process. $argEl = $conxml.CreateElement('arguments') $argEl.InnerText = '' $null = $r.AppendChild($argEl) $wdEl = $conxml.CreateElement('workingDirectory') $wdEl.InnerText = '' $null = $r.AppendChild($wdEl) $pmiEl = $conxml.CreateElement('preventMultipleInstances') $pmiEl.InnerText = 'false' $null = $r.AppendChild($pmiEl) $tcEl = $conxml.CreateElement('terminateChildren') $tcEl.InnerText = $TerminateChildren.ToString().ToLower() $null = $r.AppendChild($tcEl) $hsEl = $conxml.CreateElement('hasShellVerbs') $hsEl.InnerText = 'true' $null = $r.AppendChild($hsEl) $null = $appRoot.AppendChild($r) Write-Verbose "Added entry '$FtaComAppId' to config.json.xml (hasShellVerbs=true)." } $conxml.PreserveWhiteSpace = $false $conxml.Save($configXmlPath) } } |