Screensaver with a Shotgun: Kicking Out Stale Kiosk Sessions Before Horizon Does

Mark PlatteMark Platte
20 min read

Disclaimer :
This tool was built for a specific use case in a Horizon environment with kiosk-style endpoints.
It’s a quick, configurable workaround not a polished enterprise product.
Use it at your own risk, test before deploying, and adapt it to your needs.
If it deletes your lunch or logs off your CEO mid-call... that’s on you.

Let’s talk about a problem you’ve probably already tripped over if you manage shared Horizon setups. This is especially true in hospital meeting rooms with badge-based logins.

Picture this:

A clinician swipes their badge on a kiosk PC.
Their personal VDI session launches instantly seamless roaming magic.
They join a Teams call. Maybe open a few files. Maybe they’re presenting.

Then... they leave.
No lock. No logoff.
Just an active, unlocked session quietly waiting for the next person to stroll in.

What Could Possibly Go Wrong?

• The next person walks in and sees everything.
• Horizon will eventually disconnect the session, but not before 30 to 60 minutes, depending on your pod-level timeout settings.
• Meanwhile, the kiosk sits there exposing personal data, chats, and open applications.

The “Proper” Way?

You could go the clean architecture route and set up a separate Horizon pool just for meeting rooms, with a short idle timer.

But then:
• Users no longer roam seamlessly between their office and the meeting room.
• Instead of resuming their session, they get a completely new desktop losing context, open apps, and unsaved data.
• And if you do this across multiple shared locations? You end up with pool sprawl.

But Can’t We Warn the User?”

Yes. Horizon supports a forced logoff warning using these global GPOs:

DisplayWarningBeforeForcedLogoff
ForcedLogoffTimeout

This displays a warning message like:

“You will be logged off in X seconds...” before disconnecting or logging off the session due to inactivity.

Sounds like a clean solution?

Not quite.

These settings apply to the entire Horizon pod. Not just one pool or use case.

So if you enable them to protect a few kiosk PCs in meeting rooms, every user across every pool in that pod including persistent desktop users, radiologists, or anyone with open apps gets the same global countdown and forced logoff behavior.

This can lead to:
• Disrupted workflows
• Unexpected logouts
• Angry users who just lost their work

Once again, it’s a good idea with the wrong blast radius.

Or You Let Horizon Handle It…

...and that’s when someone gets booted mid-Teams-call without warning.

Because here's the other kicker:
Horizon’s idle timeout doesn’t natively warn the user unless you configure the above GPOs.

And even then, it only works at the pod level not per machine or per user context.

Imagine presenting to the board, and the Horizon session silently pulls the plug because you haven’t moved your mouse enough.

The Fix? A Custom Screensaver With Teeth

This tool does what Horizon doesn’t:

• Detects inactivity after a configurable number of seconds (you generic screensaver behavior)
• Pops up a countdown message across all screens:
 “No input detected. Closing in 30 seconds.” (or whatever you like it to be)
• If no one clicks, it forcefully kills Horizon (or any process you configure)
• Session gone. Machine clean. Meeting room safe

It’s:
• Brandable
• Policy-deployable
• Silent but effective

And most importantly, it puts you in control of when kiosk sessions die.

No pool gymnastics.
No surprise Teams drop-offs.
No global settings that backfire on your normal desktops.

Just a silent enforcer that cleans up behind forgetful users.

Sounds interesting?
Below is the full code explained, as usual.

Setup: Simple by Design

Let’s start with the basics.

This tool consists of just two forms:

  • frmCheck the hidden watchdog that decides whether to show the warning screen

  • frmMessage the fullscreen message with the countdown timer and kill switch

No complicated dependencies.
No external libraries.
No weird NuGet voodoo.

Just straightforward VB.NET, and yes it works without admin rights.

But here’s the important part:
📌 Target .NET Framework 4.0
Why? Because this thing is going in C:\Windows\System32 as a .scr.
And if you target anything higher, Windows might silently refuse to launch it in screensaver mode. Don’t ask. Just trust me.

