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


# 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