MSIX.PSF.ps1
|
# Registry of known fixups: name -> { Dll (generic), HasBitSuffix } # Bit-suffixed DLLs are stored as FileRedirectionFixup32.dll / 64.dll on disk # but referenced as FileRedirectionFixup.dll inside the package/config.json. $script:PsfFixupRegistry = [ordered]@{ FileRedirectionFixup = @{ HasBitSuffix = $true } MFRFixup = @{ HasBitSuffix = $true } # TMurgent fork only RegLegacyFixups = @{ HasBitSuffix = $true } EnvVarFixup = @{ HasBitSuffix = $true } DynamicLibraryFixup = @{ HasBitSuffix = $true } TraceFixup = @{ HasBitSuffix = $true } WaitForDebuggerFixup = @{ HasBitSuffix = $true } KernelTraceControl = @{ HasBitSuffix = $false } } #region --- Config builders ------------------------------------------------- function New-MsixPsfFileRedirectionConfig { <# .SYNOPSIS Builds a FileRedirectionFixup config hashtable for use with Add-MsixPsfV2. .DESCRIPTION Redirects file I/O matching one or more regex patterns under -Base into a writable, per-user location at runtime. Use this when an app writes log files, temp data, or settings to a folder that MSIX containerises as read-only. .PARAMETER Base Folder (relative to the chosen path type) whose contents are subject to redirection. Use 'logs' for VFS\ProgramFilesX64\<App>\logs etc. .PARAMETER Patterns One or more regex strings matched against filenames in -Base. .PARAMETER PathType How -Base is anchored: packageRelative (default), packageDriveRelative, or knownFolderRelative. .OUTPUTS [hashtable] suitable for Add-MsixPsfV2 -Fixups. .EXAMPLE # Chain into Add-MsixPsfV2 $fixup = New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log','.*\.tmp' Add-MsixPsfV2 -PackagePath app.msix -Fixups @($fixup) ` -Pfx cert.pfx -PfxPassword $pw #> [OutputType([hashtable])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [Parameter(Mandatory)] [string]$Base, [Parameter(Mandatory)] [string[]]$Patterns, [ValidateSet('packageRelative', 'packageDriveRelative', 'knownFolderRelative')] [string]$PathType = 'packageRelative' ) return @{ dll = 'FileRedirectionFixup.dll' config = @{ redirectedPaths = @{ $PathType = @(@{ base = $Base; patterns = [array]$Patterns }) } } } } function New-MsixPsfRegLegacyConfig { <# .SYNOPSIS Builds a RegLegacyFixups config hashtable. Supports all four types documented by the TMurgent PSF fork: - ModifyKeyAccess downgrade FULL/RW masks (default) - FakeDelete deny "key not found" for legacy uninstallers - DeletionMarker suppress reads of explicitly-deleted keys - Hklm2Hkcu redirect HKLM writes to HKCU (per-user) .PARAMETER Hive Registry hive the rule applies to: HKCU or HKLM. .PARAMETER Patterns Key-path regex patterns (relative to -Hive) the rule matches. .PARAMETER Type Behaviour: ModifyKeyAccess (default), FakeDelete, DeletionMarker, or Hklm2Hkcu. .PARAMETER Access Required when -Type is ModifyKeyAccess. Downgrade mapping like Full2MaxAllowed, Full2RW, Full2R, RW2R, RW2MaxAllowed, or NotAllowed. .OUTPUTS [hashtable] suitable for Add-MsixPsfV2 -Fixups. .EXAMPLE # Modify access mask New-MsixPsfRegLegacyConfig -Type ModifyKeyAccess -Hive HKCU ` -Access Full2MaxAllowed -Patterns 'SOFTWARE\App\*' .EXAMPLE # Pretend the key doesn't exist (legacy uninstaller probes) New-MsixPsfRegLegacyConfig -Type FakeDelete -Hive HKLM ` -Patterns 'SOFTWARE\App\Uninstall' .EXAMPLE # Send legacy HKLM writes to HKCU instead, then chain into Add-MsixPsfV2 $fixup = New-MsixPsfRegLegacyConfig -Type Hklm2Hkcu -Hive HKLM ` -Patterns 'SOFTWARE\App\*' Add-MsixPsfV2 -PackagePath app.msix -Fixups @($fixup) ` -Pfx cert.pfx -PfxPassword $pw #> [OutputType([hashtable])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [Parameter(Mandatory)] [ValidateSet('HKCU', 'HKLM')] [string]$Hive, [Parameter(Mandatory)] [string[]]$Patterns, [ValidateSet('ModifyKeyAccess', 'FakeDelete', 'DeletionMarker', 'Hklm2Hkcu')] [string]$Type = 'ModifyKeyAccess', # Required only for ModifyKeyAccess [ValidateSet('Full2RW','Full2R','Full2MaxAllowed','RW2R','RW2MaxAllowed','NotAllowed')] [string]$Access ) if ($Type -eq 'ModifyKeyAccess' -and -not $Access) { throw '-Access is required when -Type ModifyKeyAccess. Try Full2MaxAllowed.' } $remediation = [ordered]@{ hive = $Hive patterns = [array]$Patterns } if ($Type -eq 'ModifyKeyAccess') { $remediation['access'] = $Access } return @{ dll = 'RegLegacyFixups.dll' config = @{ type = $Type remediation = @($remediation) } } } function New-MsixPsfEnvVarConfig { <# .SYNOPSIS Builds an EnvVarFixup config hashtable for use with Add-MsixPsfV2. .DESCRIPTION Injects environment variables into the target process at startup without modifying the user or machine environment. Use for apps that need a configuration var set inside the package container only. .PARAMETER Variables Hashtable of name/value pairs to set for the target process. .OUTPUTS [hashtable] suitable for Add-MsixPsfV2 -Fixups. .EXAMPLE $env = New-MsixPsfEnvVarConfig -Variables @{ MY_VAR = 'value'; ANOTHER = 'val2' } Add-MsixPsfV2 -PackagePath app.msix -Fixups @($env) ` -Pfx cert.pfx -PfxPassword $pw #> [OutputType([hashtable])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [Parameter(Mandatory)] [hashtable]$Variables ) return @{ dll = 'EnvVarFixup.dll' config = @{ envVars = $Variables } } } function New-MsixPsfDynamicLibraryConfig { <# .SYNOPSIS Builds a DynamicLibraryFixup config hashtable. Maps DLL imports to package-relative replacement DLLs at runtime. .DESCRIPTION Use when an app imports a DLL by name and the OS loader can't find it (because it's vendored at a non-standard relative path). Each entry names a DLL and where the runtime should redirect to. For the "just add a search path" case, prefer the manifest-only fix: Add-MsixLoaderSearchPathOverride. .PARAMETER Mappings Array of hashtables: @{ name='foo.dll'; filepath='VFS/ProgramFilesX64/App/lib/foo.dll' } .EXAMPLE $dyn = New-MsixPsfDynamicLibraryConfig -Mappings @( @{ name='liba.dll'; filepath='VFS/ProgramFilesX64/App/lib/liba.dll' } @{ name='libb.dll'; filepath='VFS/ProgramFilesX64/App/lib/libb.dll' } ) Add-MsixPsfV2 -PackagePath app.msix -Fixups @($dyn) -Pfx cert.pfx -PfxPassword 'P@ss' #> [OutputType([hashtable])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [Parameter(Mandatory)] [hashtable[]]$Mappings ) foreach ($m in $Mappings) { if (-not $m.name) { throw "Each mapping needs 'name'." } if (-not $m.filepath) { throw "Each mapping needs 'filepath'." } } return @{ dll = 'DynamicLibraryFixup.dll' config = @{ relativePaths = @($Mappings | ForEach-Object { [ordered]@{ name = $_.name; filepath = $_.filepath } }) } } } function New-MsixPsfWaitForDebuggerConfig { <# .SYNOPSIS Builds a WaitForDebuggerFixup config hashtable. .DESCRIPTION At process startup the fixup blocks until a debugger attaches — invaluable when investigating apps that crash before you can attach. Strip this fixup before shipping a production package; it's a diagnostic helper only. .PARAMETER Processes Optional array of process names (without .exe) the fixup should block on. If omitted, the fixup applies to whatever process loads it. .EXAMPLE $wait = New-MsixPsfWaitForDebuggerConfig Add-MsixPsfV2 -PackagePath app.msix -Fixups @($wait) -Pfx cert.pfx -PfxPassword 'P@ss' #> [OutputType([hashtable])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [string[]]$Processes ) $cfg = @{} if ($Processes) { $cfg['processes'] = @($Processes | ForEach-Object { @{ executable = $_ } }) } return @{ dll = 'WaitForDebuggerFixup.dll' config = $cfg } } function New-MsixPsfArgument { <# .SYNOPSIS Returns a hashtable describing per-application command-line arguments and (optionally) a working directory. Pass to Add-MsixPsfV2 -AppOptions. Maps to the top-level `applications[].arguments` field documented at https://learn.microsoft.com/en-us/windows/msix/psf/psf-launch-apps-with-parameters .PARAMETER AppId Application Id (as in AppxManifest.xml) the arguments apply to. .PARAMETER Arguments Command-line argument string passed to the application. .PARAMETER WorkingDirectory Optional package-relative working directory. .OUTPUTS [hashtable] suitable for Add-MsixPsfV2 -AppOptions. .EXAMPLE # Pass through to Add-MsixPsfV2 via -AppOptions $opt = New-MsixPsfArgument -AppId 'App' -Arguments '/bootfromsettingshortcut' $fixup = New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log' Add-MsixPsfV2 -PackagePath app.msix -Fixups @($fixup) ` -AppOptions @($opt) -Pfx cert.pfx -PfxPassword $pw #> [OutputType([hashtable])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [Parameter(Mandatory)] [string]$AppId, [string]$Arguments, [string]$WorkingDirectory ) $h = @{ id = $AppId } if ($Arguments) { $h['arguments'] = $Arguments } if ($WorkingDirectory) { $h['workingDirectory'] = $WorkingDirectory } return $h } function New-MsixPsfStartScriptConfig { <# .SYNOPSIS Builds a PSF startScript / endScript block (per-application). .DESCRIPTION Used by PSFLauncher to run a PowerShell script before (startScript) or after (endScript) the target application. Requires StartingScriptWrapper.ps1 from PSFBinaries.zip alongside the script in the package. See https://learn.microsoft.com/en-us/windows/msix/psf/create-shortcut-with-script-package-support-framework .PARAMETER AppId Application Id this script attaches to. .PARAMETER ScriptPath Package-relative path to the .ps1 script (e.g. "ContosoExpenses\createshortcut.ps1"). .PARAMETER ScriptArguments Optional argument string passed to the script. .PARAMETER RunInVirtualEnvironment If true, the script runs inside the package container; otherwise on the host. .PARAMETER RunOnce If true, the script runs only the first time the application is launched. .PARAMETER ShowWindow If true, the PowerShell host window is visible. .PARAMETER WaitForScriptToFinish If true, the application start blocks until the script exits. .PARAMETER StopOnScriptError If true, application launch is aborted when the script returns non-zero. .PARAMETER Timeout Seconds to wait for the script before giving up. 0 = no timeout. .PARAMETER EndScript If specified, returned as endScript instead of startScript. .OUTPUTS [hashtable] suitable for Add-MsixPsfV2 -AppOptions. .EXAMPLE # Pre-launch shortcut creation, blocking until finished $opt = New-MsixPsfStartScriptConfig -AppId 'App' ` -ScriptPath 'createshortcut.ps1' -RunOnce -WaitForScriptToFinish Add-MsixPsfV2 -PackagePath app.msix ` -Fixups @( New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log' ) ` -AppOptions @($opt) ` -AdditionalFiles @('C:\src\createshortcut.ps1') ` -Pfx cert.pfx -PfxPassword $pw .EXAMPLE # Post-exit cleanup New-MsixPsfStartScriptConfig -AppId 'App' -ScriptPath 'cleanup.ps1' -EndScript #> [OutputType([hashtable])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [Parameter(Mandatory)] [string]$AppId, [Parameter(Mandatory)] [string]$ScriptPath, [string]$ScriptArguments, [switch]$RunInVirtualEnvironment, [switch]$RunOnce, [switch]$ShowWindow, [switch]$WaitForScriptToFinish, [switch]$StopOnScriptError, [int]$Timeout = 0, [switch]$EndScript ) $script = [ordered]@{ scriptPath = $ScriptPath runInVirtualEnvironment = [bool]$RunInVirtualEnvironment runOnce = [bool]$RunOnce showWindow = [bool]$ShowWindow waitForScriptToFinish = [bool]$WaitForScriptToFinish stopOnScriptError = [bool]$StopOnScriptError } if ($ScriptArguments) { $script['scriptArguments'] = $ScriptArguments } if ($Timeout -gt 0) { $script['timeout'] = $Timeout } return @{ appId = $AppId kind = if ($EndScript) { 'endScript' } else { 'startScript' } block = $script } } function New-MsixPsfTraceConfig { <# .SYNOPSIS Builds a TraceFixup config hashtable for use with Add-MsixPsfV2. .DESCRIPTION TraceFixup logs filesystem and registry calls made by the target process, classified by failure mode. Pair with DebugView (or equivalent) to capture the trace stream while reproducing an issue. .PARAMETER FilesystemLevel How much filesystem activity to log: allFailures, unexpectedFailures (default), or ignore. .PARAMETER RegistryLevel How much registry activity to log: allFailures, unexpectedFailures, or ignore (default). .OUTPUTS [hashtable] suitable for Add-MsixPsfV2 -Fixups. .EXAMPLE $trace = New-MsixPsfTraceConfig -FilesystemLevel allFailures Add-MsixPsfV2 -PackagePath app.msix -Fixups @($trace) ` -Pfx cert.pfx -PfxPassword $pw #> [OutputType([hashtable])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [ValidateSet('allFailures', 'unexpectedFailures', 'ignore')] [string]$FilesystemLevel = 'unexpectedFailures', [ValidateSet('allFailures', 'unexpectedFailures', 'ignore')] [string]$RegistryLevel = 'ignore' ) return @{ dll = 'TraceFixup.dll' config = @{ traceLevels = @{ filesystem = $FilesystemLevel registry = $RegistryLevel } } } } #endregion #region --- Config.json generation ----------------------------------------- function New-MsixPsfConfig { <# .SYNOPSIS Generates a complete PSF config.json string from a manifest, fixups, and optional per-application options (arguments, workingDirectory, startScript, endScript). .PARAMETER AppOptions Hashtables produced by New-MsixPsfArgument and New-MsixPsfStartScriptConfig. Each is merged into the matching application entry by AppId. .OUTPUTS [string] JSON #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [Parameter(Mandatory)] [xml]$Manifest, [Parameter(Mandatory)] [hashtable[]]$Fixups, [string]$WorkingDirectory, [hashtable[]]$AppOptions ) $apps = @($Manifest.Package.Applications.Application) # Index AppOptions by app id and kind so we can merge into entries $argsById = @{} $startById = @{} $endById = @{} foreach ($opt in @($AppOptions)) { if (-not $opt) { continue } if ($opt.kind -eq 'startScript') { $startById[$opt.appId] = $opt.block; continue } if ($opt.kind -eq 'endScript') { $endById[$opt.appId] = $opt.block; continue } # Otherwise treat as arguments hashtable from New-MsixPsfArgument if ($opt.id) { $argsById[$opt.id] = $opt } } $appEntries = foreach ($app in $apps) { $entry = [ordered]@{ id = $app.Id executable = $app.GetAttribute('Executable').Replace('\', '/') } if ($WorkingDirectory) { $entry['workingDirectory'] = $WorkingDirectory } if ($argsById.ContainsKey($app.Id)) { $a = $argsById[$app.Id] if ($a.arguments) { $entry['arguments'] = $a.arguments } if ($a.workingDirectory) { $entry['workingDirectory'] = $a.workingDirectory } } if ($startById.ContainsKey($app.Id)) { $entry['startScript'] = $startById[$app.Id] } if ($endById.ContainsKey($app.Id)) { $entry['endScript'] = $endById[$app.Id] } $entry } # One process block per application, all sharing the same fixup set $processEntries = foreach ($app in $apps) { $exeName = $app.GetAttribute('Executable').Split('\')[-1] -replace '\.exe$', '' [ordered]@{ executable = $exeName fixups = [array]$Fixups } } return [ordered]@{ applications = [array]$appEntries processes = [array]$processEntries } | ConvertTo-Json -Depth 15 } #endregion #region --- PSF injection --------------------------------------------------- function Add-MsixPsfV2 { <# .SYNOPSIS Injects the Package Support Framework into an MSIX package. .DESCRIPTION Unpacks the MSIX to an isolated workspace, copies PSF runtime files, generates a valid config.json, updates the AppxManifest to point each Application at the correct PsfLauncher, repacks, and re-signs. .PARAMETER PackagePath Path to the .msix file to modify (modified in-place). .PARAMETER Fixups One or more fixup config hashtables from New-MsixPsf*Config helpers. .PARAMETER PsfSourcePath Override the folder containing PSF binaries (PsfLauncher*.exe, etc.). Defaults to the 'psf' subfolder under the module tools root. .PARAMETER WorkingDirectory Optional package-relative working directory written to config.json. .PARAMETER AppOptions Hashtables produced by New-MsixPsfArgument or New-MsixPsfStartScriptConfig. Merged into the matching application entry in config.json by AppId. .PARAMETER AdditionalFiles Extra files to copy into the package's app folder before repack (e.g. a .ps1 referenced by a startScript, a .lnk, icon files). .PARAMETER OutputPath Write the repacked package here instead of overwriting -PackagePath. .PARAMETER SkipSigning Skip the final signing step. Use when chaining multiple PSF / manifest mutations and signing only at the very end with Invoke-MsixSigning. Alias: -NoSign. .PARAMETER Pfx Path to PFX certificate for signing. Omit to use the machine store. .PARAMETER PfxPassword SecureString password for the PFX file (required when -Pfx is specified). .EXAMPLE # File-redirection fixup: redirect log writes to a package-relative folder $fixup = New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log' Add-MsixPsfV2 -PackagePath app.msix -Fixups @($fixup) ` -Pfx cert.pfx -PfxPassword $pw .EXAMPLE # Chain multiple typed builders, stack fixups, sign once at the end $fr = New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log' $env = New-MsixPsfEnvVarConfig -Variables @{ MY_VAR = 'value' } $wait = New-MsixPsfWaitForDebuggerConfig Add-MsixPsfV2 -PackagePath app.msix -Fixups @($fr, $env, $wait) ` -Pfx cert.pfx -PfxPassword $pw .EXAMPLE # Start-script flow: copy the script in via -AdditionalFiles and # bind it through New-MsixPsfStartScriptConfig $script = New-MsixPsfStartScriptConfig -AppId 'App' ` -ScriptPath 'createshortcut.ps1' -RunOnce -WaitForScriptToFinish $fixup = New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log' Add-MsixPsfV2 -PackagePath app.msix -Fixups @($fixup) ` -AppOptions @($script) ` -AdditionalFiles @('C:\src\createshortcut.ps1') ` -Pfx cert.pfx -PfxPassword $pw .EXAMPLE # Stage 1 of a chained mutation: skip signing now, sign at the end Add-MsixPsfV2 -PackagePath app.msix -Fixups @($fixup) -SkipSigning # ... more manifest edits ... Invoke-MsixSigning -PackagePath app.msix -Pfx cert.pfx -PfxPassword $pw .NOTES Idempotent: re-running with the same fixup set merges new fixups into the existing config.json by DLL name rather than rewriting applications[] to reference PsfLauncher recursively. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [hashtable[]]$Fixups, [string]$PsfSourcePath, [string]$WorkingDirectory, [hashtable[]]$AppOptions, [string[]]$AdditionalFiles, # extra files to copy into the package (e.g. a startScript .ps1) # Output path for dry-run mode. If set, the modified package is written # there instead of overwriting -PackagePath. Useful for staged pipelines. [string]$OutputPath, # If $true, the repacked output is NOT signed. Use this when chaining # multiple PSF / manifest mutations and signing only at the very end. [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword ) $toolsRoot = Get-MsixToolsRoot if (-not $PsfSourcePath) { $PsfSourcePath = Join-Path $toolsRoot 'psf' } $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' $null = Test-MsixManifest "$workspace\AppxManifest.xml" [xml]$manifest = Get-MsixManifest "$workspace\AppxManifest.xml" $apps = @(Get-MsixManifestApplication $manifest) # Determine bitness from first app's executable path $firstExe = $apps[0].GetAttribute('Executable') if (-not $firstExe) { # Fallback: scan workspace for the first .exe that isn't a PSF launcher $firstExe = Get-ChildItem $workspace -Recurse -Filter '*.exe' -ErrorAction SilentlyContinue | Where-Object { $_.Name -notmatch '^Psf' } | Select-Object -First 1 | ForEach-Object { $_.FullName.Substring($workspace.Length + 1) } Write-MsixLog Warning "Application Executable attribute was empty; resolved via scan: $firstExe" } $is64 = $firstExe -match 'x64|ProgramFilesX64' $bitSuffix = if ($is64) { '64' } else { '32' } # Resolve the subfolder that contains the first app's executable $relDir = if ($firstExe -and $firstExe.Contains('\')) { $firstExe.Substring(0, $firstExe.LastIndexOf('\')) } else { '' } $appFolder = if ($relDir) { Join-Path $workspace $relDir } else { $workspace } # --- config.json (placed alongside the app executable) --- $configPath = Join-Path $appFolder 'config.json' # Detect re-injection: manifest already points to a PsfLauncher, which means # a previous Add-MsixPsfV2 run already set up the launcher → real executable # mapping. Re-generating config from the manifest would make applications[] and # processes[] reference PsfLauncher instead of the original exe. Merge instead. $psfLauncherRx = [regex]'[/\\]PsfLauncher\d+(?:_\d+)?\.exe$' $isPsfPresent = $apps | Where-Object { $psfLauncherRx.IsMatch($_.GetAttribute('Executable')) } | Select-Object -First 1 if ($isPsfPresent -and (Test-Path $configPath)) { # Merge mode: read existing config, append new fixups to each process entry $existingCfg = Get-Content $configPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop $existingApps = @($existingCfg.applications) # Build a map of process entries keyed by executable name $procMap = [ordered]@{} foreach ($p in @($existingCfg.processes)) { $procFixups = [System.Collections.Generic.List[object]]::new() foreach ($fixup in @($p.fixups)) { $procFixups.Add($fixup) } $procMap[$p.executable] = $procFixups } # For each application in the existing config, ensure a process entry exists # and append any new fixups that are not already present (deduplicate by dll name) foreach ($appEntry in $existingApps) { $exeName = ($appEntry.executable -split '[/\\]')[-1] -replace '\.exe$', '' if (-not $procMap.ContainsKey($exeName)) { $procMap[$exeName] = [System.Collections.Generic.List[object]]::new() } $existingDlls = @($procMap[$exeName] | ForEach-Object { $_.dll }) foreach ($fixup in $Fixups) { if ($fixup.dll -notin $existingDlls) { $procMap[$exeName].Add($fixup) } } } $mergedProcs = @($procMap.GetEnumerator() | ForEach-Object { [ordered]@{ executable = $_.Key; fixups = [array]$_.Value } }) $mergedJson = [ordered]@{ applications = $existingApps processes = $mergedProcs } | ConvertTo-Json -Depth 15 if ($PSCmdlet.ShouldProcess($configPath, 'Merge PSF config.json')) { $mergedJson | Out-File $configPath -Encoding utf8 -Force Write-MsixLog Info "PSF config merged (fixup(s) added to existing config): $configPath" } } else { # Fresh injection: generate config.json from manifest $psfJson = New-MsixPsfConfig -Manifest $manifest ` -Fixups $Fixups ` -WorkingDirectory $WorkingDirectory ` -AppOptions $AppOptions if ($PSCmdlet.ShouldProcess($configPath, 'Write PSF config.json')) { $psfJson | Out-File $configPath -Encoding utf8 -Force Write-MsixLog Info "PSF config written: $configPath" } } Test-MsixPsfConfig $configPath # --- Copy PSF runtime binaries --- $runtimeFiles = @( "PsfLauncher$bitSuffix.exe", "PsfRuntime$bitSuffix.dll", "PsfRunDll$bitSuffix.exe" ) foreach ($f in $runtimeFiles) { $src = Join-Path $PsfSourcePath $f if (Test-Path $src) { if ($PSCmdlet.ShouldProcess($src, "Copy PSF runtime")) { Copy-Item $src $appFolder -Force Write-MsixLog Debug "Copied: $f" } } else { Write-MsixLog Warning "PSF runtime not found: $src" } } # --- Copy fixup DLLs (strip bit suffix for package naming) --- foreach ($fixup in $Fixups) { $dllName = $fixup.dll # e.g. FileRedirectionFixup.dll $dllBase = $dllName -replace '\.dll$', '' # e.g. FileRedirectionFixup $meta = $script:PsfFixupRegistry[$dllBase] if ($meta -and $meta.HasBitSuffix) { $src = Join-Path $PsfSourcePath "${dllBase}${bitSuffix}.dll" } else { $src = Join-Path $PsfSourcePath $dllName } if (Test-Path $src) { if ($PSCmdlet.ShouldProcess($src, "Copy fixup DLL")) { Copy-Item $src (Join-Path $appFolder $dllName) -Force Write-MsixLog Debug "Fixup copied: $dllName" } } else { Write-MsixLog Warning "Fixup DLL not found: $src" } } # --- Optional extra files (e.g. start scripts, .lnk, icons) --- if ($AdditionalFiles) { foreach ($extra in $AdditionalFiles) { if (Test-Path $extra) { Copy-Item $extra $appFolder -Force Write-MsixLog Debug "Extra file copied: $extra" } else { Write-MsixLog Warning "Additional file not found: $extra" } } } # Always ship StartingScriptWrapper.ps1 if any app uses startScript/endScript $needsWrapper = @($AppOptions) | Where-Object { $_.kind -in 'startScript','endScript' } if ($needsWrapper) { $wrapper = Join-Path $PsfSourcePath 'StartingScriptWrapper.ps1' if (Test-Path $wrapper) { Copy-Item $wrapper $appFolder -Force Write-MsixLog Debug "StartingScriptWrapper.ps1 copied" } else { Write-MsixLog Warning "StartingScriptWrapper.ps1 not found in $PsfSourcePath" } } # --- Update manifest: point each Application at PsfLauncher --- # Skip when PSF is already present — launcher is already wired in the manifest. if ($isPsfPresent) { Write-MsixLog Info 'PSF already present; skipping manifest launcher update.' } else { $i = 0 foreach ($app in $apps) { $i++ # App 1 → PsfLauncher64.exe, App 2+ → PsfLauncher64_2.exe, etc. if ($i -eq 1) { $launcherName = "PsfLauncher$bitSuffix.exe" } else { $launcherName = "PsfLauncher${bitSuffix}_$i.exe" Copy-Item (Join-Path $PsfSourcePath "PsfLauncher$bitSuffix.exe") ` (Join-Path $appFolder $launcherName) -Force } $oldExe = $app.GetAttribute('Executable') $oldLeaf = $oldExe.Split('\')[-1] $newExe = $oldExe -replace [regex]::Escape($oldLeaf), $launcherName $app.SetAttribute('Executable', $newExe) # Note: we intentionally do NOT touch any existing # windows.appExecutionAlias extension here. The alias # inherits its launch target from the parent Application's # Executable attribute when the Extension itself omits # Executable/EntryPoint. Setting Executable on the alias # Extension without also setting EntryPoint is a schema # violation ("The attribute EntryPoint must be specified # if the attribute Executable on the Extension element is # specified"). An earlier sync block set only Executable # and produced exactly that MakeAppx error every time PSF # ran on a package that already had an alias declared # (e.g. one added by Invoke-MsixAutoFixFromAnalysis's # AppExecutionAlias stage). } if ($PSCmdlet.ShouldProcess("$workspace\AppxManifest.xml", 'Save updated manifest')) { Save-MsixManifest $manifest "$workspace\AppxManifest.xml" } } # --- Repack --- $repackTarget = if ($OutputPath) { $OutputPath } else { $fileinfo.FullName } Write-MsixLog Info "Repacking: $repackTarget" $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack', '/p', $repackTarget, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx pack' if ($SkipSigning) { Write-MsixLog Info "Skipping signing (use Invoke-MsixSigning later, or chain another PSF call)." } else { Invoke-MsixSigning -PackagePath $repackTarget -Pfx $Pfx -PfxPassword $PfxPassword } } finally { Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue } } #endregion # Backward-compatible plural aliases Set-Alias New-MsixPsfArguments New-MsixPsfArgument |