Articles on: RemoteApp

How to force a custom RemoteApp to use a custom Icon

The problem


You create a shortcut in the Start Menu, set a custom icon on it, publish it as a RemoteApp, and the portal shows the icon of the target program instead.


This is most common when the shortcut points at a browser or another shared executable: a shortcut to msedge.exe that opens an internal web app gets the Edge icon, a shortcut to ONENOTE.EXE under a different name gets the OneNote icon, and so on.


Why it happens


Published apps are enumerated from the Win32_TSStartMenuApplication WMI class on the session host. You can see exactly what gets collected:
powershell

Get-CimInstance -Namespace root\cimv2\terminalservices `
-ClassName Win32_TSStartMenuApplication |
Select-Object Name, Path, IconPath, IconIndex, CommandLineArguments


The class exposes an IconPath field, and your custom icon does land in it. But the icon shown in the portal comes from the executable in Path, not from IconPath.


The fix


Give the app its own executable to point at, with the icon compiled into it. The executable does nothing except launch the real program.


Run this on the session host in an elevated PowerShell session. Edit the five variables at the top.


powershell


#requires -RunAsAdministrator

$Name = "My App" # published app name
$Ico = "C:\ProgramData\MyApp.ico" # icon, must be local
$Launch = "C:\Program Files\Some Vendor\app.exe" # program to run
$AppArg = "" # arguments, or leave empty
$Exe = "C:\ProgramData\MyApp.exe" # the launcher it creates

$dir = "C:\ProgramData\Microsoft\Windows\Start Menu\Programs"

# the entire launcher: start the target, wait for it to close
$src = @"
using System.Diagnostics;
class L {
static void Main() {
Process p = Process.Start(new ProcessStartInfo(
@"$Launch", @"$AppArg") { UseShellExecute = false });
if (p != null) p.WaitForExit();
}
}
"@
$cs = "$env:TEMP\launcher.cs"
Set-Content -Path $cs -Value $src -Encoding ASCII -Force

# compile with csc.exe, already present as part of .NET Framework.
# nothing is downloaded. /target:winexe means no console window.
& "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\csc.exe" /nologo /target:winexe `
/win32icon:"$Ico" /out:"$Exe" $cs

# Start Menu shortcut pointing at the launcher
$sh = New-Object -ComObject WScript.Shell
$lnk = $sh.CreateShortcut("$dir\$Name.lnk")
$lnk.TargetPath = $Exe
$lnk.Arguments = ""
$lnk.IconLocation = "$Exe,0"
$lnk.Save()

