Public/Add-MSXIXPSFShim.ps1


function Add-MSXIXPSFShim {
<#
.SYNOPSIS
    Wires an MSIX application entry through the PSF launcher.

.DESCRIPTION
    Adds a PSF (Package Support Framework) shim to one application entry inside
    an expanded MSIX package. The function:

      1. Detects the application architecture from the PE header when
         -PSFArchitektur is not specified (uses PSFDefaultArchitecture from
         module configuration, default: Auto).
      2. Copies PsfLauncher64.exe or PsfLauncher32.exe as a uniquely named
         file following best practice: <AppId>_PsfLauncherA.exe,
         <AppId>_PsfLauncherB.exe, etc. The next available letter is found
         by counting existing *_PsfLauncher?.exe files in the package root.
      3. Renames the Application/@Id attribute in AppxManifest.xml to
         <OriginalId>PsfLauncher<Letter> (e.g. WinRARPsfLauncherA) and
         redirects Application/@Executable to the renamed launcher.
      4. Creates or updates config.json.xml so that the PSF launcher knows
         which real executable to start, using the renamed Application Id.
      5. When the active PSF framework is from Tim Mangan
         (Set-MSIXActivePSFFramework points to a TimManganPSF path), the
         enableReportError and debugLevel properties are added to the top-level
         configuration element (debugLevel from PSFTimManganDebugLevel config).

    The processes section (exclusion entries + catch-all) is managed by fixup
    functions such as Add-MSIXPSFFileRedirectionFixup and Add-MSIXPSFTracing.
    Use Set-MSIXForceletsConfiguration to control which exclusions are added.

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

.PARAMETER MISXAppID
    Application/@Id value as it appears in AppxManifest.xml.

.PARAMETER WorkingDirectory
    Optional working directory passed through to config.json.

.PARAMETER Arguments
    Optional command-line arguments passed through to config.json.

.PARAMETER PSFArchitektur
    Architecture of the PSF launcher to use: Auto, x64, or x86.
    When omitted, the module configuration value PSFDefaultArchitecture is used
    (default: Auto — the PE machine type of the executable is auto-detected).

.PARAMETER PreventMultipleInstances
    Prevents more than one instance of the application from running simultaneously.
    Tim Mangan PSF only. Always written to config.json when Tim Mangan PSF is active (default: $false).

.PARAMETER TerminateChildren
    Terminates child processes when the main application exits.
    Tim Mangan PSF only. Always written to config.json when Tim Mangan PSF is active (default: $false).

.EXAMPLE
    Add-MSXIXPSFShim -MSIXFolder "C:\MSIXTemp\WinRAR" -MISXAppID "WinRAR"

.EXAMPLE
    Add-MSXIXPSFShim -MSIXFolder "C:\MSIXTemp\App" -MISXAppID "App" `
        -PSFArchitektur x64 -WorkingDirectory "VFS\ProgramFilesX64\App" `
        -Arguments "--mode compat" -Verbose

.OUTPUTS
    System.String
    The new Application Id assigned to the shimmed entry (e.g. "WinRARPsfLauncherA").
    Capture this to pass the correct Id to subsequent cmdlets such as
    Add-MSIXAppExecutionAlias.

.NOTES
    Requires an active PSF framework set via Set-MSIXActivePSFFramework.
    https://www.nick-it.de
    Andreas Nick, 2024
#>

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

        [Parameter(Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 1)]
        [Alias('Id')]
        [String] $MISXAppID,

        [String] $WorkingDirectory = '',

        [String] $Arguments = '',

        # Overrides config.json's executable field. Use this to point at a system
        # tool (e.g. powershell.exe) when Application/@Executable is just a
        # script that needs an interpreter. The manifest still references the
        # renamed PsfLauncher; only config.json gets the override.
        [String] $LauncherExecutable = '',

        # Valid values: Auto | x64 | x86. When omitted, PSFDefaultArchitecture from module config is used.
        [ValidateSet('Auto', 'x64', 'x86')]
        [String] $PSFArchitektur,

        # Tim Mangan PSF only — always written to config.json (default: $false).
        [bool] $PreventMultipleInstances = $false,
        [bool] $TerminateChildren = $false
    )

    process {
        if ([string]::IsNullOrWhiteSpace($MISXAppID)) {
            Write-Error "-MISXAppID must not be empty or whitespace."
            return
        }

        if ($PSBoundParameters.ContainsKey('WorkingDirectory') -and $WorkingDirectory -ne '') {
            if ($WorkingDirectory -match '/') {
                Write-Warning "-WorkingDirectory '$WorkingDirectory' contains a forward slash. Use backslash for VFS paths, e.g. 'VFS\ProgramFilesX64\App'."
            }
        }

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

        Write-Verbose "Adding PSF shim for application: $MISXAppID"

        # --- Load AppxManifest and locate the application node ---
        $manifest = New-Object xml
        $manifest.Load($manifestPath)
        $ns = New-Object System.Xml.XmlNamespaceManager $manifest.NameTable
        $ns.AddNamespace('ns', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10')
        $appNode = $manifest.SelectSingleNode("//ns:Application[@Id='$MISXAppID']", $ns)

        if ($null -eq $appNode) {
            Write-Warning "Application '$MISXAppID' not found in AppxManifest.xml."
            return
        }

        $originalExecutable = $appNode.Executable

        # --- Determine architecture ---
        # If caller did not pass -PSFArchitektur, honour module config; otherwise use the explicit value.
        if ($PSBoundParameters.ContainsKey('PSFArchitektur')) {
            $resolvedArch = $PSFArchitektur
        } else {
            $resolvedArch = $Script:MSIXForceletsConfig.PSFDefaultArchitecture
        }

        if ($resolvedArch -eq 'Auto') {
            $resolvedArch = 'x64'
            $exePath = Join-Path $MSIXFolder.FullName $originalExecutable
            if (Test-Path $exePath) {
                if ((Get-MSIXAppMachineType -FilePathName $exePath) -eq 'I386') {
                    $resolvedArch = 'x86'
                }
            } else {
                Write-Warning "Executable '$originalExecutable' not found in MSIX folder, using x64."
            }
            Write-Verbose "Auto-detected architecture: $resolvedArch"
        }

        $sourceLauncher = if ($resolvedArch -eq 'x64') { 'PsfLauncher64.exe' } else { 'PsfLauncher32.exe' }

        # --- Find or assign a renamed launcher for this AppId ---
        $existingLauncher = Get-ChildItem -Path $MSIXFolder.FullName -Filter "$($MISXAppID)_PsfLauncher?.exe" -ErrorAction SilentlyContinue |
            Select-Object -First 1

        if ($null -ne $existingLauncher) {
            $launcherName    = $existingLauncher.Name
            $launcherLetter  = $existingLauncher.BaseName[-1]
            $newAppId        = $MISXAppID + 'PsfLauncher' + $launcherLetter
            Write-Verbose "Reusing existing launcher: $launcherName"
        }
        else {
            $allRenamed   = @(Get-ChildItem -Path $MSIXFolder.FullName -Filter '*_PsfLauncher?.exe' -ErrorAction SilentlyContinue)
            $nextLetter   = [char]([int][char]'A' + $allRenamed.Count)
            $launcherName = "$($MISXAppID)_PsfLauncher$($nextLetter).exe"
            $newAppId     = $MISXAppID + 'PsfLauncher' + $nextLetter

            $srcPath = Join-Path $MSIXFolder.FullName $sourceLauncher
            if (Test-Path $srcPath) {
                Copy-Item $srcPath -Destination (Join-Path $MSIXFolder.FullName $launcherName) -Force
                Write-Verbose "Copied $sourceLauncher -> $launcherName"
            }
            else {
                Write-Warning "$sourceLauncher not found in MSIX folder. The manifest will reference a missing file."
            }
        }

        # --- Update AppxManifest: rename Application Id and redirect Executable ---
        $null = $appNode.SetAttribute('Id', $newAppId)
        $appNode.Executable = $launcherName
        $manifest.Save($manifestPath)
        Write-Verbose "AppxManifest updated: Id = $newAppId, Executable = $launcherName"

        # --- Load or create 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>'
        }

        # --- Tim Mangan extras: enableReportError and debugLevel ---
        $isTimMangan = $Script:PsfBasePath -like '*TimMangan*'
        if ($isTimMangan) {
            $configRoot = $conxml.SelectSingleNode('/configuration')
            $appsEl     = $conxml.SelectSingleNode('/configuration/applications')

            $erNode = $conxml.SelectSingleNode('/configuration/enableReportError')
            if ($null -eq $erNode) {
                $erEl = $conxml.CreateElement('enableReportError')
                $erEl.InnerText = 'false'
                $configRoot.InsertBefore($erEl, $appsEl) | Out-Null
            }

            # Always update debugLevel so Set-MSIXForceletsConfiguration takes effect on rebuild
            $dlNode = $conxml.SelectSingleNode('/configuration/debugLevel')
            if ($null -eq $dlNode) {
                $dlNode = $conxml.CreateElement('debugLevel')
                $configRoot.InsertBefore($dlNode, $appsEl) | Out-Null
            }
            $dlNode.InnerText = $Script:MSIXForceletsConfig.PSFTimManganDebugLevel.ToString()

            Write-Verbose "Tim Mangan: enableReportError=false, debugLevel=$($Script:MSIXForceletsConfig.PSFTimManganDebugLevel)"
        }

        # --- Add application entry to config.json.xml if not yet present ---
        $existingEntry = $conxml.SelectSingleNode("//application/id[text()='$newAppId']")
        if ($null -eq $existingEntry) {
            Write-Verbose "Adding application entry to config.json.xml: $newAppId"
            $appRoot = $conxml.SelectSingleNode('//applications')

            $r = $conxml.CreateElement('application')

            $idEl = $conxml.CreateElement('id')
            $idEl.InnerText = $newAppId
            $r.AppendChild($idEl) | Out-Null

            # Pre-escape inner double-quotes to \" so Convert-MSIXPSFXML2JSON's
            # XSLT->JSON pipeline produces valid JSON (the XSLT does not escape
            # values; backslashes are normalised post-transform but quotes are not).
            $exeEl = $conxml.CreateElement('executable')
            $exeForConfig = if ([string]::IsNullOrEmpty($LauncherExecutable)) { $originalExecutable } else { $LauncherExecutable }
            $exeEl.InnerText = $exeForConfig.Replace('"', '\"')
            $r.AppendChild($exeEl) | Out-Null
            if ($LauncherExecutable -ne '') {
                Write-Verbose "config.json executable overridden: $LauncherExecutable"
            }

            $argEl = $conxml.CreateElement('arguments')
            $argEl.InnerText = $Arguments.Replace('"', '\"')
            $r.AppendChild($argEl) | Out-Null

            $wdEl = $conxml.CreateElement('workingDirectory')
            $wdEl.InnerText = $WorkingDirectory.Replace('"', '\"')
            $r.AppendChild($wdEl) | Out-Null

            if ($isTimMangan) {
                $pmiEl = $conxml.CreateElement('preventMultipleInstances')
                $pmiEl.InnerText = $PreventMultipleInstances.ToString().ToLower()
                $r.AppendChild($pmiEl) | Out-Null

                $tcEl = $conxml.CreateElement('terminateChildren')
                $tcEl.InnerText = $TerminateChildren.ToString().ToLower()
                $r.AppendChild($tcEl) | Out-Null
            }

            $appRoot.AppendChild($r) | Out-Null
        }
        else {
            Write-Verbose "Application entry '$newAppId' already present in config.json.xml — skipped."
        }

        $conxml.PreserveWhiteSpace = $false
        $conxml.Save($configXmlPath)
        Write-Output $newAppId
    }
}