Next up: let’s walk through what frmCheck actually does.

Private Sub Check_Load(sender As Object, e As EventArgs) Handles MyBase.Load
    Dim configPath As String = IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "kiosksaver\config.ini")
    Me.Visible = False
    Dim processName As String = ReadINIValue(configPath, "KillProcess", "vmware-view")
    Dim p() As Process
    p = Process.GetProcessesByName(processName)
    If p.Length >= 1 Then
        Dim ScreenMessage As New frmMessage(configPath)
        ScreenMessage.ShowDialog()
    End If
    Me.Close()
End Sub

Let’s break it down:

  • configPath points to your shared INI file in C:\ProgramData\kiosksaver.
    No hardcoded values it works everywhere.

  • It reads the process name to monitor (typically vmware-view) from the INI:

; Process to terminate when timer expires
KillProcess=vmware-view
  • Then it checks if that process is running using Process.GetProcessesByName.

  • If found, it quietly launches frmMessage, your fullscreen warning UI.

  • Whether it finds anything or not, it always ends by calling Me.Close().

So what does that mean in plain English?

  • The screensaver only activates if the process you’re targeting is actually running

  • If the process isn’t found (e.g. Horizon isn’t open, or it already closed), it does nothing and exits silently

This prevents:

  • False alarms on idle desktops that aren’t using Horizon

  • Countdown screens appearing when there’s nothing to clean up

  • Accidental kills of unrelated processes or idle machines doing nothing wrong

So If vmware-view.exe Isn’t Running?

No warning.
No countdown.
Just Me.Close() and done.

You can adjust this behavior if you want it to always show the message even if no process is found but the current design keeps things minimal and purposeful.

This form should never be seen hence the Me.Visible = False

The ReadINIValue Helper

Same form, utility function:

Public Function ReadINIValue(filePath As String, key As String, Optional defaultValue As String = "") As String
    If Not IO.File.Exists(filePath) Then Return defaultValue

    For Each line In IO.File.ReadAllLines(filePath)
        If line.TrimStart().StartsWith(";") Then Continue For ' Skip comments
        Dim parts = line.Split({"="c}, 2)
        If parts.Length = 2 AndAlso parts(0).Trim().Equals(key, StringComparison.OrdinalIgnoreCase) Then
            Return parts(1).Trim()
        End If
    Next
    Return defaultValue
End Function

No external config libraries here just old-school INI parsing:

  • Skips commented lines (;)

  • Splits each line into key/value

  • Trims whitespace

  • Returns a default if the key’s not found or the file’s missing

frmMessage: The Countdown Enforcer

Here’s where the real work happens.

This form goes fullscreen, paints every screen in your setup, shows your message and logo, starts a countdown, and if nobody clicks boom, it kills the Horizon process (or whatever you configure).

New() Startup Prep

Public Sub New(iniPath As String)
    InitializeComponent()
    SetStyle(ControlStyles.AllPaintingInWmPaint Or ControlStyles.UserPaint Or ControlStyles.DoubleBuffer, True)
    UpdateStyles()
    configPath = iniPath
    Me.Visible = False
End Sub

This constructor:

  • Stores the INI path

  • Enables double-buffered drawing (smoother visuals)

  • Hides the form initially to avoid flicker on load

Message_Load Config, Layout, and UI Generation

This is the main routine. Here's what happens:

1. First: Core Functionality

processName = ReadINIValue(configPath, "KillProcess", "vmware-view")
timeout = CInt(ReadINIValue(configPath, "Timeout", "300"))
Dim messageText As String = ReadINIValue(configPath, "Message", "Closing in %s seconds")

KillProcess is the name of the executable to terminate at the end of the countdown. Typically vmware-view, but you can set it to anything even notepad for testing.

  1. Timeout is the countdown duration in seconds.

  2. Message is the string shown to users. It supports placeholders:

    • %s → current countdown value

Visuals and Branding