# confirm what will be collected
Get-CimInstance -Namespace root\cimv2\terminalservices `
-ClassName Win32_TSStartMenuApplication |
Where-Object Name -eq $Name | Select-Object Name, Path, IconPath


Then refresh the application list and publish it. If the app was already published against the old target, unpublish and republish it: the target path has changed, so it is a different entry.


Deploying to multiple servers


Do not run the compiler on every server. Build the launcher once and push the same binary everywhere, so every host has a file you can verify by hash.


Keep the launcher at an identical path on every server. The WMI class is keyed on the target path, so a consistent path gives you one consistent entry across the server group. Different paths give you separate entries to select per server.


The push uses PowerShell remoting, so every target server needs WinRM running and reachable on TCP 5985.



Check from the machine you will run the script on:


powershell

"SERVER1","SERVER2" | ForEach-Object {
Test-NetConnection $_ -Port 5985 |
Select-Object ComputerName, RemoteAddress, PingSucceeded, TcpTestSucceeded
}

TcpTestSucceeded must be True for every server. If ping works but the port does not, remoting is not set up on that host.


The script below asks what to build and where to send it, then does the build, the push and the verification in one run. Run it from a management workstation or one of the servers, in an elevated PowerShell session, with WinRM reachable on the targets.


powershell

#requires -RunAsAdministrator

function Ask([string]$Prompt, [string]$Default) {
if ($Default) { $label = "$Prompt [$Default]" } else { $label = $Prompt }
$v = Read-Host $label
if ([string]::IsNullOrWhiteSpace($v)) { return $Default }
return $v
}

# ---- questions ----
$Name = Ask "Published app name" "My App"
$Launch = Ask "Full path to the program to launch, as it exists on the servers" ""
$AppArg = Ask "Arguments to pass, blank for none" ""
$Ico = Ask "Path to the .ico file on THIS machine" ""
$Exe = Ask "Launcher path to use on every server" "C:\ProgramData\$($Name -replace '\s','').exe"
$list = Ask "Target servers: comma separated, or path to a text file" ""
$useCred = Ask "Use alternate credentials? (y/n)" "n"

if (Test-Path $list) { $servers = Get-Content $list | Where-Object { $_ } }
else { $servers = $list -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } }

if (-not (Test-Path $Ico)) { throw "Icon not found: $Ico" }
if (-not $servers) { throw "No servers given" }

$cred = $null
if ($useCred -match '^(y|yes)$') { $cred = Get-Credential }

$dir = "C:\ProgramData\Microsoft\Windows\Start Menu\Programs"
$build = Join-Path $env:TEMP "$($Name -replace '\s','').exe"

# ---- build once ----
$src = @"
using System.Diagnostics;
class L {
static void Main() {
Process p = Process.Start(new ProcessStartInfo(
@"$Launch", @"$AppArg") { UseShellExecute = false });
if (p != null) p.WaitForExit();
}
}
"@
$cs = Join-Path $env:TEMP "launcher.cs"
Set-Content -Path $cs -Value $src -Encoding ASCII -Force

& "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\csc.exe" /nologo /target:winexe `
/win32icon:"$Ico" /out:"$build" $cs
if (-not (Test-Path $build)) { throw "Build failed" }

$hash = (Get-FileHash $build).Hash
Write-Host "Built $build"
Write-Host "SHA256 $hash"

# ---- push ----
foreach ($s in $servers) {
Write-Host "--- $s"
try {
if ($cred) { $sess = New-PSSession -ComputerName $s -Credential $cred -ErrorAction Stop }
else { $sess = New-PSSession -ComputerName $s -ErrorAction Stop }

Copy-Item $build -Destination $Exe -ToSession $sess -Force

Invoke-Command -Session $sess -ScriptBlock {
param($Exe, $dir, $Name)
$sh = New-Object -ComObject WScript.Shell
$lnk = $sh.CreateShortcut("$dir\$Name.lnk")
$lnk.TargetPath = $Exe
$lnk.Arguments = ""
$lnk.IconLocation = "$Exe,0"
$lnk.Save()
} -ArgumentList $Exe, $dir, $Name

Remove-PSSession $sess
Write-Host " pushed"
}
catch { Write-Warning " $s failed: $($_.Exception.Message)" }
}

