From d78188c70b36e323fa54f2a337a965be4543ebd5 Mon Sep 17 00:00:00 2001
From: SaltSpectre <200460916+SaltSpectre@users.noreply.github.com>
Date: Wed, 29 Apr 2026 13:59:44 -0400
Subject: [PATCH] feat(app): add background RDP and RemoteApp keepalive
Replace foreground activation for RDP and RemoteApp windows with background mouse movement. Add child-window targeting for RDP, top-level RemoteApp targeting, minimized-window skipping, and remove unused RemoteApp whitelist behavior.
Also remove Hyper-V support, clean up unused exploratory files/helpers, and update README/WiX metadata.
---
README.md | 18 +++-----
config.json | 8 +---
skhost.ps1 | 115 +++++++++++++++++++++++++++++++++++++++++-------
user32.cs | 9 +++-
version.txt | 2 +-
wix/Product.wxs | 4 +-
6 files changed, 120 insertions(+), 36 deletions(-)
diff --git a/README.md b/README.md
index 5733e41..4037358 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
-# skHost: An idle prevention tool for local, RDP, RemoteApp, and Hyper-V sessions written entirely in PowerShell for maximum portability.
+# skHost: An idle prevention tool for local, RDP, and RemoteApp sessions written entirely in PowerShell for maximum portability.
## Description
skHost is a Windows tool that runs in the background to keep a system alive. It does this by simulating keyboard input (Default: F15) and mouse movement every 4 minutes (240 seconds). The mouse cursor does not visibly move but the input is registered by the system. *New: Simulated keyboard input is now sent to an ephemeral window ("skSink") to reduce interference with other applications, especially WSL & SSH sessions to Linux hosts.*
-In addition to keeping a physical machine session active, skHost will also bring forward all non-minimized RDP, (whitelisted) RemoteApp, and Hyper-V VM windows to simulate mouse movement at the same interval. This keeps these sessions from timing out. For RemoteApp windows, skHost uses a configurable whitelist to determine which windows to activate.
+In addition to keeping a physical machine session active, skHost will also send background mouse movement to non-minimized RDP and RemoteApp windows at the same interval. This keeps these sessions from timing out without bringing them to the foreground.
-skHost is designed to run on the **client side** and is **transparent to the RDP or Hyper-V session**, requiring no installation or configuration on the remote host.
+skHost is designed to run on the **client side** and is **transparent to the RDP session**, requiring no installation or configuration on the remote host.
skHost runs in a background PowerShell process that exists in a hidden window. It creates a system tray icon that is used for a visual cue that the process is running and provides an interaction point in order to terminate the process without having to resort to the Task Manager. Ideally, skHost never gets in the way of your work and is never accidentally terminated when closing extraneous windows on your desktop.
@@ -78,7 +78,7 @@ This parameter will remove the shortcut and autostart entries as applicable and
```
### Changing the keystroke
-By default it will use Ctrl+Shift+F15 as the keystroke that is sent to the system. I have experimented with several different keystrokes and found many to be problematic. It is impossible to identify a universally safe keystroke, so if you find this to be problematic, you can change the keystroke by modifying the `skHostKeystroke` variable in `config.json`. The script will validate the keystroke value and revert back to Ctrl+Shift+F15 if the configured value is invalid.
+By default it will use F15 as the keystroke that is sent to the system. I have experimented with several different keystrokes and found many to be problematic. It is impossible to identify a universally safe keystroke, so if you find this to be problematic, you can change the keystroke by modifying the `skHostKeystroke` variable in `config.json`. The script will validate the keystroke value and revert back to Ctrl+Shift+F15 if the configured value is invalid.
For a list of all possible keystrokes, refer to the [SendKeys Class documentation on Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.sendkeys).
@@ -89,23 +89,19 @@ By default the script will invoke the keep-alive logic every 240 seconds (4 minu
skHost requires the following files to operate properly:
- `skhost.ps1` - The main script
-- `config.json` - Contains the RemoteApp whitelist configuration
+- `config.json` - Contains runtime configuration
- `user32.cs` - C# definitions for Windows API functions
- `mouse.cs` - C# definitions for mouse movement simulation
- `manifest.txt` - List of files to be included in installation
- `version.txt` - Used for the versioning string for the systray icon tooltip
-#### RemoteApp Whitelist
-By default, skHost will not activate every RemoteApp window to prevent interference with specific applications. You can modify the whitelist in `config.json` to include the RemoteApp windows you want to keep active. `skRAHelper` is included in the default configuration and is reserved for future use.
-
#### Custom Icon
A custom icon file can be included in the same directory as the script to display in the system tray. The icon must be in `.ico` format.
### Known Issues
- This script will not work with [Constrained Language Mode](https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode/). CLM and this script are wholly incompatible, and there are no plans to support it in the future.
-- When using this script to keep an RDP, RemoteApp window, and/or Hyper-V session active, the window ***cannot be minimized***. It does not have to be the focused window as the script will grab focus momentarily, but it will not restore a remote session window for focus. If minimizing the window is something you feel is a must, consider using a virtual desktop (`Win + Tab`, not a VM) instead. When windows are running on a separate virtual desktop, the logic still works, but the window will not flash or take up space on the current desktop.
-- For RDP, RemoteApp, and Hyper-V, the script **MUST** be running on the host (aka your computer). The session will not remain active if the script is running in the remote session.
-- RDP, RemoteApp, and Hyper-V windows will flash when focus is grabbed, however this will be a momentary disruption.
+- When using this script to keep an RDP or RemoteApp window active, the window ***cannot be minimized***. It does not have to be the focused window. It can be on a separate virtual desktop if visibility is not desired.
+- For RDP and RemoteApp, the script **MUST** be running on the host (e.g. your computer). The session will not remain active if the script is running in the remote session.
---
diff --git a/config.json b/config.json
index 582fa0f..9723ba8 100644
--- a/config.json
+++ b/config.json
@@ -1,8 +1,4 @@
-{
- "remoteAppWhitelist": [
- "Teams",
- "skRAHelper"
- ],
+{
"skHostKeystroke": "{F15}",
"loopIntervalSeconds": 240
-}
\ No newline at end of file
+}
diff --git a/skhost.ps1 b/skhost.ps1
index dd4a88a..1a16d0c 100644
--- a/skhost.ps1
+++ b/skhost.ps1
@@ -150,13 +150,95 @@ Function Get-WindowsByClass {
return $EnumeratedWindows
}
-# Things get weird if every window in a RemoteApp session is called to the foreground. Define specific windows.
-$RemoteAppWhiteList = $Config.remoteAppWhitelist
+Function Get-ChildWindows {
+ [CmdletBinding()]
+ param ( [Parameter(Mandatory = $true)] [IntPtr] $ParentHandle )
+
+ $EnumeratedWindows = New-Object System.Collections.ArrayList
+ $enumWindows = {
+ param($hWnd, $lParam)
+ $windowInfo = Get-WindowInformation -Handle $hWnd
+ $EnumeratedWindows.Add($windowInfo) | Out-Null
+ return $true
+ }
+ [User32]::EnumChildWindows($ParentHandle, $enumWindows, [IntPtr]::Zero) | Out-Null
+ return $EnumeratedWindows
+}
+
+Function New-MousePointLParam {
+ param (
+ [Parameter(Mandatory = $true)] [int] $X,
+ [Parameter(Mandatory = $true)] [int] $Y
+ )
+
+ return [IntPtr](($Y -shl 16) -bor ($X -band 0xFFFF))
+}
+
+Function Send-BackgroundMouseMove {
+ param (
+ [Parameter(Mandatory = $true)] $Handle,
+ [Parameter(Mandatory = $true)] [int] $X,
+ [Parameter(Mandatory = $true)] [int] $Y
+ )
+
+ [User32]::PostMessage($Handle, 0x0200, [IntPtr]::Zero, (New-MousePointLParam -X $X -Y $Y)) | Out-Null
+}
+
+Function Send-BackgroundMouseJiggle {
+ param ( [Parameter(Mandatory = $true)] $Handle )
+
+ Send-BackgroundMouseMove -Handle $Handle -X 10 -Y 10
+ Start-Sleep -Milliseconds 100
+ Send-BackgroundMouseMove -Handle $Handle -X 11 -Y 10
+ Start-Sleep -Milliseconds 100
+ Send-BackgroundMouseMove -Handle $Handle -X 10 -Y 10
+}
+
+Function Test-WindowIsForegroundRoot {
+ param (
+ [Parameter(Mandatory = $true)] $WindowHandle,
+ [Parameter(Mandatory = $true)] $ForegroundWindowHandle
+ )
+
+ $ForegroundRoot = [User32]::GetAncestor($ForegroundWindowHandle, 2)
+ return $ForegroundRoot -eq $WindowHandle
+}
+
+Function Invoke-BackgroundSessionMouseInput {
+ param ( [Parameter(Mandatory = $true)] $Window )
+
+ if ($Window.Class -eq "TscShellContainerClass") {
+ Send-BackgroundMouseJiggle -Handle $Window.Handle
+ $ChildWindows = Get-ChildWindows -ParentHandle $Window.Handle
+ $InputWindows = @($ChildWindows | Where-Object { $_.Class.Trim() -eq "IHWindowClass_rdclientax" })
+
+ if ($InputWindows -and $InputWindows.Count -gt 0) {
+ foreach ($InputWindow in $InputWindows) {
+ Send-BackgroundMouseJiggle -Handle $InputWindow.Handle
+ }
+ Write-skSessionLog -Message "✔️ Sent background mouse input to RDP top-level and $($InputWindows.Count) child input target(s): [$($Window.Handle)] '$($Window.Title)'" -Type "SUCCESS" -Color Green
+ }
+ else {
+ $ChildClasses = ($ChildWindows | Select-Object -ExpandProperty Class -Unique) -join ", "
+ Write-skSessionLog -Message "⚠️ Sent background mouse input to RDP top-level only; no child input target found: [$($Window.Handle)] '$($Window.Title)'. Child classes: $ChildClasses" -Type "WARNING" -Color Yellow
+ }
+
+ return $true
+ }
+
+ if ($Window.Class -eq "RAIL_WINDOW") {
+ Send-BackgroundMouseJiggle -Handle $Window.Handle
+ Write-skSessionLog -Message "✔️ Sent background mouse input to RemoteApp window: [$($Window.Handle)] '$($Window.Title)'" -Type "SUCCESS" -Color Green
+ return $true
+ }
+
+ return $false
+}
$RdpWindowClasses = @(
"TscShellContainerClass", # MSTSC/MSRDC
- "RAIL_WINDOW", # RemoteApp
- "WindowsForms10.Window.8.app.0.aa0c13_r6_ad1" #Hyper-V Console
+ "RAIL_WINDOW" # RemoteApp
+ # "WindowsForms10.Window.8.app.0.aa0c13_r6_ad1" # Hyper-V Console
)
Function Invoke-skLogic {
@@ -185,10 +267,10 @@ Function Invoke-skLogic {
Write-skSessionLog -Message "❌ Failed to create temporary skSink window" -Type "ERROR" -Color Red
}
- Write-skSessionLog -Message "🔍 Searching for RDP, RemoteApp, and Hyper-V windows across all desktops." -Type "DEBUG" -Color Yellow
+ Write-skSessionLog -Message "🔍 Searching for RDP and RemoteApp windows across all desktops." -Type "DEBUG" -Color Yellow
$ActiveRdpWindows = Get-WindowsByClass -ClassName $RdpWindowClasses
- if ($ActiveRdpWindows -ne $null) {
- Write-skSessionLog -Message "✔️ Found $($ActiveRdpWindows.Count) active session(s). Caching current foreground window..." -Type "INFO" -Color Magenta
+ if ($ActiveRdpWindows -and $ActiveRdpWindows.Count -gt 0) {
+ Write-skSessionLog -Message "✔️ Found $($ActiveRdpWindows.Count) active session(s). Checking foreground window..." -Type "INFO" -Color Magenta
$ForegroundWindowHandle = [User32]::GetForegroundWindow()
$ForegroundWindow = Get-WindowInformation -Handle $ForegroundWindowHandle
Write-skSessionLog -Message "🎯 Current foreground: [$ForegroundWindowHandle] $($ForegroundWindow.Class) - '$($ForegroundWindow.Title)'" -Type "DEBUG" -Color Magenta
@@ -196,18 +278,21 @@ Function Invoke-skLogic {
foreach ($RdpWindow in $ActiveRdpWindows) {
$WindowHandle = $RdpWindow.Handle
Write-skSessionLog -Message "⌛ Processing session: [$WindowHandle] $($RdpWindow.Class) - '$($RdpWindow.Title)'" -Type "DEBUG" -Color Yellow
- if ($RdpWindow.Class -like "*RAIL*" -and -not ($RemoteAppWhiteList | Where-Object { $RdpWindow.Title -like "*$_*" })) {
- Write-skSessionLog -Message "⏭️ Skipping: RemoteApp not in whitelist" -Type "DEBUG" -Color DarkGray
+
+ if ([User32]::IsIconic($WindowHandle)) {
+ Write-skSessionLog -Message "⏭️ Skipping minimized session: [$WindowHandle] '$($RdpWindow.Title)'" -Type "DEBUG" -Color DarkGray
}
- else {
- [User32]::SetForegroundWindow($WindowHandle) | Out-Null
- Write-skSessionLog -Message "🪄 Activated window in foreground: [$WindowHandle] '$($RdpWindow.Title)'" -Type "DEBUG" -Color Yellow
+ elseif (Test-WindowIsForegroundRoot -WindowHandle $WindowHandle -ForegroundWindowHandle $ForegroundWindowHandle) {
Move-MouseCursor
- Write-skSessionLog -Message "⌨️ Sent simulated mouse input to window: [$WindowHandle] '$($RdpWindow.Title)'" -Type "SUCCESS" -Color Green
+ Write-skSessionLog -Message "✔️ Sent foreground mouse input to active session: [$WindowHandle] '$($RdpWindow.Title)'" -Type "SUCCESS" -Color Green
+ }
+ else {
+ $backgroundInputSent = Invoke-BackgroundSessionMouseInput -Window $RdpWindow
+ if (-not $backgroundInputSent) {
+ Write-skSessionLog -Message "⏭️ Skipping: no background input target found for [$WindowHandle] '$($RdpWindow.Title)'" -Type "WARNING" -Color Yellow
+ }
}
}
- Write-skSessionLog -Message "🔄 Restoring original foreground window: [$ForegroundWindowHandle] '$($ForegroundWindow.Title)'" -Type "INFO" -Color Magenta
- [User32]::SetForegroundWindow($ForegroundWindowHandle) | Out-Null
} else {
Write-skSessionLog -Message "✔️ No matching windows found--nothing to do." -Type "DEBUG" -Color Yellow
}
diff --git a/user32.cs b/user32.cs
index e733f72..25c20b0 100644
--- a/user32.cs
+++ b/user32.cs
@@ -4,9 +4,12 @@
using System.Runtime.InteropServices;
public static class User32 {
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
+ public delegate bool EnumChildProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
public static extern bool EnumWindows(EnumWindowsProc enumFunc, IntPtr lParam);
[DllImport("user32.dll")]
+ public static extern bool EnumChildWindows(IntPtr hWndParent, EnumChildProc lpEnumFunc, IntPtr lParam);
+ [DllImport("user32.dll")]
public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll")]
public static extern int GetClassName(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount);
@@ -30,6 +33,10 @@ public static extern IntPtr CreateWindowEx(
public static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("kernel32.dll")]
public static extern IntPtr GetModuleHandle(string lpModuleName);
+ [DllImport("user32.dll", SetLastError = true)]
+ public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
+ [DllImport("user32.dll")]
+ public static extern bool IsIconic(IntPtr hWnd);
[DllImport("user32.dll")]
- public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
+ public static extern IntPtr GetAncestor(IntPtr hWnd, uint gaFlags);
}
diff --git a/version.txt b/version.txt
index 7617c62..d4528b7 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-dev-wix
\ No newline at end of file
+dev-vNext
\ No newline at end of file
diff --git a/wix/Product.wxs b/wix/Product.wxs
index 9497f83..149837e 100644
--- a/wix/Product.wxs
+++ b/wix/Product.wxs
@@ -11,7 +11,7 @@
Compressed="yes"
Scope="perUser">
-
+
@@ -72,7 +72,7 @@