Dim Bgcolor As String = ReadINIValue(configPath, "BackColor", "#000000")
Dim BgImage As String = ReadINIValue(configPath, "BgImage", "")
Dim Fgcolor As String = ReadINIValue(configPath, "TextColor", "#FFFFFF")
Dim TextSize As String = ReadINIValue(configPath, "TextSize", "#FFFFFF")
Dim Font As String = ReadINIValue(configPath, "Font", "Segoe UI")
Dim FontSize As String = ReadINIValue(configPath, "FontSize", "28")
  1. All colors use hex notation (e.g. #000000).

  2. Font and font size can be fully customized.

  3. You can show a branded image behind the message if desired (BgImage).

Logo Support

Dim Logo As String = ReadINIValue(configPath, "Logo", "C:\ProgramData\Kiosksaver\logo.png")
Dim LogoWidth As String = ReadINIValue(configPath, "LogoWidth", "128")
Dim LogoHeight As String = ReadINIValue(configPath, "LogoHeight", "64")
  1. The logo appears in the top-left corner of each screen.

  2. You control the size in pixels.

System Screensaver Timeout (for %sc)

Dim timeoutSecScr As Integer = GetScreenSaverTimeoutSeconds()
  1. This reads the system value from the registry (HKCU\Control Panel\Desktop\ScreenSaveTimeOut)

  2. If you include %sc in your message string, it gets replaced with this timeout (in minutes)

Form Setup

Dim virtualBounds As Rectangle = SystemInformation.VirtualScreen
Me.FormBorderStyle = FormBorderStyle.None
Me.Bounds = virtualBounds
Me.StartPosition = FormStartPosition.Manual
Me.TopMost = True
  1. The form stretches over all connected monitors

  2. No border, no taskbar icon, and always on top

Safe Fallbacks

Try
    Me.BackColor = ColorTranslator.FromHtml(...)
Catch
    Me.BackColor = Color.Black
End Try

Try
    Me.BackgroundImage = Image.FromFile(BgImage)
Catch
    Me.BackgroundImage = Nothing
End Try
  1. If someone messes up the INI (wrong hex color, missing file), it won’t crash.

  2. The app uses hardcoded defaults if needed.

Final Touch: Message Template Cleanup

messageTemplate = ReadINIValue(configPath, "Message", "Closing in %s seconds")
messageTemplate = messageTemplate.Replace("%sc", (timeoutSecScr / 60))
messageTemplate = messageTemplate.Replace("\n", vbCrLf)
  • %sc gets swapped for the system timeout (in minutes)

  • \n becomes a real line break

This lets you write messages like:

Message=No input received for %sc minute(s).\nClosing in %s seconds.

…and see something like:

No input received for 5 minute(s).

Closing in 30 seconds.

Fully dynamic. Fully controlled by the admin.
Zero hardcoding.

2.Dynamic UI per Screen

This block builds the message and logo on each connected monitor. Yes, it works on dual, triple, vertical, mirrored, whatever. If Windows sees it, so does the saver.

For Each scr As Screen In Screen.AllScreens

Will loop trough each screen to add the things you want your end user to see

Label: The Countdown Message

Dim label As New Label With {
    .Text = messageTemplate.Replace("%s", timeout.ToString()),
    .ForeColor = textColor,
    .BackColor = Color.Transparent,
    .Font = New Font(Font, CInt(FontSize), FontStyle.Bold),
    .AutoSize = True,
    .TextAlign = ContentAlignment.MiddleCenter
}
  1. Text gets the current timeout value baked in (%s).

  2. Color, font, size, and style come from the config file.

  3. Background is transparent no ugly blocks or overlaps.

  4. AutoSize = True means it fits the text no matter what you put in the INI.

Centered Placement (Like It Should Be)

Dim centerX = scr.Bounds.Left + (scr.Bounds.Width \ 2) - (label.PreferredWidth \ 2)
Dim centerY = scr.Bounds.Top + (scr.Bounds.Height \ 2) - (label.PreferredHeight \ 2)
label.Location = New Point(centerX, centerY)

No hardcoded values. It auto-centers on each screen individually.

Add click-to-cancel behavior:

AddHandler label.Click, AddressOf FormClickClose

Then slap it onto the form:

Me.Controls.Add(label)
textLabels.Add(label)

Note: textLabels is a list so you can easily update all labels during the countdown.

Logo: Optional, Brandable, and Clickable

Dim logobox As New PictureBox With {
    .Size = New Size(CInt(LogoWidth), CInt(LogoHeight)),
    .SizeMode = PictureBoxSizeMode.StretchImage,
    .Location = New Point(scr.Bounds.Left + 20, scr.Bounds.Top + 20),
    .BackColor = Color.Transparent
}
  1. Pulled from Logo, LogoWidth, and LogoHeight in your INI.

  2. Top-left aligned with a 20px margin.

  3. Transparent background.

  4. Respects your size but stretches image to fit cleanly.

Try
    logobox.Image = Image.FromFile(Logo)
Catch
    ' Ignore if logo not found
End Try

If the image file is missing or broken? No crash, no complaint. Just skips the logo.

Also clickable to dismiss:

AddHandler logobox.Click, AddressOf FormClickClose

Start Timer and Go Visible

Timer1.Interval = 1000
Timer1.Start()
Me.Visible = True

The countdown starts ticking as soon as everything is drawn.
1000 ms so you count down per second

If the user interacts? It closes.
If not? It kills.

3.Countdown, Kill, and Cleanup

Private Sub Timer1_Tick(sender As Object, e As EventArgs) Handles Timer1.Tick
    timeout -= 1

Every second, the Timer1_Tick event fires and subtracts 1 from the countdown.

Live Label Updates

For Each lbl As Label In textLabels
    lbl.Text = messageTemplate.Replace("%s", timeout.ToString())
Next

Updates all labels on all screens in real time.
No flicker, no redraws, just smooth countdown.

Timeout Reached = Process Killed

If timeout <= 0 Then
    Try
        For Each p In Process.GetProcessesByName(processName)
            p.Kill()
        Next
    Catch
        ' suppress
    End Try
    Me.Close()
End If
  • Once the timer hits zero, it searches for all processes by name.

  • Each one gets Kill() called on it.

  • If something goes wrong (e.g. access denied), it fails silently.

No popups. No error messages. It just tries, then closes itself.

Dismiss Behavior

Click Anywhere = Cancel

Private Sub FormClickClose(sender As Object, e As EventArgs)
    Me.Close()
End Sub

Private Sub Message_Click(sender As Object, e As EventArgs) Handles Me.Click
    Me.Close()
End Sub

Every Label, every PictureBox, and the form itself are wired to FormClickClose.

So what happens when someone clicks?

The countdown stops
The screensaver exits
The user keeps working no questions asked

This could be extended with:

  • Multi-click verification

  • A password

  • A puzzle to solve within 10 seconds or you still get disconnected

Depending on how much you love your end users you could do anything.
…but by default, it’s one click to cancel because simplicity wins.

Helpers (Again)

ReadINIValue()

Already explained earlier pulls values from your INI config safely.

GetScreenSaverTimeoutSeconds()
Using regKey = Registry.CurrentUser.OpenSubKey("Control Panel\Desktop")
    Dim value As String = regKey.GetValue("ScreenSaveTimeOut", "600").ToString()
    Return Integer.Parse(valueGrabs the current Windows screensaver timeout (in seconds).

regKey.GetValue("ScreenSaveTimeOut", "600").ToString()
This reads the system's screensaver timeout value from the registry:

HKCU\Control Panel\Desktop\ScreenSaveTimeOut

The value is in seconds
It tells Windows how long to wait before launching the system screensaver
If the key doesn’t exist (which can happen on fresh profiles or locked-down environments), it returns "600" that’s 10 minutes
So "600" is just the default fallback:
10 minutes of inactivity = screensaver starts.
We use this value to replace %sc in your message string, so users get feedback like:
"No input detected for 10 minutes. Closing in 30 seconds."
It makes the message reflect their actual system behavior, not just a hardcoded guess.

  • Used to dynamically insert %sc into your message string.

Fallback is -1 if anything fails no crash, no popup.

you can also leave this out of the message and fill in a hard coded time which you then define in your GPO as idle time tor start the screensaver.

That’s it the full screensaver loop.
From idle detection, to visual feedback, to process termination all wrapped in a neat, self-contained .scr. after you manually rename the exe

Example image :

Sample2Monitor.jpg

Full code :

frmCheck:

Public Class frmCheck

    Private Sub Check_Load(sender As Object, e As EventArgs) Handles MyBase.Load
        Dim configPath As String = IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "kiosksaver\config.ini")
        Me.Visible = False
        Dim processName As String = ReadINIValue(configPath, "KillProcess", "vmware-view")
        Dim p() As Process
        p = Process.GetProcessesByName(processName)
        If p.Length >= 1 Then
            Dim ScreenMessage As New frmMessage(configPath)
            ScreenMessage.ShowDialog()
        End If
        Me.Close()
    End Sub
    Public Function ReadINIValue(filePath As String, key As String, Optional defaultValue As String = "") As String
        If Not IO.File.Exists(filePath) Then Return defaultValue

        For Each line In IO.File.ReadAllLines(filePath)
            If line.TrimStart().StartsWith(";") Then Continue For ' Skip comments
            Dim parts = line.Split({"="c}, 2)
            If parts.Length = 2 AndAlso parts(0).Trim().Equals(key, StringComparison.OrdinalIgnoreCase) Then
                Return parts(1).Trim()
            End If
        Next
        Return defaultValue
    End Function
End Class

frmMessage

Imports Microsoft.Win32
Public Class frmMessage
    'some varaibles used in multiple subs/functions so needed more top declaration
    Private configPath As String
    Private timeout As Integer = 60
    Private killProcess As String = "vmware-view"
    Private messageTemplate As String = "Closing in %s seconds"
    Private textLabels As New List(Of Label)
    Private processName As String = "vmware-view"

    Public Sub New(iniPath As String)
        InitializeComponent()
        SetStyle(ControlStyles.AllPaintingInWmPaint Or ControlStyles.UserPaint Or ControlStyles.DoubleBuffer, True)
        UpdateStyles()
        configPath = iniPath
        Me.Visible = False
    End Sub

    Private Sub Message_Load(sender As Object, e As EventArgs) Handles MyBase.Load
        processName = ReadINIValue(configPath, "KillProcess", "vmware-view")
        timeout = CInt(ReadINIValue(configPath, "Timeout", "300"))
        Dim messageText As String = ReadINIValue(configPath, "Message", "Closing in %s seconds")
        Dim Bgcolor As String = ReadINIValue(configPath, "BackColor", "#000000")
        Dim BgImage As String = ReadINIValue(configPath, "BgImage", "")
        Dim Fgcolor As String = ReadINIValue(configPath, "TextColor", "#FFFFFF")
        Dim TextSize As String = ReadINIValue(configPath, "TextSize", "#FFFFFF")
        Dim Font As String = ReadINIValue(configPath, "Font", "Segoe UI")
        Dim FontSize As String = ReadINIValue(configPath, "FontSize", "28")
        Dim Logo As String = ReadINIValue(configPath, "Logo", "C:\ProgramData\Kiosksaver\logo.png")
        Dim LogoWidth As String = ReadINIValue(configPath, "LogoWidth", "128")
        Dim LogoHeight As String = ReadINIValue(configPath, "LogoHeight", "64")
        Dim timeoutSecScr As Integer = GetScreenSaverTimeoutSeconds()

        Dim virtualBounds As Rectangle = SystemInformation.VirtualScreen
        Me.FormBorderStyle = FormBorderStyle.None
        Me.Bounds = virtualBounds
        Me.StartPosition = FormStartPosition.Manual
        Me.TopMost = True

        ' Load config
        Try
            Me.BackColor = ColorTranslator.FromHtml(ReadINIValue(configPath, "BackColor", "#000000"))
        Catch
            Me.BackColor = Color.Black
        End Try
        Try
            Me.BackgroundImage = Image.FromFile(BgImage)
        Catch
            Me.BackgroundImage = Nothing
        End Try

        Dim textColor As Color
        Try
            textColor = ColorTranslator.FromHtml(ReadINIValue(configPath, "TextColor", "#FFFFFF"))
        Catch
            textColor = Color.White
        End Try

        messageTemplate = ReadINIValue(configPath, "Message", "Closing in %s seconds")
        messageTemplate = messageTemplate.Replace("%sc", (timeoutSecScr / 60))
        messageTemplate = messageTemplate.Replace("\n", vbCrLf)
        ' Generate UI per screen
        For Each scr As Screen In Screen.AllScreens
            ' Label
            Dim label As New Label With {
                .Text = messageTemplate.Replace("%s", timeout.ToString()),
                .ForeColor = textColor,
                .BackColor = Color.Transparent,
                .Font = New Font(Font, CInt(FontSize), FontStyle.Bold),
                .AutoSize = True,
                .TextAlign = ContentAlignment.MiddleCenter
            }
            Dim centerX = scr.Bounds.Left + (scr.Bounds.Width \ 2) - (label.PreferredWidth \ 2)
            Dim centerY = scr.Bounds.Top + (scr.Bounds.Height \ 2) - (label.PreferredHeight \ 2)
            label.Location = New Point(centerX, centerY)
            AddHandler label.Click, AddressOf FormClickClose
            Me.Controls.Add(label)
            textLabels.Add(label)

            ' Logo
            Dim logobox As New PictureBox With {
                .Size = New Size(CInt(LogoWidth), CInt(LogoHeight)), ' Adjust as needed
                .SizeMode = PictureBoxSizeMode.StretchImage,
                .Location = New Point(scr.Bounds.Left + 20, scr.Bounds.Top + 20),
                .BackColor = Color.Transparent
            }
            Try
                logobox.Image = Image.FromFile(Logo)
            Catch
                ' Ignore if logo not found
            End Try
            AddHandler logobox.Click, AddressOf FormClickClose
            Me.Controls.Add(logobox)

        Next
        Timer1.Interval = 1000
        Timer1.Start()
        Me.Visible = True
    End Sub

    Private Sub Timer1_Tick(sender As Object, e As EventArgs) Handles Timer1.Tick
        timeout -= 1

        ' Update all labels
        For Each lbl As Label In textLabels
            lbl.Text = messageTemplate.Replace("%s", timeout.ToString())
        Next

        If timeout <= 0 Then
            Try
                For Each p In Process.GetProcessesByName(processName)
                    p.Kill()
                Next
            Catch
                ' suppress
            End Try
            Me.Close()
        End If
    End Sub
    Private Sub FormClickClose(sender As Object, e As EventArgs)
        Me.Close()
    End Sub

    Private Sub Message_Click(sender As Object, e As EventArgs) Handles Me.Click
        Me.Close()
    End Sub

    Public Function ReadINIValue(filePath As String, key As String, Optional defaultValue As String = "") As String
        If Not IO.File.Exists(filePath) Then Return defaultValue

        For Each line In IO.File.ReadAllLines(filePath)
            If line.TrimStart().StartsWith(";") Then Continue For ' Skip comments
            Dim parts = line.Split({"="c}, 2)
            If parts.Length = 2 AndAlso parts(0).Trim().Equals(key, StringComparison.OrdinalIgnoreCase) Then
                Return parts(1).Trim()
            End If
        Next

        Return defaultValue
    End Function
    Public Function GetScreenSaverTimeoutSeconds() As Integer
        Try
            Using regKey = Registry.CurrentUser.OpenSubKey("Control Panel\Desktop")
                If regKey IsNot Nothing Then
                    Dim value As String = regKey.GetValue("ScreenSaveTimeOut", "600").ToString()
                    Return Integer.Parse(value)
                End If
            End Using
        Catch ex As Exception
            ' Optionally log or handle error
        End Try
        Return -1 ' fallback if error
    End Function
End Class

Example ini:

;config.ini
;Screensaver Configuration - Kiosksaver
;This inifile should be locted in OSdisk/Programdata/kiosksaver/config.ini
;This path can be changed in the code but this is the default

[Settings]
; Countdown duration in seconds
Timeout=10

; Process to terminate when timer expires
KillProcess=vmware-view

; Message shown during countdown, %s will be replaced with seconds.
;If you use %sc somewhere it takes the time it takes before starting the screensaver from the system in minutes
;use \n for new lines

Message=No input received for %sc minute(s).\nClosing in %s seconds.\nClick anywhere twice to continue working.

; Background color of the form 
BackColor=#000000

; Background Image of the form (path  must exist on disk)
BgImage=c:\ProgramData\Kiosksaver\BG.png

; Text color of the countdown/message
TextColor=#FFFFFF

; Font family for the message
Font=Arial

; Font size in points
FontSize=28

; Logo path (must exist on disk)
Logo=C:\ProgramData\Kiosksaver\logo.png

; Logo dimensions in pixels
LogoWidth=380
LogoHeight=380

**Now how to deploy

Deployment: Kiosk PCs & Zero Clients (Horizon-Connected)**

This tool was built for exactly this scenario:
Zero clients or kiosk-mode Windows endpoints that launch a Horizon session automatically and never get logged off.

Think:

  • Shared meeting room PCs

  • Badge-swipe kiosk stations

These machines might not run DEM, don’t have fat-client user profiles, and don't benefit from user-scoped logoff policies so you need a device-level screensaver policy to clean up for them.

Make sure your compiled .scr file is:

  • Targeted for .NET Framework 4.0

  • Named Kiosk_screensaver.scr ! Rename the EXE !

  • Tested locally from C:\Windows\System32 (screensavers must reside here to load)

! It might be needed to exclude the file from AV !

Also prepare:

  • config.ini

  • logo.png (and optionally BG.png)
    All referenced in the INI with absolute paths like:

Logo=C:\ProgramData\Kiosksaver\logo.png
BgImage=C:\ProgramData\Kiosksaver\BG.png

Copy Files Using GPO

You’ll push these files to each zero client or Horizon endpoint:

FileDestination
Kiosk_screensaver.scrC:\Windows\System32\Kiosk_screensaver.scr
config.iniC:\ProgramData\Kiosksaver\config.ini
logo.pngC:\ProgramData\Kiosksaver\logo.png
BG.png (opt)C:\ProgramData\Kiosksaver\BG.png

Use GPO Preferences:

Path:

Computer Configuration >
 Preferences >
  Windows Settings >
   Files

for each file:

  • Action: Replace

  • Source: UNC path to your central share (e.g. \\fileserver\kiosksaver\Kiosk_screensaver.scr)

  • Destination: One of the above paths

Also add a Folder creation item for C:\ProgramData\Kiosksaver if it doesn’t exist.

Step 3 Force the Screensaver via GPO

Path:

Computer Configuration >
 Administrative Templates >
  Control Panel >
   Personalization

Set these policies:

PolicyValue
Enable screen saverEnabled
Screen saver timeout300 (or any timeout in seconds)
Force specific screen saverKiosk_screensaver.scr

This will force Windows to activate your screensaver after X seconds of inactivity, even inside Horizon.

Quote

Important: Horizon sessions run in user context, but the .scr activates from the shell so it can still detect inactivity at the endpoint level, regardless of Horizon's internal idle policy.

Security & Considerations

  • Use a read-only share for source files

  • Ensure users can't write to C:\ProgramData\Kiosksaver

  • Make sure the screensaver is not replaced by login scripts or UEM policies

  • Don’t target fat clients with this GPO unless you want it active there too

Result: Enforced Session Cleanup

On each zero client or kiosk-mode PC:

  • After X seconds of user inactivity, your custom screensaver launches

  • The user sees a branded, multi-monitor warning

  • If no one clicks the Horizon client (vmware-view.exe) is forcefully terminated

  • The session ends (well it's a hard disconnect actually). No lingering desktops. No exposed data. No more security risk.

No extra agents.
No login scripts.
No reliance on Horizon pool-level policies.

Just a native .scr that quietly does its job.

Final Thoughts

Yes some of these problems can be solved by making your Horizon configuration smarter.
You could:

  • Split out separate pools for kiosks

  • Set aggressive idle timers

  • Configure global GPOs for forced logoff warnings

  • Use DEM with dynamic settings per location

…but let’s be real:
That’s a lot of complexity just to make sure someone doesn’t leave a session open in a meeting room.

This screensaver flips that model.
Instead of solving the problem in the datacenter with layered policy spaghetti, you let the client-side enforce cleanup locally, predictably, and without extra infrastructure.

No new tools
No user training
No Horizon changes
No scripting or registry hacks

It’s just a .scr file with a config and a purpose:
If no one clicks, the session ends.
Fast, silent, and effective.

Whether you deploy it to zero clients, shared fat clients, or hybrid devices you’re fixing a real security gap without creating new complexity.

Is it elegant? Not really.
Is it extremely practical? Absolutely.

Bonus: Not Just for Horizon

Sure, this started as a fix for Horizon kiosk sessions but let’s not overlook the real flexibility here.

The process that gets killed?
Fully configurable.

Which means this tool becomes useful in way more scenarios than just cleaning up vmware-view.

Here are a few honorable mentions:

Auto-clean shared app sessions

Have a shared workstation with someone leaving a browser or app open all day?

Set KillProcess=chrome or KillProcess=teams
When they forget to log off, it closes automatically.

Testing Labs / Simulation Rooms

Want to auto-terminate a heavy app like MATLAB, Unity, or radiotherapy software after idle time?

Just set the process name and walk away.

Kiosk-Mode Apps (Standalone, Not VDI)

Some kiosks run full-screen apps without user sessions like Wayfinding, Room Booking, or Survey tools.

If the app freezes or hangs open?
Let the screensaver detect the stall and restart it via a wrapper script.

Fat Clients with Licensed Software

Want to prevent people from "squatting" on a seat with a limited license (e.g. AutoCAD, SPSS, or even Photoshop)?

Configure a short timeout and reclaim the session cleanly.

This tool is simple on purpose but because you define the target, it adapts to whatever cleanup job you need done.

You might need to adapt some code.
Maybe change how processes are monitored.
Maybe make it handle multiple targets or custom logic per process.

In that case you’ve got a solid base to start from.

As always comments, critical remarks, and heads-ups are very welcome.
If it’s useful, let me know.
If it’s terrible, definitely let me know.
And if you broke something with it... well, that’s on you.

Have Fun,

Mark

Files :

Zip containing the program data folder example contents:

Config Examples

Zip containing the full source without compiled EXE :

Source

0
Subscribe to my newsletter

Read articles from Mark Platte directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mark Platte
Mark Platte

Mark Platte Born in January, 1984.I currently work as an IT architect in a hospital environment, with a career shaped by hands-on experience in application development, storage engineering, and infrastructure design. My roots are in software, but over time I moved deeper into system architecture, working closely with storage platforms, virtualization, and security especially in regulated and research-intensive environments. I have a strong focus on building stable, secure, and manageable IT solutions, particularly in complex environments where clinical systems, research data, and compliance requirements intersect. I’m especially experienced in enterprise storage design, backup strategies, and performance tuning, often acting as the bridge between engineering teams and long-term architectural planning. I enjoy solving difficult problems and still believe most issues in IT can be fixed with enough determination, focus, and sometimes budget. It’s that drive to find solutions that keeps me motivated.