geenforce: relaunch the display kiosk when Edge goes away
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s

Kiosks were updating, closing Edge, and never coming back - the display stayed
dead until the next logon or reboot.

The kiosk is launched by an all-users Startup shortcut, which runs ONCE at
logon, and nothing supervised the browser afterwards. RelaunchNotification=2
was meant to cover the update case and does not: that policy drives Edge's own
update-restart, which depends on session restore to return to where it was.
Kiosk mode restores no session and has no UI to show the notification in, so
Edge honours the close and never the relaunch. The same gap swallowed crashes
and anyone closing the window.

Adds a scope entry that registers a scheduled task in the INTERACTIVE session -
SYSTEM cannot launch a visible browser, which is why the dispatcher writes a
shortcut rather than calling Start-Process. The task relaunches from that same
shortcut, so the target URL keeps one source of truth: retarget a subtype in
DISPLAY_TYPE_TARGETS and the watchdog follows unchanged.

Two details that matter. It matches on the COMMAND LINE, not the image name:
Edge runs a crowd of msedge.exe children and only the parent carries --kiosk,
so testing "is msedge running" would let a stray renderer mask a dead kiosk
forever - verified against a real kiosk PC showing 7 processes and 1 match. And
it avoids -RepetitionDuration [TimeSpan]::MaxValue, which serialises out of
range and is rejected, exactly as the kiosk installer documents.

A launch debounce stops a display that fails to start from spawning a browser
every cycle, the log is size-bounded because this runs forever on a PC nobody
watches, and it does nothing at all when no kiosk shortcut is present so it
cannot put Edge on a PC that never asked for one.

Verified on Windows: registers with the right principal and triggers, relaunches
when the kiosk is gone, debounces an immediate re-run, and is idempotent across
cycles (the staged script compare is trimmed - Set-Content adds a trailing
newline the here-string lacks, so an untrimmed compare rewrote it every cycle).
This commit is contained in:
cproudlock
2026-08-10 07:49:43 -04:00
parent 178dacd55e
commit 939cdd0882
2 changed files with 275 additions and 9 deletions

View File

