MSIX.PackageMutators.ps1
|
# ============================================================================= # MSIX package mutators (split from MSIX.Heuristics.ps1 in issue #38) # ----------------------------------------------------------------------------- # Functions that modify a .msix in place via _MsixMutatePackage: # Add-MsixCapability, Remove-Msix*Artifact, Add-MsixSplashScreen, # Update-MsixPackageVersion. Each wraps the unpack -> mutate -> # atomic-repack-sign-move helper defined in MSIX.Pipeline.ps1. # Scanners (Get-Msix*) live in MSIX.Scanners.ps1. # ============================================================================= #region Capabilities -------------------------------------------------------- # Common rescap / standard capabilities admins frequently add to packaged apps $script:KnownCapabilities = [ordered]@{ # rescap — <rescap:Capability> 'runFullTrust' = 'rescap' 'allowElevation' = 'rescap' 'unvirtualizedResources' = 'rescap' 'broadFileSystemAccess' = 'rescap' 'extendedExecutionUnconstrained' = 'rescap' # standard — plain <Capability> (schema enum: only these 5) 'internetClient' = 'standard' 'internetClientServer' = 'standard' 'privateNetworkClientServer' = 'standard' 'codeGeneration' = 'standard' 'allJoyn' = 'standard' # uap — <uap:Capability> 'documentsLibrary' = 'uap' 'picturesLibrary' = 'uap' 'videosLibrary' = 'uap' 'musicLibrary' = 'uap' 'removableStorage' = 'uap' 'enterpriseAuthentication' = 'uap' 'sharedUserCertificates' = 'uap' 'userAccountInformation' = 'uap' 'objects3D' = 'uap' 'voipCall' = 'uap' 'chat' = 'uap' 'remoteSystem' = 'uap' } function Get-MsixKnownCapability { <# .SYNOPSIS Returns the capability table this module knows about, with the namespace prefix each one belongs in. .DESCRIPTION Read-only enumeration of the capabilities Add-MsixCapability can resolve to a namespace without an explicit -Namespace override. Pipe to Where-Object Namespace -eq 'rescap' to filter by class. .EXAMPLE # Show all rescap capabilities the module recognises Get-MsixKnownCapability | Where-Object Namespace -eq 'rescap' .OUTPUTS [pscustomobject] one per known capability: Name, Namespace. #> foreach ($k in $script:KnownCapabilities.Keys) { [pscustomobject]@{ Name = $k Namespace = $script:KnownCapabilities[$k] } } } function Add-MsixCapability { <# .SYNOPSIS Adds one or more capabilities (standard or rescap) to a package's AppxManifest.xml. Idempotent. Repacks + signs unless -SkipSigning. .DESCRIPTION For each name in -Names, the namespace is resolved against the module's known-capabilities table (Get-MsixKnownCapability) and the appropriate `<Capability>` / `<uap:Capability>` / `<rescap:Capability>` element is added under `<Package><Capabilities>`. Adds the namespace declaration when needed. Idempotency: existing entries with the same Name attribute are skipped regardless of prefix, so chained autofix stages don't duplicate them. Unknown capability names emit a warning and are written as plain `<Capability>` (standard namespace). To declare capabilities the lookup table doesn't recognise yet, supply -Namespace explicitly so the correct prefix is used. .PARAMETER PackagePath .msix to modify. .PARAMETER Names Capability names. Looked up against the registry — anything unknown gets a warning and is treated as standard unless -Namespace is set. .PARAMETER Namespace Optional namespace override applied to every name in -Names. Use this to declare capabilities the module's known-capabilities table doesn't recognise yet (without editing MSIX.Heuristics.ps1#KnownCapabilities). One of: 'standard', 'uap', 'uap2', 'uap3', 'uap4', 'uap5', 'uap6', 'uap7', 'uap8', 'uap10', 'rescap'. .PARAMETER OutputPath If set, write the repacked package here instead of overwriting -PackagePath. .PARAMETER SkipSigning Skip the signing pass. Alias: -NoSign. .PARAMETER Pfx Signing certificate (.pfx) path. .PARAMETER PfxPassword SecureString password for the .pfx. .PARAMETER UnsignedOutputPath If signing fails, preserve the unsigned scratch package at this path for inspection. The user's -PackagePath is left byte-equal to before the call in this scenario. .EXAMPLE # Add runFullTrust and internetClient (idempotent — safe to re-run) Add-MsixCapability -PackagePath app.msix ` -Names runFullTrust,internetClient -SkipSigning .EXAMPLE # Declare a capability the lookup table doesn't know yet Add-MsixCapability -PackagePath app.msix ` -Names 'previewStore' -Namespace uap8 -SkipSigning .EXAMPLE # Typical Invoke-MsixAutoFix integration: chained via -Capabilities Invoke-MsixAutoFix -PackagePath app.msix ` -Capabilities runFullTrust,internetClient ` -Pfx cert.pfx -PfxPassword $pw #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '', Justification = 'ShouldProcess is invoked inside _MsixMutatePackage; PSSA cannot trace it through the scriptblock dispatch (issue #37).')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Names', Justification = 'Captured by the -Mutator scriptblock via GetNewClosure().')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Namespace', Justification = 'Captured by the -Mutator scriptblock via GetNewClosure().')] [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [string[]]$Names, [ValidateSet('standard','uap','uap2','uap3','uap4','uap5','uap6','uap7','uap8','uap10','rescap')] [string]$Namespace, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) $null = _MsixMutatePackage -PackagePath $PackagePath -Operation 'cap' ` -OutputPath $OutputPath -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -NoChangeMessage 'No capabilities added.' ` -Mutator { param($workspace) $null = Test-MsixManifest "$workspace\AppxManifest.xml" [xml]$manifest = Get-MsixManifest "$workspace\AppxManifest.xml" $caps = $manifest.Package.Capabilities if (-not $caps) { $caps = $manifest.CreateElement('Capabilities', $manifest.Package.NamespaceURI) $null = $manifest.Package.AppendChild($caps) } $added = @() foreach ($name in $Names) { # Explicit -Namespace overrides the lookup table; otherwise use it. $ns = if ($Namespace) { $Namespace } else { $script:KnownCapabilities[$name] } # Idempotency: match by LocalName + Name attribute regardless of prefix $existing = $caps.ChildNodes | Where-Object { ($_.LocalName -eq 'Capability') -and ($_.'Name' -eq $name) } if ($existing) { Write-MsixLog Info "Capability already present: $name" continue } if ($ns -and $ns -ne 'standard') { Add-MsixManifestNamespace $manifest $ns $nsUri = Get-MsixManifestNamespaceUri $ns $node = $manifest.CreateElement("${ns}:Capability", $nsUri) } else { if (-not $ns) { Write-MsixLog Warning "Capability '$name' is not in the known-capabilities table (MSIX.Heuristics.ps1#KnownCapabilities). Creating a plain <Capability> element (standard namespace). If this is a uap/rescap capability, the install may fail at deployment time — verify against https://learn.microsoft.com/en-us/windows/uwp/packaging/app-capability-declarations and either add it to the lookup table or pass -Namespace explicitly." } $node = $manifest.CreateElement('Capability', $manifest.Package.NamespaceURI) } $node.SetAttribute('Name', $name) $null = $caps.AppendChild($node) Write-MsixLog Info "Capability added: $name" $added += $name } if (-not $added) { return $null } Save-MsixManifest $manifest "$workspace\AppxManifest.xml" @{ CapabilitiesAdded = $added } }.GetNewClosure() } #endregion # --------------------------------------------------------------------------- # Uninstaller / updater / shell-registry mutators # --------------------------------------------------------------------------- function Remove-MsixUninstallerArtifact { <# .SYNOPSIS Strips uninstaller-looking files from inside the package AND removes their Uninstall\<key> registry entries from Registry.dat (the package's virtualized HKLM hive). Repacks + re-signs unless -SkipSigning / -NoSign. .DESCRIPTION Mutator counterpart to Get-MsixUninstallerCandidate / Get-MsixUninstallRegistryEntry. Two-step cleanup: 1. Remove files inside the package matching -PathPatterns. 2. Load Registry.dat (when elevated and -KeepRegistry not set) and delete Uninstall\<key> subkeys whose DisplayName matches -UninstallKeyFilter. Repacks and re-signs at the end. Idempotent — a second run on a clean package logs "No uninstaller artefacts found." and returns without repacking. Used by both Invoke-MsixAutoFix (via -RemoveUninstallers) and Invoke-MsixAutoFixFromAnalysis (RemoveUninstallers stage). .PARAMETER PackagePath .msix file to mutate. .PARAMETER PathPatterns Filename regex patterns. Defaults to a sensible uninstaller list. .PARAMETER UninstallKeyFilter Regex matched against `DisplayName` of each Uninstall subkey to decide whether to delete it. Default `.*` (every entry — they're all leftover from the original installer; MSIX doesn't use them). .PARAMETER KeepRegistry Skip the Registry.dat cleanup; only strip the .exe files. .PARAMETER OutputPath Write the repacked package here instead of overwriting -PackagePath. .PARAMETER SkipSigning Don't sign the repacked .msix. Alias: -NoSign. .PARAMETER Pfx Signing certificate (.pfx) path. .PARAMETER PfxPassword SecureString password for the .pfx. .PARAMETER UnsignedOutputPath If signing fails, preserve the unsigned scratch package at this path for inspection. The user's -PackagePath is left byte-equal to before the call in this scenario. .EXAMPLE # Full cleanup: strip files + registry, then sign (idempotent) Remove-MsixUninstallerArtifact -PackagePath app.msix ` -Pfx cert.pfx -PfxPassword $pw .EXAMPLE # Strip files only, no registry edit, no signing (test/dev) Remove-MsixUninstallerArtifact -PackagePath app.msix ` -KeepRegistry -SkipSigning .OUTPUTS [pscustomobject] with FilesRemoved (string[]), KeysRemoved (string[]), and Output (final package path). Returns nothing when nothing matched. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'UninstallKeyFilter', Justification = 'Captured by the -Mutator scriptblock via GetNewClosure() (issue #37).')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'KeepRegistry', Justification = 'Captured by the -Mutator scriptblock via GetNewClosure() (issue #37).')] [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$PackagePath, [string[]]$PathPatterns, [string]$UninstallKeyFilter = '.*', [switch]$KeepRegistry, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) if (-not $PathPatterns) { $PathPatterns = @( '^uninst.*\.exe$','^unins.*\.exe$','^setup\.exe$','^install\.exe$', '^_isres.*$','^autorun\.inf$','^uninstall\.exe$','^uninstaller.*\.exe$' ) } _MsixMutatePackage -PackagePath $PackagePath -Operation 'uninstrm' ` -OutputPath $OutputPath -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -NoChangeMessage 'No uninstaller artefacts found.' ` -Mutator { param($workspace) # ── Strip files ──────────────────────────────────────────────── $removedFiles = @() Get-ChildItem $workspace -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $name = $_.Name ($PathPatterns | Where-Object { $name -match $_ }).Count -gt 0 } | ForEach-Object { Remove-Item $_.FullName -Force $removedFiles += $_.FullName.Substring($workspace.Length + 1) } # ── Strip Registry.dat Uninstall\* entries ──────────────────── $removedKeys = @() $datPath = Join-Path $workspace 'Registry.dat' if (-not $KeepRegistry -and (Test-Path $datPath)) { # Parse + mutate the hive via offreg.dll (no elevation required). # ORSaveHive cannot overwrite, so we save to a sibling path and replace. $newDat = "$datPath.new" if (Test-Path -LiteralPath $newDat) { Remove-Item -LiteralPath $newDat -Force } $hive = _MsixOpenOfflineHive -Path $datPath $modified = $false try { foreach ($branch in @( 'REGISTRY\MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', 'REGISTRY\MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' )) { $branchKey = _MsixOfflineOpenKey -Parent $hive -SubKey $branch if ($branchKey -eq [IntPtr]::Zero) { continue } try { $children = _MsixOfflineEnumSubKeys -Key $branchKey } finally { _MsixOfflineCloseKey -Key $branchKey } foreach ($child in $children) { $name = _MsixOfflineGetValue -Parent $hive -SubKey "$branch\$child" -Name 'DisplayName' if (-not $name -or ($name -match $UninstallKeyFilter)) { $logical = "$branch\$child" # Uninstall\<app> often has Component subkeys # (per-feature MSI references etc.). ORDeleteKey # is NOT recursive — calling it on a key that # still has children silently fails. Use the # bottom-up recursive helper so the WHOLE # subtree goes away in one call. if (_MsixOfflineDeleteKeyRecursive -Parent $hive -SubKey $logical) { $removedKeys += if ($name) { $name } else { $child } $modified = $true } else { Write-MsixLog Warning "Recursive ORDeleteKey failed for '$logical' — the hive is now in a partial state and will be discarded; the package is unchanged." # Bail out so we never persist a half-deleted hive. $modified = $false break } } } } if ($modified) { if (-not (_MsixOfflineSaveHive -Hive $hive -Path $newDat)) { Write-MsixLog Warning 'ORSaveHive failed; Registry.dat is unchanged.' $modified = $false } } } finally { _MsixCloseOfflineHive -Hive $hive } if ($modified) { Move-Item -LiteralPath $newDat -Destination $datPath -Force } elseif (Test-Path -LiteralPath $newDat) { Remove-Item -LiteralPath $newDat -Force -ErrorAction SilentlyContinue } } if (-not $removedFiles -and -not $removedKeys) { return $null } if ($removedFiles) { Write-MsixLog Info "Files removed: $($removedFiles -join ', ')" } if ($removedKeys) { Write-MsixLog Info "Reg keys removed: $($removedKeys -join ', ')" } @{ FilesRemoved = $removedFiles; KeysRemoved = $removedKeys } }.GetNewClosure() } function Remove-MsixUpdaterArtifact { <# .SYNOPSIS Strips auto-updater binaries and scheduled-task XMLs from inside the package. Repacks + re-signs unless -SkipSigning / -NoSign. .DESCRIPTION Mutator counterpart to Get-MsixUpdaterCandidate. Two-step cleanup: 1. Remove files inside the package whose leaf name matches -PathPatterns (default = the known updater set). 2. Remove *.xml files under any Tasks\ or VFS\Windows\Tasks\ subdirectory (scheduled-task artefacts that ship with installers but cannot fire from inside the MSIX container). Repacks via a scratch path and re-signs at the end. Idempotent — a second run on a clean package logs "No updater artefacts found." and returns without repacking. Does NOT touch Registry.dat — updater registry entries (e.g. Run-key autostart) are detected separately via Get-MsixRunKeyEntry and the existing uninstall-registry / shell-registry cleanup paths. Used by both Invoke-MsixAutoFix (via -RemoveUpdaters) and Invoke-MsixAutoFixFromAnalysis (RemoveUpdaters stage). .PARAMETER PackagePath .msix file to mutate. .PARAMETER PathPatterns Filename regex patterns. Defaults to the same set Get-MsixUpdaterCandidate uses. .PARAMETER OutputPath Write the repacked package here instead of overwriting -PackagePath. .PARAMETER SkipSigning Don't sign the repacked .msix. Alias: -NoSign. .PARAMETER Pfx Signing certificate (.pfx) path. .PARAMETER PfxPassword SecureString password for the .pfx. .PARAMETER UnsignedOutputPath If signing fails, preserve the unsigned scratch package at this path for inspection. .EXAMPLE # Strip updater binaries and scheduled-task XMLs, then sign Remove-MsixUpdaterArtifact -PackagePath app.msix ` -Pfx cert.pfx -PfxPassword $pw .EXAMPLE # Test/dev — strip without signing Remove-MsixUpdaterArtifact -PackagePath app.msix -SkipSigning .OUTPUTS [pscustomobject] with FilesRemoved (string[]), TasksRemoved (string[]), and Output (final package path). Returns nothing when nothing matched. .NOTES Pair with Get-MsixRunKeyEntry to surface Run-key autostart leftovers that updaters often plant in Registry.dat / User.dat. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$PackagePath, [string[]]$PathPatterns, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) if (-not $PathPatterns) { $PathPatterns = @( '^.*updater?\.exe$', '^.*updatesvc.*\.exe$', '^.*sparkle.*\.(dll|exe)$', '^.*squirrel.*\.exe$', '^GoogleUpdate.*\.exe$', '^MicrosoftEdgeUpdate.*\.exe$', '^omaha.*\.exe$', '^.*autoupdater?.*\.exe$', '^.*maintenanceservice.*\.exe$', '^.*winsparkle.*\.(dll|exe)$' ) } $excludePatterns = @('^psf', '^msvc', '^vcruntime', '^api-ms-win-', '^msix') _MsixMutatePackage -PackagePath $PackagePath -Operation 'updrm' ` -WorkspaceSuffix '-updrm' ` -OutputPath $OutputPath -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -NoChangeMessage 'No updater artefacts found.' ` -Mutator { param($workspace) # ── Strip updater binaries ───────────────────────────────────── $removedFiles = @() Get-ChildItem $workspace -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $name = $_.Name $skip = $false foreach ($ex in $excludePatterns) { if ($name -match $ex) { $skip = $true; break } } if ($skip) { return $false } ($PathPatterns | Where-Object { $name -match $_ }).Count -gt 0 } | ForEach-Object { Remove-Item $_.FullName -Force $removedFiles += $_.FullName.Substring($workspace.Length + 1) } # ── Strip scheduled-task XMLs ────────────────────────────────── $removedTasks = @() Get-ChildItem $workspace -Recurse -File -Filter '*.xml' -ErrorAction SilentlyContinue | Where-Object { $rel = $_.FullName.Substring($workspace.Length + 1).ToLowerInvariant() ($rel -match '(^|\\)tasks\\') -or ($rel -match '\\vfs\\windows\\tasks\\') } | ForEach-Object { Remove-Item $_.FullName -Force $removedTasks += $_.FullName.Substring($workspace.Length + 1) } if (-not $removedFiles -and -not $removedTasks) { return $null } if ($removedFiles) { Write-MsixLog Info "Files removed: $($removedFiles -join ', ')" } if ($removedTasks) { Write-MsixLog Info "Tasks removed: $($removedTasks -join ', ')" } @{ FilesRemoved = $removedFiles; TasksRemoved = $removedTasks } }.GetNewClosure() } function Remove-MsixShellRegistryArtifact { <# .SYNOPSIS Strips legacy shellex/shell context-menu entries from the package's Registry.dat so they don't double-register alongside the modern desktop4/desktop5 manifest declaration that Add-MsixLegacyContextMenu emits. .DESCRIPTION After Add-MsixLegacyContextMenu adds the manifest-declared verb (desktop4:Extension/desktop5:Verb), the package's Registry.dat may still contain the original installer's classic shell extension registration: HKCR\<target>\shellex\ContextMenuHandlers\<HandlerName> (default) = "{<clsid>}" HKCR\<target>\shell\<verb> ExplorerCommandHandler = "{<clsid>}" Both forms cause the OS to register the handler AGAIN — the symptom is two identical entries in File Explorer's right-click menu. This cmdlet walks Registry.dat (via offreg.dll, no elevation needed) and removes ONLY the entries that point at CLSIDs we have just declared in the manifest. The CLSID class itself (HKCR\CLSID\{...}) is left intact — the manifest's com:Extension is the new source of truth. The -Entries shape matches what Get-MsixShellContextMenuEntry emits, so the autofix orchestrator can hand the exact same set straight through after Add-MsixLegacyContextMenu. .PARAMETER PackagePath .msix to mutate. .PARAMETER Entries Array of pscustomobjects with at least Target, HandlerName/VerbName, and Clsid properties. Typically the auto-fixable subset of Get-MsixShellContextMenuEntry. .PARAMETER OutputPath If set, write the repacked package here. .PARAMETER SkipSigning Skip signing. Alias: -NoSign. .PARAMETER Pfx / PfxPassword / UnsignedOutputPath Forwarded to the shared sign/move path. .EXAMPLE $shell = Get-MsixShellContextMenuEntry -PackagePath app.msix Remove-MsixShellRegistryArtifact -PackagePath app.msix -Entries $shell -SkipSigning .OUTPUTS [pscustomobject] with KeysRemoved (string[]) and Output (final path). #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [object[]]$Entries, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword, [string]$UnsignedOutputPath ) if (-not $Entries -or $Entries.Count -eq 0) { Write-MsixLog Info 'No shell registry entries supplied; nothing to do.' return } _MsixMutatePackage -PackagePath $PackagePath -Operation 'shellreg' ` -WorkspaceSuffix '-shellreg' ` -OutputPath $OutputPath -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword ` -UnsignedOutputPath $UnsignedOutputPath ` -NoChangeMessage 'No matching legacy shell registry entries found.' ` -Mutator { param($workspace) $datPath = Join-Path $workspace 'Registry.dat' if (-not (Test-Path $datPath)) { Write-MsixLog Info 'No Registry.dat in package — nothing to clean.' return $null } # Targets to walk under Classes — the same set Get-MsixShellContextMenuEntry uses. $targets = @('*', 'Directory', 'Directory\Background', 'AllFilesystemObjects') # Build a CLSID set for fast membership testing (lower-cased, both bare # and braced forms accepted in inputs). $clsidSet = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($e in $Entries) { if ($e.Clsid) { $bare = $e.Clsid.ToString().Trim().Trim('{', '}').ToLowerInvariant() $null = $clsidSet.Add($bare) } } if ($clsidSet.Count -eq 0) { Write-MsixLog Warning 'None of the supplied entries had a Clsid; nothing to clean (resolve CLSIDs via Get-MsixShellContextMenuEntry).' return $null } $newDat = "$datPath.new" if (Test-Path -LiteralPath $newDat) { Remove-Item -LiteralPath $newDat -Force } $removedKeys = @() $hive = _MsixOpenOfflineHive -Path $datPath $modified = $false try { foreach ($prefix in @( 'REGISTRY\MACHINE\SOFTWARE\Classes', 'REGISTRY\MACHINE\SOFTWARE\WOW6432Node\Classes' )) { foreach ($target in $targets) { # ── shellex\ContextMenuHandlers\<name> — delete iff (default) value matches our CLSID $shexBase = "$prefix\$target\shellex\ContextMenuHandlers" $shexKey = _MsixOfflineOpenKey -Parent $hive -SubKey $shexBase if ($shexKey -ne [IntPtr]::Zero) { try { $handlers = _MsixOfflineEnumSubKeys -Key $shexKey } finally { _MsixOfflineCloseKey -Key $shexKey } foreach ($handler in $handlers) { $logical = "$shexBase\$handler" $value = _MsixOfflineGetValue -Parent $hive -SubKey $logical -Name '' if (-not $value) { continue } $bare = $value.ToString().Trim().Trim('{', '}').ToLowerInvariant() if ($clsidSet.Contains($bare)) { if (_MsixOfflineDeleteKeyRecursive -Parent $hive -SubKey $logical) { $removedKeys += $logical $modified = $true } else { Write-MsixLog Warning "Recursive ORDeleteKey failed for '$logical' — discarding partial changes." $modified = $false break } } } } if ($modified -eq $false -and $removedKeys.Count -gt 0) { break } # ── shell\<verb> — delete iff ExplorerCommandHandler matches our CLSID $shellBase = "$prefix\$target\shell" $shellKey = _MsixOfflineOpenKey -Parent $hive -SubKey $shellBase if ($shellKey -ne [IntPtr]::Zero) { try { $verbs = _MsixOfflineEnumSubKeys -Key $shellKey } finally { _MsixOfflineCloseKey -Key $shellKey } foreach ($verb in $verbs) { $logical = "$shellBase\$verb" $ech = _MsixOfflineGetValue -Parent $hive -SubKey $logical -Name 'ExplorerCommandHandler' if (-not $ech) { continue } $bare = $ech.ToString().Trim().Trim('{', '}').ToLowerInvariant() if ($clsidSet.Contains($bare)) { if (_MsixOfflineDeleteKeyRecursive -Parent $hive -SubKey $logical) { $removedKeys += $logical $modified = $true } else { Write-MsixLog Warning "Recursive ORDeleteKey failed for '$logical' — discarding partial changes." $modified = $false break } } } } } } if ($modified) { if (-not (_MsixOfflineSaveHive -Hive $hive -Path $newDat)) { Write-MsixLog Warning 'ORSaveHive failed; Registry.dat is unchanged.' $modified = $false } } } finally { _MsixCloseOfflineHive -Hive $hive } if ($modified) { Move-Item -LiteralPath $newDat -Destination $datPath -Force } elseif (Test-Path -LiteralPath $newDat) { Remove-Item -LiteralPath $newDat -Force -ErrorAction SilentlyContinue } if (-not $removedKeys -or $removedKeys.Count -eq 0) { return $null } Write-MsixLog Info "Legacy shell registry entries removed: $($removedKeys.Count)" $removedKeys | ForEach-Object { Write-MsixLog Info " $_" } @{ KeysRemoved = $removedKeys } }.GetNewClosure() } #region Splash screen ------------------------------------------------------ function Add-MsixSplashScreen { <# .SYNOPSIS Adds a splash-screen image to the PSF launcher config so users see feedback while a slow startScript runs. Requires PSF already to be injected (Add-MsixPsfV2 first). .DESCRIPTION Copies -ImagePath next to the existing config.json (the one created by Add-MsixPsfV2) and patches the targeted application's startScript section to reference it. Repacks + re-signs unless -SkipSigning. Idempotent: re-running with the same -ImagePath / -AppId overwrites the splashImage entry to match. Integrates with Invoke-MsixAutoFix via -SplashImagePath / -SplashAppId. .PARAMETER PackagePath .msix to modify (must already use PsfLauncher). .PARAMETER ImagePath PNG/JPG to display. Copied into the package folder next to config.json. .PARAMETER AppId Application id whose config.json gets the splash entry. .PARAMETER OutputPath Write the modified package here instead of overwriting -PackagePath. .PARAMETER SkipSigning Skip the signing pass. Alias: -NoSign. .PARAMETER Pfx Signing certificate (.pfx) path. .PARAMETER PfxPassword SecureString password for the .pfx. .EXAMPLE # Standalone use after Add-MsixPsfV2 has injected PSF Add-MsixSplashScreen -PackagePath app.msix ` -ImagePath .\splash.png -AppId App ` -Pfx cert.pfx -PfxPassword $pw .EXAMPLE # As part of an Invoke-MsixAutoFix run (sign-once pattern) Invoke-MsixAutoFix -PackagePath app.msix ` -PsfFixups @(New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log') ` -SplashImagePath .\splash.png -SplashAppId App ` -Pfx cert.pfx -PfxPassword $pw #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$PackagePath, [Parameter(Mandatory)] [string]$ImagePath, [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, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword ) if (-not (Test-Path $ImagePath)) { throw "Splash image not found: $ImagePath" } $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item $PackagePath $workspace = New-MsixWorkspace $fileinfo.BaseName try { $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx unpack' # Find config.json — should be next to PsfLauncher $cfgPaths = @(Get-ChildItem $workspace -Recurse -Filter 'config.json' -ErrorAction SilentlyContinue) if (-not $cfgPaths) { throw 'config.json not found; run Add-MsixPsfV2 first.' } $cfgPath = $cfgPaths[0].FullName $cfgDir = Split-Path $cfgPath -Parent # Copy splash next to config.json $imageLeaf = (Get-Item $ImagePath).Name Copy-Item $ImagePath $cfgDir -Force # Patch config.json $cfg = Get-Content $cfgPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop foreach ($app in @($cfg.applications)) { if ($app.id -ne $AppId) { continue } if (-not $app.startScript) { $app | Add-Member -NotePropertyName startScript -NotePropertyValue ([pscustomobject]@{}) -Force } $app.startScript | Add-Member -NotePropertyName splashImage -NotePropertyValue $imageLeaf -Force } $cfg | ConvertTo-Json -Depth 15 | Set-Content $cfgPath -Encoding utf8 $target = if ($OutputPath) { $OutputPath } else { $fileinfo.FullName } $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack', '/p', $target, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx pack' if (-not $SkipSigning) { Invoke-MsixSigning -PackagePath $target -Pfx $Pfx -PfxPassword $PfxPassword } } finally { Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue } } #endregion #region Version bump -------------------------------------------------------- function Update-MsixPackageVersion { <# .SYNOPSIS Bumps the AppxManifest Identity Version (4-part). .PARAMETER Component Major | Minor | Build | Revision (default: Build). .PARAMETER KeepLastZero If $true, leaves the rightmost component at 0 after the bump (matches TMEditX's KeepPackageVersionFieldLastAsZero). .PARAMETER NewVersion Explicit version string overriding -Component. Use this for date-based versions etc. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$PackagePath, [ValidateSet('Major','Minor','Build','Revision')] [string]$Component = 'Build', [bool]$KeepLastZero, [ValidatePattern( '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$', ErrorMessage = 'Version must be a 4-part dotted-decimal like 1.2.3.4.' )] [string]$NewVersion, [string]$OutputPath, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword ) $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item $PackagePath $workspace = New-MsixWorkspace $fileinfo.BaseName try { $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" $current = [version]$manifest.Package.Identity.Version if ($NewVersion) { $next = [version]$NewVersion } else { switch ($Component) { 'Major' { $next = [version]"$([int]$current.Major + 1).0.0.0" } 'Minor' { $next = [version]"$($current.Major).$([int]$current.Minor + 1).0.0" } 'Build' { $next = [version]"$($current.Major).$($current.Minor).$([int]$current.Build + 1).0" } 'Revision' { $next = [version]"$($current.Major).$($current.Minor).$($current.Build).$([int]$current.Revision + 1)" } } if ($KeepLastZero) { # Force the last component to 0 (already done above for Major/Minor/Build) if ($Component -eq 'Revision') { $next = [version]"$($current.Major).$($current.Minor).$([int]$current.Build + 1).0" } } } $manifest.Package.Identity.Version = $next.ToString(4) Write-MsixLog Info "Version: $current -> $next" if ($PSCmdlet.ShouldProcess("$workspace\AppxManifest.xml", 'Save manifest')) { Save-MsixManifest $manifest "$workspace\AppxManifest.xml" } $target = if ($OutputPath) { $OutputPath } else { $fileinfo.FullName } $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack', '/p', $target, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx pack' if (-not $SkipSigning) { Invoke-MsixSigning -PackagePath $target -Pfx $Pfx -PfxPassword $PfxPassword } return $next.ToString(4) } finally { Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue } } #endregion # --------------------------------------------------------------------------- # Plural-noun back-compat aliases (issue #38: preserved across the heuristics # file split — every alias defined in the pre-split MSIX.Heuristics.ps1 still # resolves to the same singular cmdlet). # --------------------------------------------------------------------------- Set-Alias Get-MsixKnownCapabilities Get-MsixKnownCapability Set-Alias Remove-MsixUninstallerArtifacts Remove-MsixUninstallerArtifact |