# ---- verify ----
Write-Host "`nVerification, expected hash $hash"
$sb = {
param($Exe)
$f = Get-Item $Exe -ErrorAction SilentlyContinue
$a = Get-CimInstance -Namespace root\cimv2\terminalservices `
-ClassName Win32_TSStartMenuApplication |
Where-Object Path -eq $Exe
[pscustomobject]@{
Hash = if ($f) { (Get-FileHash $f).Hash } else { "MISSING" }
InWMI = [bool]$a
AppName = $a.Name
}
}
if ($cred) {
Invoke-Command -ComputerName $servers -Credential $cred -ScriptBlock $sb `
-ArgumentList $Exe -ErrorAction SilentlyContinue |
Format-Table PSComputerName, Hash, InWMI, AppName
} else {
Invoke-Command -ComputerName $servers -ScriptBlock $sb `
-ArgumentList $Exe -ErrorAction SilentlyContinue |
Format-Table PSComputerName, Hash, InWMI, AppName
}


Every row should show the same hash as the build, InWMI true, and the same app name. Anything else is the server to go and look at.
Then refresh the application list and publish the app to the server group.


Signing


Unsigned binaries appearing across a whole fleet at once are a better way to trip antivirus or EDR than a single one on a single server. If you have a code signing certificate, sign the build before pushing:


powershell

Set-AuthenticodeSignature -FilePath $build -Certificate $cert


Sign before the push, or the deployed hashes will not match the one you recorded.


Group Policy as an alternative


The push loop is fine for a static fleet you want done now. If servers get rebuilt or added regularly, deploy it by Group Policy instead so new hosts pick it up on their own.


1. Put the built launcher somewhere every server can read. NETLOGON works, since Domain Computers can already read it:


powershell

Copy-Item "C:\Build\MyApp.exe" "\\yourdomain.com\NETLOGON\MyApp.exe" -Force


2. Save this as a startup script, for example \\yourdomain.com\NETLOGON\Deploy-MyApp.ps1. It copies the launcher down if it is missing or out of date, and creates the shortcut if it is missing or wrong. It is safe to run on every boot:


powershell

$Source = "\\yourdomain.com\NETLOGON\MyApp.exe"
$Exe = "C:\ProgramData\MyApp.exe"
$Name = "My App"
$dir = "C:\ProgramData\Microsoft\Windows\Start Menu\Programs"

if (-not (Test-Path $Source)) { exit }

$needCopy = $true
if (Test-Path $Exe) {
$needCopy = (Get-FileHash $Source).Hash -ne (Get-FileHash $Exe).Hash
}
if ($needCopy) { Copy-Item $Source $Exe -Force }

$lnkPath = "$dir\$Name.lnk"
$sh = New-Object -ComObject WScript.Shell
$current = ""
if (Test-Path $lnkPath) { $current = $sh.CreateShortcut($lnkPath).TargetPath }
if ($current -ne $Exe) {
$lnk = $sh.CreateShortcut($lnkPath)
$lnk.TargetPath = $Exe
$lnk.Arguments = ""
$lnk.IconLocation = "$Exe,0"
$lnk.Save()
}


3. Create the GPO and attach the script. In Group Policy Management, create a new GPO and edit it. Go to Computer Configuration, Policies, Windows Settings, Scripts (Startup/Shutdown), open Startup, switch to the PowerShell Scripts tab, add the script, and enter its full UNC path.
4. Link the GPO to the OU holding the session hosts.
5. Apply it. The script runs as SYSTEM at boot, so either reboot the servers or run it once by hand on each:


powershell

gpupdate /force

gpupdate alone does not trigger startup scripts. To apply without a reboot, run the script directly on each server, or use the push loop above for the servers that are already live and let the GPO cover rebuilds and new hosts.


To roll out a new icon or a changed target later, rebuild the launcher, copy it over the NETLOGON copy, and the hash check pulls it down on the next boot.


Notes


The icon file must be local. An icon on a UNC path cannot be read reliably by the process doing the collection. In the multi-server script the .ico is only read at build time, so it only needs to exist on the machine you build on.


Arguments live inside the launcher, not on the shortcut. That is deliberate. The target and its arguments are compiled in, so there is nothing for the publishing chain to lose. Arguments containing double quotes will break the generated code, so keep them simple or escape them by hand.


The launcher waits for the program to exit. A RemoteApp session ends when the launched process ends, so the launcher has to stay alive as long as the app does. Some programs hand off to an already-running instance and exit immediately, which closes the window. If that happens, the launcher needs to wait on the real process by name instead.


The executable is unsigned unless you sign it, so some antivirus and EDR products may quarantine it. Add an exclusion for the launcher's path if that happens.


To change the icon later, replace the .ico and rerun. The launcher is rebuilt in place and the published entry keeps working, since the path doesn't change.

Updated on: 15/10/2026

Was this article helpful?

Share your feedback

Cancel

Thank you!