How to verify what programs RemoteApp can discover
When the RemoteApp feature is enabled, the TruGrid Sentry will perform a discovery of installed applications on the specified Windows Servers. The discovered applications are then published to the TruGrid portal for assigned users to launch.
If a certain program is installed but not being discovered by the TruGrid Sentry, the first step in diagnosing this is to export the list of programs on the server in question, that are discoverable.
Log into the server, open PowerShell and run the below command:
gwmi Win32_TSStartMenuApplication -Namespace "root/cimv2/terminalservices" | Select-Object Name, Path | Sort-Object Name
If you see the application in the list but it is not being seen in the TruGrid portal, then run the below command to do a complete export of all the programs and send it to us for further analysis.
If you don't see the application in the list, please go to Add/Remove Programs and check if its visible there. It may not be installed using a traditional installer program.
To do a complete export for our support team, please open Powershell and run the below command. This will save it to a file. Please email the file to helpfiles@trugrid.com.
gwmi Win32_TSStartMenuApplication -Namespace "root/cimv2/terminalservices" | Select-Object * | Sort-Object Name > c:\export-programs-for-trugrid-support.txt
Troubleshooting: "an app's icon is missing or wrong in RemoteApp"
- Confirm Sentry is on the latest version.
- On the server, check the app's icon in Windows File Explorer. If Explorer shows the correct icon, RemoteApp will show the same one.
- Refresh it:
- Standard RemoteApp → turn RemoteApp off/on.
- Advanced Publishing → remove and re-add the application.
- Confirm the app is discoverable using the existing command in How to verify what programs RemoteApp can discover.
- Still wrong → email helpfiles@trugrid.com with the application name.
Checking all servers in a RemoteApp pool at once
When an application is published across several servers, for example a security group of session hosts, it must be discoverable on every server in that group and the published path must point to the same program on each of them. A user whose session lands on a server where the program is missing, or where the shortcut points elsewhere, will not get the application they expect even though it works for users on the other servers.
You can verify all servers in one run from any domain-joined machine, without logging into each server.
Prerequisites
The check reaches each server over the standard Windows management channels. On every server in the group, these must be allowed from the machine you run the check on:
- TCP 445 (File and Printer Sharing), to read the application file through the administrative share
- TCP 135 and RPC (Windows Management Instrumentation), to query what RemoteApp discovery sees
- TCP 5985 (Windows Remote Management), used automatically when WMI is not reachable
- The Remote Registry service running, for the allow-list column
If a server shows as UNREACHABLE or the discovery column reports an error, run this once on that server in an elevated PowerShell window:
Enable-NetFirewallRule -DisplayGroup “Windows Management Instrumentation (WMI)”
Enable-NetFirewallRule -DisplayGroup “File and Printer Sharing”
Set-Service RemoteRegistry -StartupType Automatic; Start-Service RemoteRegistry
Enable-PSRemoting -Force -SkipNetworkProfileCheck
Running the check
<#
.SYNOPSIS
Checks every server in a RemoteApp group for a published application, from one admin session.
.DESCRIPTION
Read-only. For each server it reports: reachability (TCP 445 / 3389), whether RemoteApp discovery
sees the application on that server (Win32_TSStartMenuApplication, the class the TruGrid Sentry reads),
the application path (exists, version, hash; for shortcuts the target and arguments; for .url the
address), and the RDS allow list (fDisabledAllowList and the entry for the application).
Ends with one verdict line per server: OK, MISSING, NOT DISCOVERED, UNREACHABLE.
How to run: open in PowerShell ISE and press F5. The script asks for the servers (or the AD security
group that holds them), the published application name, optionally its path, and an optional CSV file.
Run it as a domain administrator on any domain-joined machine. Windows PowerShell 5.1, no modules.
Prerequisites on each server being checked:
TCP 445 (File and Printer Sharing) file check via the administrative share
TCP 135 + RPC (Windows Management Instrumentation) discovery query
TCP 5985 (Windows Remote Management) used automatically when WMI is not reachable
Remote Registry service running allow-list check (WinRM is used as fallback)
.PARAMETER ComputerName
Servers to check. Prompted for when omitted.
.PARAMETER ADGroup
AD security group whose computer members are the servers to check. Prompted for when no servers are given.
.PARAMETER AppName
Published application name exactly as shown in the TruGrid portal. Prompted for when omitted.
.PARAMETER AppPath
Published application path. Optional; the path RemoteApp discovered on each server is checked regardless.
.PARAMETER Csv
Optional file to save the result rows to.
#>
[CmdletBinding()]
param(
[string[]]$ComputerName,
[string]$ADGroup,
[string[]]$AppPath = @(),
[string]$AppName = '',
[string]$Csv
)
$ErrorActionPreference = 'Continue'
# ---- interactive prompts when run without parameters (ISE F5, right-click Run) ----
if (-not $ComputerName -and -not $ADGroup) {
$in = Read-Host 'Servers to check, comma-separated (leave empty to use an AD security group instead)'
if ($in.Trim()) { $ComputerName = @($in -split '[,;\s]+' | Where-Object { $_ }) }
else { $ADGroup = (Read-Host 'AD security group that holds the servers').Trim() }
if (-not $ComputerName -and -not $ADGroup) { throw 'No servers and no group given.' }
}
if (-not $AppName -and $AppPath.Count -eq 0) {
$AppName = (Read-Host 'Published application name, exactly as shown in the TruGrid portal').Trim()
$in = Read-Host 'Published application path (Enter to skip; the path RemoteApp discovered on each server is used)'
if ($in.Trim()) { $AppPath = @($in.Trim()) }
if (-not $Csv) { $in = Read-Host 'Save results to CSV file (Enter to skip)'; if ($in.Trim()) { $Csv = $in.Trim() } }
}
if (-not $AppName -and $AppPath.Count -eq 0) { throw 'Give an application name or a path.' }
# ---- hosts ----
$Hosts = @()
if ($ADGroup) {
$g = ([ADSISearcher]"(&(objectCategory=group)(cn=$ADGroup))").FindOne()
if (-not $g) { throw "AD group '$ADGroup' not found." }
foreach ($dn in $g.Properties['member']) {
$m = ([ADSISearcher]"(distinguishedName=$dn)").FindOne()
if ($m -and $m.Properties['objectclass'] -contains 'computer') {
$h = "$($m.Properties['dnshostname'])"; if (-not $h) { $h = "$($m.Properties['cn'])" }
$Hosts += $h
}
}
}
$Hosts += @($ComputerName)
$Hosts = @($Hosts | Where-Object { $_ } | Sort-Object -Unique)
if ($Hosts.Count -eq 0) { throw 'No hosts resolved.' }
# ---- helpers ----
$Ws = New-Object -ComObject WScript.Shell
$EnvMap = @{ '%PROGRAMFILES%' = 'C:\Program Files'; '%PROGRAMFILES(X86)%' = 'C:\Program Files (x86)'; '%PROGRAMDATA%' = 'C:\ProgramData'; '%SYSTEMROOT%' = 'C:\Windows'; '%WINDIR%' = 'C:\Windows'; '%PUBLIC%' = 'C:\Users\Public'; '%SYSTEMDRIVE%' = 'C:' }
function To-Unc([string]$Computer, [string]$LocalPath) {
$p = "$LocalPath".Trim().Trim('"')
foreach ($k in $EnvMap.Keys) { $p = [regex]::Replace($p, [regex]::Escape($k), $EnvMap[$k], 'IgnoreCase') }
if ($p -match '^([A-Za-z]):\\(.*)$') { return "\\$Computer\$($Matches[1])`$\$($Matches[2])" }
if ($p -match '^\\\\') { return $p } # already UNC
return $null
}
function Test-Port([string]$Computer, [int]$Port, [int]$TimeoutMs = 2000) {
try { $c = New-Object System.Net.Sockets.TcpClient; $r = $c.BeginConnect($Computer, $Port, $null, $null); $ok = $r.AsyncWaitHandle.WaitOne($TimeoutMs) -and $c.Connected; $c.Close(); return $ok } catch { return $false }
}
function Probe-File([string]$Computer, [string]$LocalPath) {
$o = [ordered]@{ Path = $LocalPath; Unc = (To-Unc $Computer $LocalPath); Exists = $false; Version = ''; SHA256 = ''; LastWriteUtc = ''; LnkTarget = ''; LnkArgs = ''; LnkTargetExists = ''; Url = ''; Error = '' }
if (-not $o.Unc) { $o.Error = 'path is not a drive-letter or UNC path'; return $o }
try {
if (-not (Test-Path -LiteralPath $o.Unc -PathType Leaf)) { return $o }
$o.Exists = $true
$fi = Get-Item -LiteralPath $o.Unc
$o.Version = "$($fi.VersionInfo.FileVersion)"
$o.LastWriteUtc = $fi.LastWriteTimeUtc.ToString('u')
$o.SHA256 = (Get-FileHash -LiteralPath $o.Unc -Algorithm SHA256).Hash.Substring(0, 12)
switch ($fi.Extension.ToLowerInvariant()) {
'.lnk' {
$sc = $Ws.CreateShortcut($fi.FullName)
$o.LnkTarget = $sc.TargetPath; $o.LnkArgs = $sc.Arguments
$tu = To-Unc $Computer $sc.TargetPath
$o.LnkTargetExists = if ($tu) { Test-Path -LiteralPath $tu } else { 'n/a' }
}
'.url' { $o.Url = ((Get-Content -LiteralPath $o.Unc -ErrorAction SilentlyContinue | Where-Object { $_ -match '^URL=' }) -join ' ') }
}
} catch { $o.Error = $_.Exception.Message }
return $o
}
function Get-AllowList([string]$Computer) {
# remote registry first, WinRM fallback
$res = [ordered]@{ Source = ''; fDisabledAllowList = ''; Entries = @(); Error = '' }
try {
$rk = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $Computer)
$k = $rk.OpenSubKey('SOFTWARE\Microsoft\Windows NT\CurrentVersion\Terminal Server\TSAppAllowList')
if ($k) {
$res.Source = 'RemoteRegistry'
$res.fDisabledAllowList = "$($k.GetValue('fDisabledAllowList'))"
$apps = $k.OpenSubKey('Applications')
if ($apps) { foreach ($n in $apps.GetSubKeyNames()) { $a = $apps.OpenSubKey($n); $res.Entries += [pscustomobject]@{ Alias = $n; Name = "$($a.GetValue('Name'))"; Path = "$($a.GetValue('Path'))"; CommandLineSetting = "$($a.GetValue('CommandLineSetting'))"; RequiredCommandLine = "$($a.GetValue('RequiredCommandLine'))" } } }
return $res
}
$res.Error = 'TSAppAllowList key missing'
return $res
} catch { $rrErr = $_.Exception.Message }
try {
$r = Invoke-Command -ComputerName $Computer -ErrorAction Stop -ScriptBlock {
$b = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Terminal Server\TSAppAllowList'
$d = (Get-ItemProperty $b -ErrorAction SilentlyContinue).fDisabledAllowList
$e = @(Get-ChildItem "$b\Applications" -ErrorAction SilentlyContinue | ForEach-Object { $p = Get-ItemProperty $_.PSPath; [pscustomobject]@{ Alias = $_.PSChildName; Name = "$($p.Name)"; Path = "$($p.Path)"; CommandLineSetting = "$($p.CommandLineSetting)"; RequiredCommandLine = "$($p.RequiredCommandLine)" } })
[pscustomobject]@{ D = "$d"; E = $e }
}
$res.Source = 'WinRM'; $res.fDisabledAllowList = $r.D; $res.Entries = @($r.E)
} catch { $res.Error = "RemoteRegistry: $rrErr | WinRM: $($_.Exception.Message)" }
return $res
}
function Get-Discovered([string]$Computer) {
# What RemoteApp discovery sees on the host. Remote WMI (DCOM) first, WinRM fallback.
$res = [ordered]@{ Source = ''; Apps = @(); Error = '' }
try {
$res.Apps = @(Get-WmiObject -Class Win32_TSStartMenuApplication -Namespace 'root/cimv2/terminalservices' -ComputerName $Computer -ErrorAction Stop | Select-Object Name, Path)
$res.Source = 'WMI'; return $res
} catch { $wmiErr = $_.Exception.Message }
try {
$res.Apps = @(Invoke-Command -ComputerName $Computer -ErrorAction Stop -ScriptBlock { Get-WmiObject -Class Win32_TSStartMenuApplication -Namespace 'root/cimv2/terminalservices' | Select-Object Name, Path })
$res.Source = 'WinRM'
} catch { $res.Error = "WMI: $wmiErr | WinRM: $($_.Exception.Message)" }
return $res
}
# ---- main ----
$Rows = New-Object System.Collections.ArrayList
foreach ($h in $Hosts) {
Write-Host "Checking $h ..." -ForegroundColor Cyan
$smb = Test-Port $h 445; $rdp = Test-Port $h 3389
$allow = if ($smb -or $rdp) { Get-AllowList $h } else { [ordered]@{ Source = ''; fDisabledAllowList = ''; Entries = @(); Error = 'unreachable' } }
$disc = if ($smb -or $rdp) { Get-Discovered $h } else { [ordered]@{ Source = ''; Apps = @(); Error = 'unreachable' } }
$dmatch = @($disc.Apps | Where-Object { $a = $_; ($AppName -and "$($a.Name)" -ieq $AppName) -or (@($AppPath | Where-Object { $_.Trim('"') -ieq "$($a.Path)".Trim('"') }).Count -gt 0) })
$discSummary = if ($disc.Error) { "ERR: $($disc.Error)" } elseif ($dmatch.Count -eq 0) { "NOT DISCOVERED ($($disc.Apps.Count) apps discovered on host)" } else { ($dmatch | ForEach-Object { "Name='$($_.Name)' Path='$($_.Path)'" }) -join ' || ' }
$discovered = if ($disc.Error) { '?' } else { ($dmatch.Count -gt 0) }
$match = @($allow.Entries | Where-Object { $e = $_; ($AppName -and $e.Name -ieq $AppName) -or (@($AppPath | Where-Object { $_.Trim('"') -ieq $e.Path.Trim('"') }).Count -gt 0) })
$allowSummary = if ($allow.Error) { "ERR: $($allow.Error)" } elseif ($match.Count -eq 0) { "no entry for app (fDisabledAllowList=$($allow.fDisabledAllowList), $($allow.Entries.Count) entries)" } else { ($match | ForEach-Object { "[$($_.Alias)] Name='$($_.Name)' Path='$($_.Path)' CmdSetting=$($_.CommandLineSetting) ReqCmd='$($_.RequiredCommandLine)'" }) -join ' || ' }
$pathsToTest = @($AppPath) + @($match | ForEach-Object { $_.Path } | Where-Object { $_ }) + @($dmatch | ForEach-Object { "$($_.Path)" } | Where-Object { $_ })
$pathsToTest = @($pathsToTest | Where-Object { $_ } | ForEach-Object { $_.Trim('"') } | Sort-Object -Unique)
if ($pathsToTest.Count -eq 0) { $pathsToTest = @('(no path: app not discovered on this host and no -AppPath given)') }
foreach ($p in $pathsToTest) {
$f = if ($p -like '(no path*') { [ordered]@{ Path = $p; Exists = ''; Version = ''; SHA256 = ''; LastWriteUtc = ''; LnkTarget = ''; LnkArgs = ''; LnkTargetExists = ''; Url = ''; Error = '' } } elseif ($smb) { Probe-File $h $p } else { [ordered]@{ Path = $p; Exists = ''; Version = ''; SHA256 = ''; LastWriteUtc = ''; LnkTarget = ''; LnkArgs = ''; LnkTargetExists = ''; Url = ''; Error = 'SMB 445 closed' } }
[void]$Rows.Add([pscustomobject]@{
Host = $h; SMB = $smb; RDP = $rdp
Discovered = $discovered; DiscoverySource = $disc.Source; DiscoveredEntry = $discSummary
Path = $p; Exists = $f.Exists; Version = $f.Version; SHA256 = $f.SHA256; LastWriteUtc = $f.LastWriteUtc
LnkTarget = $f.LnkTarget; LnkArgs = $f.LnkArgs; LnkTargetExists = $f.LnkTargetExists; Url = $f.Url; FileError = $f.Error
fDisabledAllowList = $allow.fDisabledAllowList; AllowListSource = $allow.Source; AllowListEntry = $allowSummary
})
}
}
# ---- output ----
Write-Host ''
$Rows | Format-Table Host, SMB, RDP, Discovered, Path, Exists, Version, SHA256, LnkTarget, LnkArgs, LnkTargetExists, fDisabledAllowList -AutoSize -Wrap
Write-Host 'RemoteApp discovery per host (Win32_TSStartMenuApplication):' -ForegroundColor Cyan
$Rows | Select-Object Host, DiscoverySource, DiscoveredEntry -Unique | Format-Table -AutoSize -Wrap
Write-Host 'Allow list per host:' -ForegroundColor Cyan
$Rows | Select-Object Host, AllowListSource, fDisabledAllowList, AllowListEntry -Unique | Format-Table -AutoSize -Wrap
Write-Host 'Verdict:' -ForegroundColor Cyan
foreach ($h in $Hosts) {
$hr = @($Rows | Where-Object Host -eq $h)
if ($hr.Count -eq 0 -or -not ($hr[0].SMB)) { Write-Host " $h : UNREACHABLE (445 closed)" -ForegroundColor Red; continue }
$missing = @($hr | Where-Object { $_.Path -notlike '(no path*' -and ($_.Exists -ne $true -or $_.LnkTargetExists -eq $false) } | ForEach-Object { $_.Path })
$notDisc = ($hr[0].Discovered -eq $false)
if ($missing.Count -gt 0) { Write-Host (" {0} : MISSING -> {1}" -f $h, ($missing -join ' ; ')) -ForegroundColor Red }
elseif ($notDisc) { Write-Host " $h : paths present but NOT DISCOVERED by RemoteApp (check Start Menu shortcut, then toggle RemoteApp off/on)" -ForegroundColor Yellow }
elseif ($hr[0].Discovered -eq '?') { Write-Host " $h : paths present, discovery not readable ($($hr[0].DiscoveredEntry))" -ForegroundColor Yellow }
else { Write-Host " $h : OK (discovered, all paths present)" -ForegroundColor Green }
}
if ($Csv) { $Rows | Export-Csv -Path $Csv -NoTypeInformation -Encoding UTF8; Write-Host "CSV: $Csv" }
- Save the script above and save it as TG-RemoteAppPathCheck.ps1 .
- Log into any domain-joined machine as a domain administrator. Right-click the file and choose Edit. It opens in PowerShell ISE.
- Press F5. Answer the prompts:
- the servers to check, comma-separated, or leave empty and give the AD security group that holds them
- the published application name, exactly as shown in the TruGrid portal
- the application path, or press Enter to use the path RemoteApp discovered on each server
- a CSV file to save the results to, or press Enter to skip
- Read the result. For every server the output shows whether the application is discoverable (the same list the Sentry reads), whether the path exists, its version and hash, and for shortcuts the target program and its arguments. The last lines give one verdict per server:
- OK: discovered, all paths present.
- MISSING: the path, or the shortcut’s target, does not exist on that server.
- NOT DISCOVERED: the file exists but is not in that server’s discoverable list. Make sure the program has a Start Menu shortcut under All Users, then refresh RemoteApp.
- UNREACHABLE: the server did not answer on TCP 445. See Prerequisites.
- On every server marked MISSING, install the program or recreate the shortcut so that path, target and arguments match a server marked OK. Then toggle RemoteApp off and on (Standard) or remove and re-add the application (Advanced Publishing) so the Sentry picks up the updated list.
Updated on: 26/10/2026
Thank you!