@@ -287,6 +287,201 @@ Write-Host "display-type '$displayType' -> kiosk Startup shortcut for $kioskUrl"
"""
WATCHDOG_FILENAME = 'Register-KioskWatchdog.ps1'
# Where the watchdog stages the script the scheduled task actually runs. The
# task must run in the INTERACTIVE session, so its action cannot be an inline
# payload executed by the SYSTEM enforce task.
WATCHDOG_TASKNAME = 'ShopDB Kiosk Watchdog'
WATCHDOG_SCRIPTPATH = r'C:\ProgramData\ShopDB\Watch-ShopdbKiosk.ps1'
WATCHDOG_INTERVAL_MIN = 2
def build_watchdog_script():
"""Return the inline watchdog-installer PowerShell as text.
WHY THIS EXISTS: the kiosk is launched by an all-users Startup shortcut,
which runs ONCE at logon. Nothing supervises the browser afterwards, so when
Edge exits - an update, a crash, someone closing the window - the display
stays dead until the next logon or reboot.
The Edge RelaunchNotification=2 policy was intended to cover the update
case, but it does not: that policy drives Edge's own update-restart flow,
which depends on session restore to come back to where it was. Kiosk mode
(--kiosk) restores no session and shows no notification UI, so Edge honours
the close and never the relaunch.
This entry runs as SYSTEM under the enforce task and cannot launch a visible
browser itself (session 0 has no desktop - the same reason the dispatcher
writes a Startup shortcut). So it REGISTERS a scheduled task that runs in
the interactive user's session and relaunches the kiosk when it is gone.
Idempotent: the staged script and the task are rewritten only when they
differ from what is wanted, so a steady-state cycle costs one file read.
"""
# The script the task runs. Kept as a separate literal so the outer f-string
# does not have to escape its braces.
watched = r'''# Watch-ShopdbKiosk.ps1 -- relaunch the kiosk browser if it has gone.
#
# Runs in the INTERACTIVE user session (see the watchdog task), every couple of
# minutes. Reads the same Startup shortcut the dispatcher writes, so the target
# URL has exactly one source of truth: retarget a display by editing the
# dispatcher, and this follows without change.
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\ShopDB'
$logFile = Join-Path $logDir 'kiosk-watchdog.log'
$stamp = Join-Path $env:TEMP 'shopdb-kiosk-lastlaunch.txt'
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
}
function Log {
param([string]$Message)
# Bounded: this runs every couple of minutes forever on a PC nobody logs
# into, so an unbounded log would be the only thing that ever fills the disk.
try {
if ((Test-Path $logFile) -and ((Get-Item $logFile).Length -gt 512KB)) {
$keep = Get-Content $logFile -Tail 200 -EA SilentlyContinue
Set-Content -Path $logFile -Value $keep -EA SilentlyContinue
}
} catch { }
Add-Content -Path $logFile -EA SilentlyContinue `
-Value ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message)
}
# The kiosk shortcut is the source of truth for target + arguments.
$startup = Join-Path $env:ProgramData 'Microsoft\Windows\Start Menu\Programs\StartUp'
$lnkPath = Join-Path $startup 'ShopDB Kiosk.lnk'
if (-not (Test-Path -LiteralPath $lnkPath)) {
# Not a kiosk display, or the dispatcher has not run yet. Do NOT invent a
# browser launch here - that would put Edge on a PC that never asked for it.
return
}
try {
$shell = New-Object -ComObject WScript.Shell
$lnk = $shell.CreateShortcut($lnkPath)
$target = $lnk.TargetPath
$arguments = $lnk.Arguments
} catch {
Log "could not read $lnkPath : $_"
return
}
if (-not $target -or -not (Test-Path -LiteralPath $target)) {
Log "shortcut target missing: $target"
return
}
# Match on the COMMAND LINE, not just the image name. Edge spawns a crowd of
# msedge.exe children (renderers, GPU, crashpad) that outlive nothing useful;
# testing for "is msedge running" would leave a stray child masking a dead
# kiosk forever.
$running = $false
try {
$running = [bool](Get-CimInstance Win32_Process -Filter "Name = 'msedge.exe'" -EA SilentlyContinue |
Where-Object { $_.CommandLine -match '--kiosk' })
} catch {
Log "process query failed, assuming kiosk is up: $_"
return
}
if ($running) { return }
# Debounce. Edge takes a few seconds to present a --kiosk process, and a display
# that fails to start would otherwise get a new browser every couple of minutes.
try {
if (Test-Path $stamp) {
$last = Get-Content $stamp -First 1 -EA SilentlyContinue
if ($last) {
$age = ((Get-Date) - [datetime]$last).TotalSeconds
if ($age -lt 120) {
Log ('kiosk still absent {0:N0}s after a launch - not relaunching yet' -f $age)
return
}
}
}
} catch { }
Log "kiosk browser not running - relaunching: $target $arguments"
try {
Set-Content -Path $stamp -Value (Get-Date -Format 'o') -EA SilentlyContinue
Start-Process -FilePath $target -ArgumentList $arguments -EA Stop
Log 'relaunched.'
} catch {
Log "relaunch FAILED: $_"
}
'''
return f"""# Register-KioskWatchdog.ps1 -- gea-shopfloor-display kiosk watchdog.
#
# Installs a scheduled task that relaunches the kiosk browser when it is gone.
# See the manifest comment for why the Startup shortcut alone is not enough.
#
# Runs as SYSTEM under the enforce task, so it registers the task rather than
# launching anything: SYSTEM has no interactive desktop.
$ErrorActionPreference = 'Continue'
$taskName = '{WATCHDOG_TASKNAME}'
$scriptPath = '{WATCHDOG_SCRIPTPATH}'
$intervalMin = {WATCHDOG_INTERVAL_MIN}
$wanted = @'
{watched}
'@
# --- stage the script the task runs --------------------------------------
$dir = Split-Path -Parent $scriptPath
if (-not (Test-Path $dir)) {{ New-Item -ItemType Directory -Path $dir -Force | Out-Null }}
$current = ''
if (Test-Path -LiteralPath $scriptPath) {{
$current = Get-Content -LiteralPath $scriptPath -Raw -EA SilentlyContinue
}}
# Compare trimmed: Set-Content appends a trailing newline that the here-string
# does not carry, so a raw comparison never matches and the file is rewritten
# every enforce cycle forever.
if ($current.TrimEnd() -ne $wanted.TrimEnd()) {{
Set-Content -LiteralPath $scriptPath -Value $wanted -Encoding UTF8 -Force
Write-Host "staged $scriptPath"
}} else {{
Write-Host "watchdog script already current"
}}
# --- register the task ----------------------------------------------------
# Principal is the INTERACTIVE users group, not SYSTEM: the relaunched browser
# has to appear on the auto-login user's visible desktop. A SYSTEM task would
# start Edge in session 0 where nobody can see it.
$existing = Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue
if (-not $existing) {{
try {{
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument ('-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "{{0}}"' -f $scriptPath)
$atLogon = New-ScheduledTaskTrigger -AtLogOn
# -Once + -RepetitionInterval repeats indefinitely. Do NOT pass
# -RepetitionDuration [TimeSpan]::MaxValue: it serialises to an
# out-of-range Duration that Register-ScheduledTask rejects.
$repeat = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(1)) `
-RepetitionInterval (New-TimeSpan -Minutes $intervalMin)
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 5) `
-DontStopIfGoingOnBatteries -AllowStartIfOnBatteries
Register-ScheduledTask -TaskName $taskName -Action $action `
-Trigger @($atLogon, $repeat) -Principal $principal -Settings $settings `
-Force | Out-Null
Write-Host "registered '$taskName' (every $intervalMin min, interactive)"
}} catch {{
Write-Host "FAILED to register '$taskName': $_"
}}
}} else {{
Write-Host "'$taskName' already registered"
}}
"""
ALWAYSON_FILENAME = 'Set-DisplayAlwaysOn.ps1'
@@ -416,6 +611,26 @@ def build_display_manifest():
'PayloadRef': DISPATCHER_FILENAME,
'DetectionMethod': 'Always',
},
{
'_comment': (
'Relaunch the kiosk browser when it goes away. The Startup '
'shortcut the dispatcher writes runs ONCE at logon, so an Edge '
'update, a crash or a closed window leaves the display dead '
'until the next logon. RelaunchNotification=2 does not cover '
'it: that policy drives Edge\'s update-restart, which relies on '
'session restore, and --kiosk restores no session and shows no '
'notification UI. Registers an INTERACTIVE-session scheduled '
'task (SYSTEM cannot launch a visible browser) that relaunches '
'from the same shortcut every '
f'{WATCHDOG_INTERVAL_MIN} minutes when no --kiosk process is '
'running.'),
'Name': 'Display kiosk watchdog (relaunch Edge)',
'Type': 'PS1',
'Script': WATCHDOG_FILENAME,
'PayloadSource': 'inline',
'PayloadRef': WATCHDOG_FILENAME,
'DetectionMethod': 'Always',
},
{
'_comment': (
'Keep the display awake 24/7 so the kiosk page stays visible: '
@@ -472,6 +687,13 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
alwayson, ALWAYSON_FILENAME,
'text/plain; charset=utf-8', alwaysonbytes)
watchdog = next(entry for entry in scope.entries
if entry.name == 'Display kiosk watchdog (relaunch Edge)')
watchdogbytes = build_watchdog_script().encode('utf-8')
watchdogpayload = service.store_inline_payload(
watchdog, WATCHDOG_FILENAME,
'text/plain; charset=utf-8', watchdogbytes)
publishedversion = None
if publish:
publishedversion = service.publish_scope(
@@ -485,5 +707,6 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
'entrytypes': [entry.entrytype for entry in scope.entries],
'dispatchersha256': payload.payloadsha256,
'alwaysonsha256': alwaysonpayload.payloadsha256,
'watchdogsha256': watchdogpayload.payloadsha256,
'publishedversion': publishedversion,
}