Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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).

Expand All @@ -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.

---

Expand Down
8 changes: 2 additions & 6 deletions config.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
{
"remoteAppWhitelist": [
"Teams",
"skRAHelper"
],
{
"skHostKeystroke": "{F15}",
"loopIntervalSeconds": 240
}
}
115 changes: 100 additions & 15 deletions skhost.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -185,29 +267,32 @@ 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

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
}
Expand Down
9 changes: 8 additions & 1 deletion user32.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
dev-wix
dev-vNext
4 changes: 2 additions & 2 deletions wix/Product.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
Compressed="yes"
Scope="perUser">

<SummaryInformation Description="An idle prevention tool for local, RDP, RemoteApp, and Hyper-V sessions written entirely in PowerShell for maximum portability." />
<SummaryInformation Description="An idle prevention tool for local, RDP, and RemoteApp sessions written entirely in PowerShell for maximum portability." />

<MajorUpgrade DowngradeErrorMessage="A newer version of [ProductName] is already installed." />

Expand Down Expand Up @@ -72,7 +72,7 @@
<Component Id="ApplicationShortcutComponent" Guid="B3A4C8D1-2E5F-4A6B-9C7D-8E1F2A3B4C5D">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="skHost"
Description="An idle prevention tool for local, RDP, RemoteApp, and Hyper-V sessions written entirely in PowerShell for maximum portability."
Description="An idle prevention tool for local, RDP, and RemoteApp sessions written entirely in PowerShell for maximum portability."
Target="[System64Folder]conhost.exe"
Arguments="powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File &quot;[INSTALLFOLDER]skhost.ps1&quot;"
WorkingDirectory="INSTALLFOLDER"
Expand Down
Loading