MSIX.Detection.ps1
|
# ============================================================================= # Auto-detection helpers # ----------------------------------------------------------------------------- # Read-only scanners that look at an unpacked MSIX and surface things the # operator probably wants to act on. They feed into Get-MsixHeuristicFinding # and (via Invoke-MsixAutoFixFromAnalysis) into the autofix planner. # ============================================================================= #region Fonts ---------------------------------------------------------------- function Get-MsixFontCandidate { <# .SYNOPSIS Lists font files inside the package (.ttf / .otf / .ttc) — candidates for registration via uap4:SharedFonts. .DESCRIPTION Read-only scanner that unpacks the package to a scratch workspace, enumerates .ttf / .otf / .ttc files anywhere in the tree, and returns their package-relative paths. The workspace is always cleaned up. Pipe the Path values into Add-MsixFontExtension to register the discovered fonts via uap4:SharedFonts. Surfaces a `ManifestFix:SharedFonts` finding via Get-MsixHeuristicFinding when the package ships fonts but does not declare them. .PARAMETER PackagePath .msix to scan (read-only). .EXAMPLE # Discover fonts and register them via uap4:SharedFonts in one pipeline $fonts = Get-MsixFontCandidate -PackagePath app.msix | Select-Object -ExpandProperty Path Add-MsixFontExtension -PackagePath app.msix -FontPaths $fonts -SkipSigning .OUTPUTS [pscustomobject] one per font: Name, Path (package-relative), SizeBytes #> [CmdletBinding()] param([Parameter(Mandatory)][string]$PackagePath) $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item $PackagePath $workspace = New-MsixWorkspace "$($fileinfo.BaseName)-fonts" try { $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx unpack' Get-ChildItem $workspace -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in '.ttf','.otf','.ttc' } | ForEach-Object { [pscustomobject]@{ Name = $_.Name Path = $_.FullName.Substring($workspace.Length + 1).Replace('\','/') SizeBytes = $_.Length } } } finally { Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue } } #endregion #region Desktop shortcuts inside the package -------------------------------- function Get-MsixDesktopShortcutCandidate { <# .SYNOPSIS Lists .lnk files dropped under the package's virtualized public Desktop (VFS\Common Desktop, VFS\User Desktop, etc.) — common installer leftovers that clutter the user's actual desktop after MSIX install. .DESCRIPTION Read-only scanner. Matches .lnk files whose package-relative path lies under any of `VFS\Common Desktop`, `VFS\User Desktop`, or `VFS\Desktop`. Feeds Get-MsixHeuristicFinding (Category=DesktopShortcuts) and is the detection half of Remove-MsixDesktopShortcut. No mutation, no signing. .PARAMETER PackagePath .msix to scan (read-only). .EXAMPLE # Surface unwanted desktop shortcuts, then strip them in-place Get-MsixDesktopShortcutCandidate -PackagePath app.msix Remove-MsixDesktopShortcut -PackagePath app.msix -SkipSigning .OUTPUTS [pscustomobject] one per shortcut: Name, Path (package-relative), SizeBytes #> [CmdletBinding()] param([Parameter(Mandatory)][string]$PackagePath) $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item $PackagePath $workspace = New-MsixWorkspace "$($fileinfo.BaseName)-shortcuts" try { $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx unpack' $patterns = @('VFS\\Common Desktop','VFS\\User Desktop','VFS\\Desktop') Get-ChildItem $workspace -Recurse -File -Filter *.lnk -ErrorAction SilentlyContinue | Where-Object { $rel = $_.FullName.Substring($workspace.Length + 1) ($patterns | Where-Object { $rel -match $_ }).Count -gt 0 } | ForEach-Object { [pscustomobject]@{ Name = $_.Name Path = $_.FullName.Substring($workspace.Length + 1).Replace('\','/') SizeBytes = $_.Length } } } finally { Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue } } function Remove-MsixDesktopShortcut { <# .SYNOPSIS Removes shortcut files (.lnk) the original installer dropped under the package's virtualized desktop folders. Repacks + signs unless -SkipSigning / -NoSign. .DESCRIPTION Mutator counterpart to Get-MsixDesktopShortcutCandidate. Unpacks the package, deletes every .lnk under VFS\Common Desktop, VFS\User Desktop or VFS\Desktop, repacks, and (unless -SkipSigning) re-signs. Idempotent: re-running on a package with no matching shortcuts logs an info line and returns without repacking. Wired into Invoke-MsixAutoFix via the `-RemoveDesktopShortcuts` switch and into Invoke-MsixAutoFixFromAnalysis for the `DesktopShortcuts` finding category. .PARAMETER PackagePath .msix file to mutate. .PARAMETER OutputPath If set, the repacked package is written here instead of overwriting the input. .PARAMETER SkipSigning Skip the signing pass — useful when chaining multiple fixers and signing only once at the end. Alias: -NoSign. .PARAMETER Pfx Path to a signing certificate (.pfx). Ignored when -SkipSigning is set. .PARAMETER PfxPassword SecureString password for the .pfx. .EXAMPLE # Test/dev case: strip desktop shortcuts and skip signing Remove-MsixDesktopShortcut -PackagePath app.msix -SkipSigning .EXAMPLE # Production: strip and re-sign with a dev cert (idempotent) Remove-MsixDesktopShortcut -PackagePath app.msix ` -Pfx cert.pfx -PfxPassword $pw .OUTPUTS [pscustomobject] with Removed (string[] of package-relative paths) and Output (final package path). Returns nothing when no shortcuts matched. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$PackagePath, [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' $patterns = @('VFS\\Common Desktop','VFS\\User Desktop','VFS\\Desktop') $removed = @() Get-ChildItem $workspace -Recurse -File -Filter *.lnk -ErrorAction SilentlyContinue | Where-Object { $rel = $_.FullName.Substring($workspace.Length + 1) ($patterns | Where-Object { $rel -match $_ }).Count -gt 0 } | ForEach-Object { if ($PSCmdlet.ShouldProcess($_.FullName, 'Remove desktop shortcut')) { $removed += $_.FullName.Substring($workspace.Length + 1) Remove-Item $_.FullName -Force } } if (-not $removed) { Write-MsixLog Info 'No desktop shortcuts found in the package.' return } Write-MsixLog Info "Removed: $($removed -join ', ')" $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 [pscustomobject]@{ Removed = $removed; Output = $target } } finally { Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue } } #endregion #region Capability hints from PE imports ------------------------------------ # Heuristic: for each well-known DLL name in the package's PE imports, suggest # a likely-required capability. Best-effort; user should validate via ACP. $script:DllToCapability = @{ 'wsock32.dll' = 'internetClientServer' 'ws2_32.dll' = 'internetClient' 'wininet.dll' = 'internetClient' 'winhttp.dll' = 'internetClient' 'fwpuclnt.dll' = 'privateNetworkClientServer' 'crypt32.dll' = 'sharedUserCertificates' # niche, may not always apply } function Get-MsixCapabilityHint { <# .SYNOPSIS Suggests a minimum capability set based on the DLLs imported by executables inside the package. Heuristic only — confirm with the Application Capability Profiler before publishing. .DESCRIPTION Unpacks the package, scans the first 8 MB of each .exe / .dll for well-known Win32 DLL imports, and maps them to the capability names a packaged app typically needs (e.g. `wininet.dll` -> `internetClient`). Returns the union of detected capability names. Feeds the `CapabilityHints` finding produced by Get-MsixHeuristicFinding and the `AddCapabilityHints` stage of Invoke-MsixAutoFixFromAnalysis. .PARAMETER PackagePath .msix to scan (read-only). .EXAMPLE # Discover hints and add them via Add-MsixCapability $hints = Get-MsixCapabilityHint -PackagePath app.msix Add-MsixCapability -PackagePath app.msix -Names $hints -SkipSigning .OUTPUTS [string[]] capability names (sorted, unique). #> [CmdletBinding()] param([Parameter(Mandatory)][string]$PackagePath) $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item $PackagePath $workspace = New-MsixWorkspace "$($fileinfo.BaseName)-caphints" try { $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx unpack' $hits = New-Object System.Collections.Generic.HashSet[string] $allDlls = $script:DllToCapability.Keys Get-ChildItem $workspace -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in '.exe','.dll' } | ForEach-Object { try { $stream = [IO.File]::OpenRead($_.FullName) try { $buf = New-Object byte[] ([math]::Min($stream.Length, 8MB)) $n = $stream.Read($buf, 0, $buf.Length) $txt = [Text.Encoding]::ASCII.GetString($buf, 0, $n) } finally { $stream.Dispose() } foreach ($d in $allDlls) { if ($txt -match [regex]::Escape($d)) { $null = $hits.Add($script:DllToCapability[$d]) } } } catch { Write-MsixLog Debug "PE scan skipped for file: $_" } } return @($hits) | Sort-Object -Unique } finally { Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue } } #endregion #region Nested package detection --------------------------------------------- function Get-MsixNestedPackageCandidate { <# .SYNOPSIS Lists .msix/.appx/.msixbundle/.appxbundle files found inside the package. .DESCRIPTION Nested installer packages baked in by the original installer cannot be installed from within an MSIX container. This is a detection-only helper; there is no automated fix — nested packages require a different deployment strategy (side-loading, startScript wrapper, or Intune / SCCM staging). .PARAMETER PackagePath .msix to scan (read-only). .EXAMPLE # Surface any nested installer packages — there is no auto-fix Get-MsixNestedPackageCandidate -PackagePath app.msix .OUTPUTS [pscustomobject] one per nested package: Name, Path (package-relative), SizeBytes #> [CmdletBinding()] param([Parameter(Mandatory)][string]$PackagePath) $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item $PackagePath $workspace = New-MsixWorkspace "$($fileinfo.BaseName)-nested" try { $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx unpack' Get-ChildItem $workspace -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in '.msix','.appx','.msixbundle','.appxbundle' } | ForEach-Object { [pscustomobject]@{ Name = $_.Name Path = $_.FullName.Substring($workspace.Length + 1) SizeBytes = $_.Length } } } finally { Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue } } #endregion #region Plugin / extension-point detection ----------------------------------- # Subdirectory names that strongly suggest "this app loads runtime extensions # from here". When the package's plugin manager probes write-access at startup # (or downloads new plugins into the path) those operations fail or vanish # under default MSIX containerisation. The autofix below turns each detected # directory into a per-user-writable carve-out. $script:MsixPluginDirectoryNames = @( 'plugins','plugin','Plugins', 'extensions','extension','Extensions', 'add-ins','addins','Addins','add-in', 'addons','Addons','add-ons', 'themes','Themes','skins','Skins', 'templates','Templates', 'presets','Presets', 'macros','Macros', 'dictionaries','Dictionaries','spellcheck', 'localization','localizations','lang','Languages','locale','locales', 'userDefineLangs','userdefinedlangs' ) function Get-MsixPluginExtensionPoint { <# .SYNOPSIS Lists directories inside the package that look like runtime extension points (plugins, themes, add-ins, language packs). .DESCRIPTION MSIX containerisation virtualises writes to the install directory. Apps with a plugin/theme manager typically: - probe write-access on the plugin folder at startup (and fail before any virtualised write happens), - download new plugins into %ProgramFiles%\<app>\plugins\ which virtualises to a per-user shadow path the unmodified plugin enumerator never looks at. This scanner finds those directories from three independent signals so we never recommend a fix for an unrelated folder that just happens to be called 'Plugins': 1. Directory NAME matches a curated list of conventions ($script:MsixPluginDirectoryNames). 2. The directory CONTAINS at least one of these signals: - a plugin-manifest file (plugin.xml, manifest.json, pluginlist.cfg) - subfolders with .exe + .dll pairs (typical plugin layout) - more than -MinFiles entries (default 1) so empty stub folders shipped by the installer are ignored. 3. The directory is under the main Application's executable folder (heuristic: we resolve the Application's Executable attribute and only flag folders that live alongside it). Detection-only — pair with the Invoke-MsixAutoFixFromAnalysis PluginDirectory stage (or call Set-MsixFileSystemWriteVirtualization directly) to apply the fix. .PARAMETER PackagePath .msix to scan (read-only). .PARAMETER MinFiles Minimum number of entries a directory must contain to be flagged. Default 1 (empty stub folders are skipped). .EXAMPLE Get-MsixPluginExtensionPoint -PackagePath app.msix | Format-Table Name, RelativePath, MatchSignal .OUTPUTS [pscustomobject[]] each with Name, RelativePath, MatchSignal, FileCount, HasManifest. #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)] [string]$PackagePath, [int]$MinFiles = 1 ) $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item $PackagePath $workspace = New-MsixWorkspace "$($fileinfo.BaseName)-plugins" try { $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx unpack' # Anchor the scan under the first Application's exe directory if we # can resolve it. Without that anchor we'd also flag e.g. # VFS\Windows\System32\<random>\Plugins (false positive). [xml]$manifest = Get-MsixManifest "$workspace\AppxManifest.xml" $apps = @($manifest.Package.Applications.Application) $appRoots = New-Object 'System.Collections.Generic.List[string]' foreach ($app in $apps) { $exe = $app.GetAttribute('Executable') if (-not $exe -or -not $exe.Contains('\')) { continue } $rel = $exe.Substring(0, $exe.LastIndexOf('\')) $abs = Join-Path $workspace $rel if (Test-Path -LiteralPath $abs) { $appRoots.Add($abs) | Out-Null } } if ($appRoots.Count -eq 0) { # Fall back to the unpack root. $appRoots.Add($workspace) | Out-Null } # Manifest files that indicate a managed plugin discovery system. $pluginManifestNames = @('plugin.xml','manifest.json','pluginlist.cfg','plugin.json','plugins.cfg','plugins.xml') $seen = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($root in $appRoots) { Get-ChildItem -LiteralPath $root -Directory -Recurse -ErrorAction SilentlyContinue | Where-Object { $script:MsixPluginDirectoryNames -contains $_.Name } | ForEach-Object { $dir = $_.FullName if (-not $seen.Add($dir)) { return } $entries = @(Get-ChildItem -LiteralPath $dir -Force -ErrorAction SilentlyContinue) if ($entries.Count -lt $MinFiles) { return } $manifestHits = @($entries | Where-Object { $pluginManifestNames -contains $_.Name }) $hasExeDll = ($entries.Where({ $_.Extension -in '.exe','.dll' }, 'First', 1)).Count -gt 0 # Compose the explanatory signal so the finding line # shows operators why we flagged this directory. $signals = @() $signals += "name '$($_.Name)' matches plugin/theme convention" if ($manifestHits) { $signals += "contains plugin manifest ($($manifestHits.Name -join ', '))" } if ($hasExeDll) { $signals += 'contains .exe/.dll' } [pscustomobject]@{ Name = $_.Name RelativePath = $dir.Substring($workspace.Length + 1) FileCount = $entries.Count HasManifest = [bool]$manifestHits MatchSignal = $signals -join '; ' } } } } finally { Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue } } #endregion # Backward-compatible plural aliases Set-Alias Get-MsixFontCandidates Get-MsixFontCandidate Set-Alias Get-MsixDesktopShortcutCandidates Get-MsixDesktopShortcutCandidate Set-Alias Remove-MsixDesktopShortcuts Remove-MsixDesktopShortcut Set-Alias Get-MsixCapabilityHints Get-MsixCapabilityHint Set-Alias Get-MsixNestedPackageCandidates Get-MsixNestedPackageCandidate Set-Alias Get-MsixPluginExtensionPoints Get-MsixPluginExtensionPoint |