display kiosk watchdog: relaunch Edge when it goes away

This commit is contained in:
2026-08-10 07:54:03 -04:00
parent 8a2a3ee216
commit e68f522132
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,
}