Files
wehub-resource-sync 3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

1103 lines
53 KiB
PowerShell

# SPDX-License-Identifier: MIT
# discover_apps.ps1 -- enumerate installed Windows apps and emit JSON on stdout
#
# Consumed by winpodx.core.discovery. Up to five sources are scanned inside
# the running guest and unioned, deduping by lowercase executable path or UWP
# AUMID:
#
# 1. Registry App Paths (HKLM + HKCU) [full-scan only]
# 2. Start Menu .lnk recursion (ProgramData + every user profile)
# 3. UWP / MSIX packages via Get-AppxPackage + AppxManifest.xml
# (default: only those whose AUMID is in the Start Menu / Get-StartApps)
# 4. Chocolatey + Scoop shims [full-scan only]
# 5. Essentials (File Explorer / Calculator / Settings) -- always emitted
#
# DEFAULT (Start-Menu-only, #581): only what the Windows Start Menu actually
# shows reaches the Linux menu -- Source 2 (.lnk), Start-Menu-visible UWP, and
# the essentials. App Paths + choco/scoop shims (the sources that flooded the
# menu with uninstallers / helpers / background exes) are SKIPPED. Set
# ``desktop.full_app_scan = true`` (host prepends the opt-in, see below) or
# pass ``-FullScan`` (file/CLI use) to restore the legacy 5-source union.
#
# Output schema per entry (JSON array on stdout):
#
# { "name": str, "path": str, "args": str,
# "source": "win32" | "uwp", "wm_class_hint": str,
# "launch_uri": str, "icon_b64": str,
# "start_menu_folder": str } # #581 Goal 2: relative Start Menu subfolder
# # ("" for top-level / non-.lnk sources)
#
# Semantic contract for `launch_uri`:
# - source == "uwp" : bare AUMID of the form `PackageFamilyName!AppId`
# (NO `shell:AppsFolder\` prefix -- the host prepends
# that when building the FreeRDP `/app-cmd`). The
# host-side regex `_AUMID_RE` in
# `src/winpodx/core/rdp.py` rejects any value that
# already carries the prefix.
# - source == "win32" : empty string.
#
# An optional trailing element `{"_truncated": true}` signals that the
# guest clipped its own output.
#
# Invocation: run inside the Windows guest under Administrator (required
# for Get-AppxPackage -AllUsers). Host side pipes stdout into json.loads.
# `-DryRun` returns a single canned entry without touching Registry/AppX
# so CI runners (no Windows) can smoke-test the JSON shape.
[CmdletBinding()]
param(
[switch]$DryRun,
[switch]$FullScan
)
# #581: scan mode. DEFAULT is Start-Menu-only (App Paths + choco/scoop shims
# skipped; UWP intersected with the Start Menu set). For
# ``desktop.full_app_scan = true`` the host flips the assignment below to true
# BEFORE exec (it can't prepend -- ``param`` must stay the first statement; it
# does a one-shot literal swap of the line `<var> = $false`). The ``-FullScan``
# switch forces it for direct file / CLI runs.
$WinpodxFullScan = $false
if ($FullScan.IsPresent) { $WinpodxFullScan = $true }
$ErrorActionPreference = 'Continue'
$MAX_APPS = 500
$MAX_ICON_BYTES = 1MB
$MAX_NAME_LEN = 255
$MAX_PATH_LEN = 1024
$ICON_SIZE = 32
# --- Helpers ---------------------------------------------------------------
function ConvertTo-IconBase64 {
param([string]$SourcePath, [int]$Size = $ICON_SIZE)
if (-not $SourcePath) { return '' }
if (-not (Test-Path -LiteralPath $SourcePath -PathType Leaf)) { return '' }
$icon = $null; $bmp = $null; $resized = $null; $g = $null; $ms = $null
try {
Add-Type -AssemblyName System.Drawing -ErrorAction Stop
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($SourcePath)
if (-not $icon) { return '' }
$bmp = $icon.ToBitmap()
$resized = New-Object System.Drawing.Bitmap($Size, $Size)
$g = [System.Drawing.Graphics]::FromImage($resized)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.DrawImage($bmp, 0, 0, $Size, $Size)
$ms = New-Object System.IO.MemoryStream
$resized.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$bytes = $ms.ToArray()
if ($bytes.Length -gt $MAX_ICON_BYTES) { return '' }
return [Convert]::ToBase64String($bytes)
} catch {
return ''
} finally {
if ($ms) { $ms.Dispose() }
if ($g) { $g.Dispose() }
if ($resized) { $resized.Dispose() }
if ($bmp) { $bmp.Dispose() }
if ($icon) { $icon.Dispose() }
}
}
function Get-WmClassHint {
param([string]$ExePath)
if (-not $ExePath) { return '' }
try {
$stem = [System.IO.Path]::GetFileNameWithoutExtension($ExePath)
if (-not $stem) { return '' }
$safe = ($stem.ToLower() -replace '[^a-z0-9_-]', '')
if ($safe.Length -gt 64) { $safe = $safe.Substring(0, 64) }
return $safe
} catch { return '' }
}
function Get-DisplayName {
param([string]$ExePath, [string]$Fallback = '')
try {
$item = Get-Item -LiteralPath $ExePath -ErrorAction Stop
$vi = $item.VersionInfo
if ($vi -and $vi.FileDescription -and $vi.FileDescription.Trim()) {
return $vi.FileDescription.Trim()
}
} catch { }
if ($Fallback) { return $Fallback }
try { return [System.IO.Path]::GetFileNameWithoutExtension($ExePath) } catch { return '' }
}
function Get-AppDescription {
# Pull a one-line description from the executable's version metadata.
# Win32 binaries rarely fill Comments; ProductName + CompanyName are the
# only fields we can rely on. The output lands in the Linux .desktop
# Comment= key (a tooltip), so identical-to-the-name strings are still
# useful -- better to surface "Microsoft Edge" than the project-wide
# "Windows application via winpodx" generic stamp.
#
# Order:
# 1. Comments -- most authored field; trust it when set.
# 2. ProductName when distinct from FileDescription (gives e.g.
# "Microsoft Windows Operating System" for inbox tools where the
# FileDescription is just "Notepad").
# 3. CompanyName when present -- "by Microsoft Corporation" is a
# meaningful tooltip even when ProductName duplicates the name.
# 4. Bare ProductName as a last resort (still better than nothing).
param([string]$ExePath)
try {
$item = Get-Item -LiteralPath $ExePath -ErrorAction Stop
$vi = $item.VersionInfo
if (-not $vi) { return '' }
$comments = if ($vi.Comments) { $vi.Comments.Trim() } else { '' }
if ($comments) { return $comments }
$product = if ($vi.ProductName) { $vi.ProductName.Trim() } else { '' }
$fileDesc = if ($vi.FileDescription) { $vi.FileDescription.Trim() } else { '' }
$company = if ($vi.CompanyName) { $vi.CompanyName.Trim() } else { '' }
if ($product -and $product -ne $fileDesc) { return $product }
if ($company) { return "by $company" }
if ($product) { return $product }
} catch { }
return ''
}
function Resolve-AdvertisedShortcut {
# Office 2016 (and other MSI) apps install *advertised* Start Menu shortcuts:
# the .lnk stores a Windows Installer "Darwin descriptor", not a direct exe
# path. WScript.Shell's .TargetPath resolves such a shortcut to the product's
# generic icon stub under C:\Windows\Installer\{ProductCode}\ (e.g.
# xlicons.exe), whose FileDescription is a shared "Microsoft Office 2016
# component" -- so Word/Excel/PowerPoint all resolve to look-alike stubs that
# dedup into one dead entry (#680). MsiGetShortcutTarget + MsiGetComponentPath
# resolve the advertisement to the REAL installed executable. Returns the
# resolved .exe path, or '' when the shortcut isn't an advertised/installed
# MSI target (caller then keeps the plain .TargetPath). Best-effort: any
# failure (no msi.dll interop, not advertised, component absent) returns ''.
param([string]$LnkPath)
if (-not $script:MsiInteropTried) {
$script:MsiInteropTried = $true
try {
Add-Type -Namespace Winpodx -Name Msi -ErrorAction Stop -MemberDefinition @'
[System.Runtime.InteropServices.DllImport("msi.dll", CharSet=System.Runtime.InteropServices.CharSet.Unicode)]
public static extern uint MsiGetShortcutTarget(string szShortcutTarget, System.Text.StringBuilder szProductCode, System.Text.StringBuilder szFeatureId, System.Text.StringBuilder szComponentCode);
[System.Runtime.InteropServices.DllImport("msi.dll", CharSet=System.Runtime.InteropServices.CharSet.Unicode)]
public static extern int MsiGetComponentPath(string szProduct, string szComponent, System.Text.StringBuilder lpPathBuf, ref uint pcchBuf);
'@
$script:MsiInteropReady = $true
} catch {
$script:MsiInteropReady = $false
}
}
if (-not $script:MsiInteropReady) { return '' }
try {
# Product + Component are GUID strings (38 chars); Feature can be longer.
$prod = New-Object System.Text.StringBuilder 40
$feat = New-Object System.Text.StringBuilder 260
$comp = New-Object System.Text.StringBuilder 40
$rc = [Winpodx.Msi]::MsiGetShortcutTarget($LnkPath, $prod, $feat, $comp)
# ERROR_SUCCESS = 0; a normal (non-advertised) shortcut returns non-zero.
if ($rc -ne 0) { return '' }
$buf = New-Object System.Text.StringBuilder 1024
[uint32]$len = 1024
$state = [Winpodx.Msi]::MsiGetComponentPath($prod.ToString(), $comp.ToString(), $buf, [ref]$len)
# INSTALLSTATE_LOCAL = 3, INSTALLSTATE_SOURCE = 4 -> a usable install.
if ($state -eq 3 -or $state -eq 4) {
$p = $buf.ToString()
if ($p -and $p -match '\.exe$' -and (Test-Path -LiteralPath $p -PathType Leaf)) {
return $p
}
}
} catch { }
return ''
}
function Read-IconBytesFromFile {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' }
try {
$bytes = [System.IO.File]::ReadAllBytes($Path)
if ($bytes.Length -eq 0) { return '' }
if ($bytes.Length -gt $MAX_ICON_BYTES) { return '' }
return [Convert]::ToBase64String($bytes)
} catch { return '' }
}
function Get-ExeHash {
# SHA256 of an app's executable, used host-side to detect when an app was
# updated (icon may have changed) vs unchanged (skip re-extraction). Returns
# '' for non-file paths (UWP InstallLocation dirs), missing or locked files.
param([string]$Path)
if (-not $Path) { return '' }
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' }
try {
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLower()
} catch { return '' }
}
# --- DryRun short-circuit for CI smoke tests -------------------------------
if ($DryRun) {
$canned = @(
[ordered]@{
name = 'Notepad (DryRun)'
description = 'Plain text editor (DryRun fixture)'
path = 'C:\Windows\notepad.exe'
args = ''
source = 'win32'
wm_class_hint = 'notepad'
launch_uri = ''
icon_b64 = ''
start_menu_folder = 'Accessories'
}
)
# @(...) wrapper forces array even for single element on PS 5.1.
ConvertTo-Json -InputObject @($canned) -Depth 6 -Compress
exit 0
}
# --- Readiness gate (first-boot race avoidance) ----------------------------
# At Sysprep first-boot the user session has just started: AppX deployment
# is still finishing, Start Menu indexer hasn't propagated all .lnk files
# yet, AppXSvc may still be queueing work. discovery firing in this 30-60 s
# window returns partial / empty results -- kernalix7 reported the menu
# populating one install, missing UWP entries the next, despite identical
# config. Both symptoms collapse to "we ran too early".
#
# Gate: wait until BOTH conditions hold or until the bounded budget
# expires (we proceed regardless after timeout -- a partial discovery is
# better than none, and the host's retry-on-empty layer covers the
# all-empty case).
#
# 1. AppXSvc service is Running. If it's StartPending / Stopped the
# AppX broker can't service Get-AppxPackage queries reliably.
# 2. Start Menu has at least one .lnk under ProgramData. The default
# Windows Start Menu ships dozens of these; their absence means
# first-boot Start Menu population is still in flight.
#
# Quiescence: poll every 1 s; require 3 consecutive samples where
# AppXSvc is Running + .lnk count is non-zero before declaring ready.
# This catches the case where AppXSvc flips Running briefly during
# StartPending -> Running -> restart cycles.
$readyBudgetSec = 60
$pollIntervalSec = 1
$stableSamplesNeeded = 3
$readyDeadline = (Get-Date).AddSeconds($readyBudgetSec)
$stableSamples = 0
$systemStartMenu = "$env:ProgramData\Microsoft\Windows\Start Menu\Programs"
[Console]::Error.WriteLine("[discover] waiting for first-boot stability (max ${readyBudgetSec}s)...")
while ((Get-Date) -lt $readyDeadline) {
$appxRunning = $false
try {
$svc = Get-Service -Name AppXSvc -ErrorAction Stop
$appxRunning = ($svc.Status -eq 'Running')
} catch {
$appxRunning = $false
}
$lnkCount = 0
try {
$lnkCount = (Get-ChildItem -LiteralPath $systemStartMenu -Recurse -Filter '*.lnk' -ErrorAction SilentlyContinue |
Measure-Object).Count
} catch {
$lnkCount = 0
}
if ($appxRunning -and $lnkCount -gt 0) {
$stableSamples++
if ($stableSamples -ge $stableSamplesNeeded) {
[Console]::Error.WriteLine("[discover] stable (AppXSvc=Running, .lnk=$lnkCount, samples=$stableSamples) -- proceeding")
break
}
} else {
$stableSamples = 0
}
Start-Sleep -Seconds $pollIntervalSec
}
if ($stableSamples -lt $stableSamplesNeeded) {
[Console]::Error.WriteLine("[discover] stability budget exceeded (${readyBudgetSec}s); proceeding with potentially partial state")
}
# --- Accumulator -----------------------------------------------------------
$results = New-Object System.Collections.Generic.List[object]
$seen = @{}
# Build {identity -> [".ext", ...]} ONCE per run, where identity is the app a
# file type opens with: "aumid:<AppUserModelID>" for UWP apps (Notepad, Paint,
# Photos, the browsers...) or "exe:<basename>" for Win32. This is the source
# Windows Settings > Default apps reads -- the per-extension UserChoice -- so it
# covers UWP and Win32 uniformly (UWP ProgIDs have no .exe open command, so an
# exe-only scan misses them entirely, the original #545 bug). The host maps the
# extensions to MIME types for the .desktop MimeType=.
$script:WinpodxExtMap = $null
$script:WinpodxIdCache = @{}
# URL-scheme harvest state (#421 mailto->Outlook + #694). Same shape as
# WinpodxExtMap: {identity -> List[<scheme>]} built ONCE per run, identity being
# "aumid:<AUMID>" or "exe:<basename>" via the shared Resolve-Identity.
$script:WinpodxSchemeMap = $null
# Schemes never surfaced as an x-scheme-handler on Linux (code-exec / local-file
# / settings vectors). Coarse guest-side guard; core/url_schemes.py owns the
# authoritative denylist -- keep the two in lockstep.
$script:WinpodxSchemeDeny = [System.Collections.Generic.HashSet[string]]::new(
[string[]]@('file', 'javascript', 'vbscript', 'data', 'about', 'shell', 'res',
'chrome', 'chrome-extension', 'ms-settings', 'ms-msdt', 'ms-search',
'search-ms', 'hcp', 'its', 'mk', 'ldap', 'help', 'wscript', 'cscript',
'view-source'))
function Add-ExtTo {
param([hashtable]$Map, [string]$Id, [string]$Ext)
if (-not $Id -or -not $Ext) { return }
$x = $Ext.ToLower()
if ($x -notmatch '^\.[a-z0-9]{1,16}$') { return }
if (-not $Map.ContainsKey($Id)) { $Map[$Id] = New-Object System.Collections.Generic.List[string] }
if (-not $Map[$Id].Contains($x)) { $Map[$Id].Add($x) }
}
function Resolve-Identity {
# A handler ProgID -> ALL identities it is known by: "aumid:<AUMID>" (UWP,
# from \Application\AppUserModelID) and/or "exe:<basename>" (Win32, from the
# shell\open\command). An app such as Edge carries BOTH (an AUMID and an
# msedge.exe command), and app discovery may surface it either way -- emit
# every identity so the per-app lookup hits no matter which form was found.
# Returns an array (possibly empty). Cached -- one ProgID handles many types.
param([string]$ProgId)
if (-not $ProgId) { return @() }
if ($script:WinpodxIdCache.ContainsKey($ProgId)) { return $script:WinpodxIdCache[$ProgId] }
$ids = New-Object System.Collections.Generic.List[string]
foreach ($root in @('HKCU:\SOFTWARE\Classes', 'HKLM:\SOFTWARE\Classes')) {
$aumid = [string](Get-ItemProperty -LiteralPath "$root\$ProgId\Application" -ErrorAction SilentlyContinue).AppUserModelID
if ($aumid) { $id = "aumid:$aumid"; if (-not $ids.Contains($id)) { $ids.Add($id) } }
$cmd = [string](Get-ItemProperty -LiteralPath "$root\$ProgId\shell\open\command" -ErrorAction SilentlyContinue).'(default)'
if ($cmd -and $cmd -match '([a-zA-Z]:\\[^"]*?\.exe)') {
$id = "exe:" + ([System.IO.Path]::GetFileName($Matches[1])).ToLower()
if (-not $ids.Contains($id)) { $ids.Add($id) }
}
}
$arr = @($ids)
$script:WinpodxIdCache[$ProgId] = $arr
return $arr
}
function Build-ExtMap {
$map = @{}
try {
# Per-extension current handler (UserChoice) under the user's FileExts.
# Bounded to extensions the user actually has associations for, so it's
# fast (a full HKCR\.* scan times out) and matches Settings > Default apps.
$fe = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FileExts'
if (Test-Path -LiteralPath $fe) {
Get-ChildItem -LiteralPath $fe -ErrorAction SilentlyContinue | ForEach-Object {
$ext = $_.PSChildName
if ($ext -notmatch '^\.[a-z0-9]{1,16}$') { return }
$uc = [string](Get-ItemProperty -LiteralPath "$fe\$ext\UserChoice" -ErrorAction SilentlyContinue).ProgId
foreach ($id in (Resolve-Identity $uc)) { Add-ExtTo $map $id $ext }
}
}
# Per-app DECLARED associations: RegisteredApplications -> Capabilities\
# FileAssociations (browsers, mail clients, desktop apps that aren't the
# current default for a type still declare what they can open). The
# Capabilities path is relative to the SAME hive root as its
# RegisteredApplications entry — resolve it there only (probing the
# other root costs ~50x as much on non-existent cross-hive paths).
foreach ($pair in @(
@('HKLM:\SOFTWARE\RegisteredApplications', 'HKLM:\'),
@('HKCU:\SOFTWARE\RegisteredApplications', 'HKCU:\'))) {
$raHive = $pair[0]; $root = $pair[1]
if (-not (Test-Path -LiteralPath $raHive)) { continue }
$ra = Get-ItemProperty -LiteralPath $raHive -ErrorAction SilentlyContinue
if (-not $ra) { continue }
foreach ($prop in $ra.PSObject.Properties) {
if ($prop.Name -match '^PS') { continue }
$capRel = [string]$prop.Value
if (-not $capRel) { continue }
$faKey = Join-Path $root ($capRel + '\FileAssociations')
if (-not (Test-Path -LiteralPath $faKey)) { continue }
$fa = Get-ItemProperty -LiteralPath $faKey -ErrorAction SilentlyContinue
if (-not $fa) { continue }
foreach ($p in $fa.PSObject.Properties) {
if ($p.Name -match '^PS' -or $p.Name -notlike '.*') { continue }
foreach ($id in (Resolve-Identity ([string]$p.Value))) { Add-ExtTo $map $id ([string]$p.Name) }
}
}
}
# Per-app SupportedTypes: Applications\<exe>\SupportedTypes (Win32 apps
# that declare openable types by exe rather than via Capabilities).
foreach ($appsHive in @('HKLM:\SOFTWARE\Classes\Applications', 'HKCU:\SOFTWARE\Classes\Applications')) {
if (-not (Test-Path -LiteralPath $appsHive)) { continue }
Get-ChildItem -LiteralPath $appsHive -ErrorAction SilentlyContinue | ForEach-Object {
$stKey = Join-Path $_.PSPath 'SupportedTypes'
if (-not (Test-Path -LiteralPath $stKey)) { return }
$id = "exe:" + $_.PSChildName.ToLower()
$st = Get-ItemProperty -LiteralPath $stKey -ErrorAction SilentlyContinue
if ($st) { foreach ($p in $st.PSObject.Properties) { if ($p.Name -notmatch '^PS') { Add-ExtTo $map $id ([string]$p.Name) } } }
}
}
} catch { }
return $map
}
function Get-AppExtensions {
# Extensions the given app handles, matched by AUMID (UWP, from launch_uri =
# "shell:AppsFolder\<AUMID>") then by exe basename (Win32).
param([string]$ExePath, [string]$LaunchUri)
try {
if ($null -eq $script:WinpodxExtMap) { $script:WinpodxExtMap = Build-ExtMap }
$keys = New-Object System.Collections.Generic.List[string]
if ($LaunchUri) {
$aumid = $LaunchUri -replace '^shell:AppsFolder\\', ''
if ($aumid) { $keys.Add("aumid:$aumid") }
}
if ($ExePath) { $keys.Add("exe:" + ([System.IO.Path]::GetFileName($ExePath)).ToLower()) }
# Union every matching identity (an app discovered with both an AUMID
# and an exe path can have entries under both keys).
$acc = New-Object System.Collections.Generic.List[string]
foreach ($k in $keys) {
if ($script:WinpodxExtMap.ContainsKey($k)) {
foreach ($x in $script:WinpodxExtMap[$k]) { if (-not $acc.Contains($x)) { $acc.Add($x) } }
}
}
if ($acc.Count -gt 64) { return @($acc.GetRange(0, 64)) }
return @($acc)
} catch { return @() }
return @()
}
function Add-SchemeTo {
param([hashtable]$Map, [string]$Id, [string]$Scheme)
if (-not $Id -or -not $Scheme) { return }
$s = $Scheme.ToLower().Trim().TrimEnd(':')
# RFC 3986 scheme syntax, length-bounded (mirrors core/url_schemes.py).
if ($s -notmatch '^[a-z][a-z0-9+.\-]{0,31}$') { return }
if ($script:WinpodxSchemeDeny.Contains($s)) { return }
if (-not $Map.ContainsKey($Id)) { $Map[$Id] = New-Object System.Collections.Generic.List[string] }
if (-not $Map[$Id].Contains($s)) { $Map[$Id].Add($s) }
}
function Build-SchemeMap {
# URL analog of Build-ExtMap: {identity -> List[scheme]} from the per-scheme
# UserChoice default + per-app declared Capabilities\URLAssociations.
$map = @{}
try {
# Per-scheme current handler (UserChoice) under the user's UrlAssociations
# -- bounded to protocols the user has a default for (matches Settings).
$ua = 'HKCU:\SOFTWARE\Microsoft\Windows\Shell\Associations\UrlAssociations'
if (Test-Path -LiteralPath $ua) {
Get-ChildItem -LiteralPath $ua -ErrorAction SilentlyContinue | ForEach-Object {
$scheme = $_.PSChildName
$uc = [string](Get-ItemProperty -LiteralPath "$ua\$scheme\UserChoice" -ErrorAction SilentlyContinue).ProgId
foreach ($id in (Resolve-Identity $uc)) { Add-SchemeTo $map $id $scheme }
}
}
# Per-app DECLARED handlers: RegisteredApplications -> Capabilities\
# URLAssociations (value NAME = scheme, DATA = handler ProgId). Same
# same-hive-root rule Build-ExtMap uses for FileAssociations.
foreach ($pair in @(
@('HKLM:\SOFTWARE\RegisteredApplications', 'HKLM:\'),
@('HKCU:\SOFTWARE\RegisteredApplications', 'HKCU:\'))) {
$raHive = $pair[0]; $root = $pair[1]
if (-not (Test-Path -LiteralPath $raHive)) { continue }
$ra = Get-ItemProperty -LiteralPath $raHive -ErrorAction SilentlyContinue
if (-not $ra) { continue }
foreach ($prop in $ra.PSObject.Properties) {
if ($prop.Name -match '^PS') { continue }
$capRel = [string]$prop.Value
if (-not $capRel) { continue }
$uaKey = Join-Path $root ($capRel + '\URLAssociations')
if (-not (Test-Path -LiteralPath $uaKey)) { continue }
$ub = Get-ItemProperty -LiteralPath $uaKey -ErrorAction SilentlyContinue
if (-not $ub) { continue }
foreach ($p in $ub.PSObject.Properties) {
if ($p.Name -match '^PS') { continue }
foreach ($id in (Resolve-Identity ([string]$p.Value))) { Add-SchemeTo $map $id ([string]$p.Name) }
}
}
}
} catch { }
return $map
}
function Get-AppUrlSchemes {
# URL schemes the given app handles, matched by AUMID then exe basename --
# the URL analog of Get-AppExtensions.
param([string]$ExePath, [string]$LaunchUri)
try {
if ($null -eq $script:WinpodxSchemeMap) { $script:WinpodxSchemeMap = Build-SchemeMap }
$keys = New-Object System.Collections.Generic.List[string]
if ($LaunchUri) {
$aumid = $LaunchUri -replace '^shell:AppsFolder\\', ''
if ($aumid) { $keys.Add("aumid:$aumid") }
}
if ($ExePath) { $keys.Add("exe:" + ([System.IO.Path]::GetFileName($ExePath)).ToLower()) }
$acc = New-Object System.Collections.Generic.List[string]
foreach ($k in $keys) {
if ($script:WinpodxSchemeMap.ContainsKey($k)) {
foreach ($s in $script:WinpodxSchemeMap[$k]) { if (-not $acc.Contains($s)) { $acc.Add($s) } }
}
}
if ($acc.Count -gt 32) { return @($acc.GetRange(0, 32)) }
return @($acc)
} catch { return @() }
return @()
}
function Add-Result {
param([hashtable]$Entry)
if (-not $Entry) { return }
if ($results.Count -ge $MAX_APPS) { return }
$name = [string]$Entry.name
$path = [string]$Entry.path
if (-not $name -or -not $path) { return }
if ($name.Length -gt $MAX_NAME_LEN) { return }
if ($path.Length -gt $MAX_PATH_LEN) { return }
# Reverse-open shims (#48 / v0.5.0) live under
# C:\Users\Public\winpodx\reverse-open\bin\. They are Windows .exe
# entries created by winpodx itself to surface Linux host apps in
# the Windows "Open with..." menu and must not be returned as
# Windows apps. Match the directory fragment rather than the full
# path so a future layout change still catches the entries.
$pathLower = $path.ToLower()
if ($pathLower -like '*\winpodx\reverse-open\bin\*') { return }
$key = if ($Entry.launch_uri) { ([string]$Entry.launch_uri).ToLower() }
else { $pathLower }
if ($seen.ContainsKey($key)) { return }
$seen[$key] = $true
$results.Add([ordered]@{
name = $name
description = [string]$Entry.description
path = $path
args = [string]$Entry.args
source = [string]$Entry.source
wm_class_hint = [string]$Entry.wm_class_hint
launch_uri = [string]$Entry.launch_uri
icon_b64 = [string]$Entry.icon_b64
# #581 Goal 2: Start Menu subfolder (relative), '' for non-.lnk sources.
start_menu_folder = [string]$Entry.start_menu_folder
exe_hash = Get-ExeHash $path
# Union of every place Windows records what this app can open: the
# caller's declared associations (UWP AppxManifest fileTypeAssociation)
# AND the registry handler map (per-app Capabilities\FileAssociations,
# Applications\<exe>\SupportedTypes, and the per-extension UserChoice
# default). An app that isn't the *default* for a type still shows up
# in the Linux "Open with" list as long as it declares it anywhere.
extensions = @(
$extAcc = New-Object System.Collections.Generic.List[string]
foreach ($e in (@($Entry.extensions) + @(Get-AppExtensions $path $Entry.launch_uri))) {
$el = ([string]$e).ToLower()
if ($el -match '^\.[a-z0-9]{1,16}$' -and -not $extAcc.Contains($el)) { $extAcc.Add($el) }
}
if ($extAcc.Count -gt 64) { $extAcc.GetRange(0, 64) } else { $extAcc }
)
# URL schemes this app handles (#421/#694): union of the caller's declared
# schemes (UWP AppxManifest windows.protocol) + the registry map (UserChoice
# default + Capabilities\URLAssociations). Dangerous schemes dropped here
# (WinpodxSchemeDeny) and again host-side. Emitted as a JSON array like
# extensions; the host maps each to MimeType=x-scheme-handler/<scheme>.
url_schemes = @(
$schemeAcc = New-Object System.Collections.Generic.List[string]
foreach ($s in (@($Entry.url_schemes) + @(Get-AppUrlSchemes $path $Entry.launch_uri))) {
$sl = ([string]$s).ToLower().Trim().TrimEnd(':')
if ($sl -match '^[a-z][a-z0-9+.\-]{0,31}$' -and
-not $script:WinpodxSchemeDeny.Contains($sl) -and
-not $schemeAcc.Contains($sl)) { $schemeAcc.Add($sl) }
}
if ($schemeAcc.Count -gt 32) { $schemeAcc.GetRange(0, 32) } else { $schemeAcc }
)
}) | Out-Null
}
# v0.2.0 streaming progress: when the host wraps this script via
# windows_exec.run_in_windows with a progress_callback, the wrapper
# defines `Write-WinpodxProgress`. When run standalone (or via a wrapper
# that doesn't define it) the function is missing and the calls would
# error -- define a no-op shim that yields when the real one isn't
# available.
if (-not (Get-Command 'Write-WinpodxProgress' -ErrorAction SilentlyContinue)) {
function Write-WinpodxProgress($msg) { }
}
# --- Source 1: Registry App Paths --------------------------- [full-scan only]
# Skipped by default (#581): App Paths lists every registered exe (helpers,
# CLI tools, background processes) -- not what the Start Menu shows.
if ($WinpodxFullScan) {
Write-WinpodxProgress 'Scanning Registry App Paths...'
foreach ($hive in 'HKLM:', 'HKCU:') {
$root = Join-Path $hive 'Software\Microsoft\Windows\CurrentVersion\App Paths'
if (-not (Test-Path $root)) { continue }
try {
Get-ChildItem -Path $root -ErrorAction SilentlyContinue | ForEach-Object {
try {
$props = Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue
if (-not $props) { return }
$default = $props.'(default)'
if (-not $default) { return }
$exe = ([string]$default).Trim('"')
if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { return }
$stem = [System.IO.Path]::GetFileNameWithoutExtension($exe)
Add-Result @{
name = Get-DisplayName -ExePath $exe -Fallback $stem
description = Get-AppDescription $exe
path = $exe
args = ''
source = 'win32'
wm_class_hint = Get-WmClassHint $exe
launch_uri = ''
icon_b64 = ConvertTo-IconBase64 $exe
}
} catch { }
}
} catch { }
}
} # end if ($WinpodxFullScan) -- Source 1
# --- Source 2: Start Menu .lnk files ---------------------------------------
Write-WinpodxProgress 'Scanning Start Menu shortcuts...'
$startDirs = @()
if ($env:ProgramData) {
$startDirs += (Join-Path $env:ProgramData 'Microsoft\Windows\Start Menu\Programs')
}
try {
$userProfiles = Get-ChildItem -Path 'C:\Users' -Directory -ErrorAction SilentlyContinue
foreach ($u in $userProfiles) {
if ($u.Name -in @('Default', 'Default User', 'Public', 'All Users')) { continue }
$p = Join-Path $u.FullName 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs'
if (Test-Path -LiteralPath $p) { $startDirs += $p }
}
} catch { }
$wsh = $null
try { $wsh = New-Object -ComObject WScript.Shell } catch { }
foreach ($d in $startDirs) {
if (-not $wsh) { break }
try {
# L3 hardening: bound recursion depth. Start Menu\Programs layouts
# with symlink loops or pathologically deep nesting could otherwise
# stall the guest until the host-side 120s timeout fires. PowerShell
# 5.1+ honors -Depth on Get-ChildItem. The MAX_APPS post-filter
# still caps absolute output size.
Get-ChildItem -Path $d -Recurse -Depth 8 -Filter '*.lnk' -ErrorAction SilentlyContinue |
ForEach-Object {
try {
if ($_.Name -match '(?i)uninstall|readme|license|eula') { return }
$lnk = $wsh.CreateShortcut($_.FullName)
$target = [string]$lnk.TargetPath
# Advertised (MSI/Darwin) shortcuts: .TargetPath resolves to a
# shared icon stub under C:\Windows\Installer\, collapsing all
# of Office into one dead entry (#680). Resolve the real
# installed exe via the Installer API; normal (Click-to-Run /
# direct) shortcuts return '' and keep their plain target.
$advTarget = Resolve-AdvertisedShortcut $_.FullName
if ($advTarget) { $target = $advTarget }
if (-not $target) { return }
if ($target -notmatch '\.exe$') { return }
if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { return }
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($_.Name)
# .lnk shortcuts have a Description property (the Comment
# field in shortcut Properties); prefer it when set since
# it's user-curated, then fall back to exe metadata.
$lnkDesc = ''
try { $lnkDesc = [string]$lnk.Description } catch { $lnkDesc = '' }
if (-not $lnkDesc) { $lnkDesc = Get-AppDescription $target }
# #581 Goal 2: the Start Menu subfolder this .lnk lives in,
# relative to its Programs root ($d). e.g. a shortcut at
# ...\Programs\Microsoft Office\Tools\Foo.lnk -> "Microsoft
# Office\Tools"; a top-level shortcut -> "". The host mirrors
# this into a nested winpodx submenu. Only the relative
# subpath is emitted -- never the absolute / user path.
$smFolder = ''
try {
$lnkDir = [System.IO.Path]::GetDirectoryName($_.FullName)
if ($lnkDir -and $lnkDir.Length -gt $d.Length -and
$lnkDir.ToLower().StartsWith($d.ToLower())) {
$smFolder = $lnkDir.Substring($d.Length).Trim('\').Trim('/')
}
} catch { $smFolder = '' }
Add-Result @{
name = Get-DisplayName -ExePath $target -Fallback $baseName
description = $lnkDesc
path = $target
args = [string]$lnk.Arguments
source = 'win32'
wm_class_hint = Get-WmClassHint $target
launch_uri = ''
icon_b64 = ConvertTo-IconBase64 $target
start_menu_folder = $smFolder
}
} catch { }
}
} catch { }
}
# --- Source 3: UWP / MSIX packages -----------------------------------------
Write-WinpodxProgress 'Scanning UWP / MSIX packages...'
try {
$pkgs = Get-AppxPackage -AllUsers -ErrorAction SilentlyContinue
# Canonical Start-menu names keyed by AUMID. Many UWP manifests give the
# display name as an `ms-resource:` indirection that PowerShell can't resolve
# non-interactively -- the package name then falls back to the dotted
# PackageFamilyName (e.g. "Microsoft.Windows.Photos"), which the host junk
# filter drops. Get-StartApps resolves the real label ("Photos"), so real
# apps aren't lost and we don't depend on a hardcoded allowlist (#545).
$startAppNames = @{}
try {
Get-StartApps -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.AppID) { $startAppNames[[string]$_.AppID] = [string]$_.Name }
}
} catch { }
foreach ($pkg in $pkgs) {
try {
if ($pkg.IsFramework) { continue }
if ($pkg.SignatureKind -eq 'System') { continue }
if (-not $pkg.InstallLocation) { continue }
$manifestPath = Join-Path $pkg.InstallLocation 'AppxManifest.xml'
if (-not (Test-Path -LiteralPath $manifestPath)) { continue }
[xml]$manifest = Get-Content -LiteralPath $manifestPath -ErrorAction SilentlyContinue
if (-not $manifest) { continue }
$apps = $null
try { $apps = $manifest.Package.Applications.Application } catch { $apps = $null }
if (-not $apps) { continue }
if ($apps -isnot [System.Collections.IEnumerable]) { $apps = @($apps) }
foreach ($appNode in $apps) {
try {
$appId = [string]$appNode.Id
if (-not $appId) { continue }
# Emit bare AUMID only. The host-side FreeRDP command
# builder (src/winpodx/core/rdp.py) prepends
# `shell:AppsFolder\` itself; duplicating the prefix
# here would produce `shell:AppsFolder\shell:AppsFolder\...`.
$aumid = "$($pkg.PackageFamilyName)!$appId"
# #581: in the default Start-Menu-only mode, keep only UWP
# apps the Start Menu actually shows (AUMID in Get-StartApps).
# Drops background/headless packages. Fall back to emitting
# all when Get-StartApps came back empty (non-interactive
# session) so we never hide every UWP app.
if (-not $WinpodxFullScan -and $startAppNames.Count -gt 0 `
-and -not $startAppNames.ContainsKey($aumid)) {
continue
}
$ve = $null
foreach ($probe in 'VisualElements', 'uap:VisualElements') {
try {
$probed = $appNode.$probe
if ($probed) { $ve = $probed; break }
} catch { }
}
$displayName = [string]$pkg.Name
$description = ''
if ($ve) {
$dn = [string]$ve.DisplayName
if ($dn -and ($dn -notmatch '^ms-resource:')) {
$displayName = $dn
}
# Canonical Start-menu name wins (resolves ms-resource
# display names so e.g. Photos isn't dropped as dotted junk).
if ($startAppNames.ContainsKey($aumid)) {
$displayName = $startAppNames[$aumid]
}
# AppxManifest's <VisualElements Description="..."> is the
# Start-menu tooltip -- exactly what we want for the
# Linux .desktop Comment field. Skip ms-resource:
# indirections that PowerShell can't resolve in a
# non-interactive session.
$desc = [string]$ve.Description
if ($desc -and ($desc -notmatch '^ms-resource:')) {
$description = $desc.Trim()
}
}
# Fall back to the package-level <Properties><Description>.
if (-not $description) {
try {
$pkgDesc = [string]$manifest.Package.Properties.Description
if ($pkgDesc -and ($pkgDesc -notmatch '^ms-resource:')) {
$description = $pkgDesc.Trim()
}
} catch { }
}
$logoRel = ''
if ($ve) {
foreach ($attr in 'Square44x44Logo', 'Square30x30Logo', 'SmallLogo', 'Logo') {
try {
$val = [string]$ve.$attr
if ($val) { $logoRel = $val; break }
} catch { }
}
}
$iconB64 = ''
if ($logoRel) {
$logoPath = Join-Path $pkg.InstallLocation $logoRel
$iconB64 = Read-IconBytesFromFile $logoPath
if (-not $iconB64) {
$parent = [System.IO.Path]::GetDirectoryName($logoPath)
$stem = [System.IO.Path]::GetFileNameWithoutExtension($logoPath)
$ext = [System.IO.Path]::GetExtension($logoPath)
foreach ($scale in '100', '200', '400') {
$scaled = Join-Path $parent "$stem.scale-$scale$ext"
$iconB64 = Read-IconBytesFromFile $scaled
if ($iconB64) { break }
}
}
}
# File associations the app DECLARES it can open (#545) --
# the AppxManifest's windows.fileTypeAssociation. This is the
# full set the app handles (Paint -> .png/.jpg/..., Photos ->
# all images), not just whatever it's the *default* for. The
# manifest is already parsed, so this is free.
$uwpExts = New-Object System.Collections.Generic.List[string]
try {
foreach ($ex in @($appNode.Extensions.Extension)) {
if (([string]$ex.Category) -notmatch 'fileTypeAssociation') { continue }
foreach ($fta in @($ex.FileTypeAssociation)) {
foreach ($ft in @($fta.SupportedFileTypes.FileType)) {
$v = [string]$ft.'#text'
if (-not $v) { $v = [string]$ft }
$v = $v.Trim().ToLower()
if ($v -and -not $v.StartsWith('.')) { $v = ".$v" }
if ($v -match '^\.[a-z0-9]{1,16}$' -and -not $uwpExts.Contains($v)) {
$uwpExts.Add($v)
}
}
}
}
} catch { }
if ($uwpExts.Count -gt 64) { $uwpExts = $uwpExts.GetRange(0, 64) }
# URL schemes the UWP app DECLARES via the manifest's
# windows.protocol extension (uap:Protocol Name="..."). URL
# analog of the fileTypeAssociation harvest above; free from the
# already-parsed manifest. Add-Result unions it with the
# registry map + denylist.
$uwpSchemes = New-Object System.Collections.Generic.List[string]
try {
foreach ($ex in @($appNode.Extensions.Extension)) {
if (([string]$ex.Category) -notmatch 'windows\.protocol') { continue }
foreach ($proto in @($ex.Protocol)) {
$nm = ([string]$proto.Name).Trim().ToLower()
if ($nm -match '^[a-z][a-z0-9+.\-]{0,31}$' -and -not $uwpSchemes.Contains($nm)) {
$uwpSchemes.Add($nm)
}
}
}
} catch { }
# path must be non-empty per core contract; use InstallLocation as placeholder.
Add-Result @{
name = $displayName
description = $description
path = [string]$pkg.InstallLocation
args = ''
source = 'uwp'
wm_class_hint = ''
launch_uri = $aumid
icon_b64 = $iconB64
extensions = @($uwpExts)
url_schemes = @($uwpSchemes)
}
} catch { }
}
} catch { }
}
} catch { }
# --- Source 4: Chocolatey + Scoop shims --------------------- [full-scan only]
# Skipped by default (#581): shims are CLI tools, rarely Start Menu apps.
if ($WinpodxFullScan) {
Write-WinpodxProgress 'Scanning Chocolatey + Scoop shims...'
$shimDirs = @()
if ($env:ProgramData) {
$shimDirs += (Join-Path $env:ProgramData 'chocolatey\bin')
$shimDirs += (Join-Path $env:ProgramData 'scoop\shims')
}
if ($env:USERPROFILE) {
$shimDirs += (Join-Path $env:USERPROFILE 'scoop\shims')
}
foreach ($d in $shimDirs) {
if (-not (Test-Path -LiteralPath $d)) { continue }
try {
Get-ChildItem -Path $d -Filter '*.exe' -ErrorAction SilentlyContinue | ForEach-Object {
try {
$resolved = $_.FullName
try {
$cmd = Get-Command -Name $_.BaseName -CommandType Application -ErrorAction SilentlyContinue
if ($cmd -and $cmd.Source -and (Test-Path -LiteralPath $cmd.Source -PathType Leaf)) {
$resolved = $cmd.Source
}
} catch { }
Add-Result @{
name = Get-DisplayName -ExePath $resolved -Fallback $_.BaseName
description = Get-AppDescription $resolved
path = $resolved
args = ''
source = 'win32'
wm_class_hint = Get-WmClassHint $resolved
launch_uri = ''
icon_b64 = ConvertTo-IconBase64 $resolved
}
} catch { }
}
} catch { }
}
} # end if ($WinpodxFullScan) -- Source 4
# --- Source 5: Essentials (always emit) ------------------------------------
#
# OS staples (File Explorer, Calculator, Settings) sometimes fall through
# the previous sources -- File Explorer has no Start Menu .lnk, and UWP apps
# whose DisplayName is an unresolved ms-resource: lookup get filtered as
# junk by the host because their fallback name is a dotted package id.
# Emit them explicitly here with proper icons + launch args so the host
# always shows them in the app menu without resorting to synthesized stubs.
Write-WinpodxProgress 'Emitting essential apps (File Explorer / Calculator / Settings)...'
# File Explorer -- must launch with a shell: argument so RemoteApp opens a
# window instead of trying to take over as the user shell. ``shell:MyComputerFolder``
# opens the "This PC" view; the cmd: side propagates as explorer.exe args.
try {
$explorer = Join-Path $env:WINDIR 'explorer.exe'
if (Test-Path -LiteralPath $explorer) {
# Pull the real description from explorer.exe's VersionInfo via the
# same Get-AppDescription helper Source 1-4 use -- no hardcoded
# string. Stock Win11 returns "Microsoft(R) Windows(R) Operating
# System" (ProductName, since it differs from FileDescription
# "Windows Explorer").
Add-Result @{
name = 'File Explorer'
description = Get-AppDescription $explorer
path = $explorer
args = 'shell:MyComputerFolder'
source = 'win32'
wm_class_hint = 'explorer'
launch_uri = ''
icon_b64 = ConvertTo-IconBase64 $explorer
}
}
} catch { }
# Resolve a UWP package by family-name prefix and emit the entry, pulling
# the icon from the same AppxManifest path the main UWP scan uses. Skips
# silently if the package isn't installed (some Windows SKUs ship without
# Calculator on the Server image, for example).
function Emit-EssentialUwp([string]$FamilyPrefix, [string]$DisplayName, [string]$AppId, [string]$WmClassHint, [string]$DefaultDescription = '') {
try {
$pkg = Get-AppxPackage -AllUsers -ErrorAction SilentlyContinue |
Where-Object { $_.PackageFamilyName -like "$FamilyPrefix*" } |
Select-Object -First 1
if (-not $pkg) { return }
if (-not $pkg.InstallLocation) { return }
$manifestPath = Join-Path $pkg.InstallLocation 'AppxManifest.xml'
if (-not (Test-Path -LiteralPath $manifestPath)) { return }
[xml]$manifest = Get-Content -LiteralPath $manifestPath -ErrorAction SilentlyContinue
if (-not $manifest) { return }
$aumid = "$($pkg.PackageFamilyName)!$AppId"
# Mine the same Square logo path the main UWP block uses so the
# icon matches what users see in Start Menu.
$logoRel = ''
try {
$apps = $manifest.Package.Applications.Application
if ($apps -isnot [System.Collections.IEnumerable]) { $apps = @($apps) }
foreach ($appNode in $apps) {
if ([string]$appNode.Id -ne $AppId) { continue }
$ve = $null
foreach ($probe in 'VisualElements', 'uap:VisualElements') {
try {
$probed = $appNode.$probe
if ($probed) { $ve = $probed; break }
} catch { }
}
if ($ve) {
foreach ($attr in 'Square44x44Logo', 'Square30x30Logo', 'SmallLogo', 'Logo') {
try {
$val = [string]$ve.$attr
if ($val) { $logoRel = $val; break }
} catch { }
}
}
break
}
} catch { }
$iconB64 = ''
if ($logoRel) {
$logoPath = Join-Path $pkg.InstallLocation $logoRel
$iconB64 = Read-IconBytesFromFile $logoPath
if (-not $iconB64) {
$parent = [System.IO.Path]::GetDirectoryName($logoPath)
$stem = [System.IO.Path]::GetFileNameWithoutExtension($logoPath)
$ext = [System.IO.Path]::GetExtension($logoPath)
foreach ($scale in '100', '200', '400') {
$scaled = Join-Path $parent "$stem.scale-$scale$ext"
$iconB64 = Read-IconBytesFromFile $scaled
if ($iconB64) { break }
}
}
}
# Mine the AppxManifest for a real description if available;
# ms-resource: indirections fall back to a sensible default.
$emitDesc = ''
try {
$apps = $manifest.Package.Applications.Application
if ($apps -isnot [System.Collections.IEnumerable]) { $apps = @($apps) }
foreach ($appNode in $apps) {
if ([string]$appNode.Id -ne $AppId) { continue }
$ve = $null
foreach ($probe in 'VisualElements', 'uap:VisualElements') {
try { if ($appNode.$probe) { $ve = $appNode.$probe; break } } catch { }
}
if ($ve) {
$d = [string]$ve.Description
if ($d -and ($d -notmatch '^ms-resource:')) { $emitDesc = $d.Trim() }
}
break
}
} catch { }
# Fall back to a curated one-liner when the manifest's Description
# was an ms-resource: indirection (PowerShell can't resolve those
# in a non-interactive session, so we'd otherwise stamp the
# generic 'Windows application via winpodx' for staples).
if (-not $emitDesc -and $DefaultDescription) {
$emitDesc = $DefaultDescription
}
Add-Result @{
name = $DisplayName
description = $emitDesc
path = [string]$pkg.InstallLocation
args = ''
source = 'uwp'
wm_class_hint = $WmClassHint
launch_uri = $aumid
icon_b64 = $iconB64
}
} catch { }
}
Emit-EssentialUwp 'Microsoft.WindowsCalculator_' 'Calculator' 'App' 'calculator' `
'Calculator app from the Windows guest'
Emit-EssentialUwp 'windows.immersivecontrolpanel_' 'Settings' 'microsoft.windows.immersivecontrolpanel' 'settings' `
'Open the Windows guest Settings panel'
# --- Emit JSON -------------------------------------------------------------
$output = $results.ToArray()
if ($results.Count -ge $MAX_APPS) {
$output = $output + [ordered]@{ _truncated = $true }
}
# @(...) forces array encoding on PowerShell 5.1 even if the array has
# exactly one element (Compress otherwise emits a bare object).
ConvertTo-Json -InputObject @($output) -Depth 6 -Compress