display kiosk watchdog: relaunch Edge when it goes away
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from plugins.geenforce.models import (
|
||||
)
|
||||
from plugins.geenforce.seed_display_scope import (
|
||||
seed_display_scope, build_display_manifest, build_dispatcher_script,
|
||||
build_watchdog_script,
|
||||
DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME, ALWAYSON_FILENAME,
|
||||
)
|
||||
|
||||
@@ -25,10 +26,11 @@ def test_seed_creates_display_scope(db):
|
||||
# and do NOT inherit common.
|
||||
assert scope.iscommon is False
|
||||
|
||||
# Four Registry drift-heal entries + two inline PS1 (dispatcher, always-on).
|
||||
assert summary['entrycount'] == 6
|
||||
# Four Registry drift-heal entries + three inline PS1 (dispatcher,
|
||||
# watchdog, always-on).
|
||||
assert summary['entrycount'] == 7
|
||||
assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry',
|
||||
'Registry', 'PS1', 'PS1']
|
||||
'Registry', 'PS1', 'PS1', 'PS1']
|
||||
|
||||
|
||||
def test_registry_entries_use_valuematches_detection(db):
|
||||
@@ -112,14 +114,16 @@ def test_seed_draft_is_idempotent(db):
|
||||
assert first['entrycount'] == second['entrycount']
|
||||
assert first['dispatchersha256'] == second['dispatchersha256']
|
||||
assert first['alwaysonsha256'] == second['alwaysonsha256']
|
||||
assert first['watchdogsha256'] == second['watchdogsha256']
|
||||
|
||||
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
|
||||
assert len(entries) == 6
|
||||
# Exactly two inline payloads (dispatcher + always-on) exist after a rebuild,
|
||||
# not more - a payload-count invariant. (The underlying re-publish FK crash
|
||||
# only reproduces on MySQL, which enforces the manifestpayloads FK; it was
|
||||
# verified there directly. SQLite does not enforce it.)
|
||||
assert ManifestPayload.query.count() == 2
|
||||
assert len(entries) == 7
|
||||
# Exactly three inline payloads (dispatcher + watchdog + always-on) exist
|
||||
# after a rebuild, not more - a payload-count invariant. (The underlying
|
||||
# re-publish FK crash only reproduces on MySQL, which enforces the
|
||||
# manifestpayloads FK; it was verified there directly. SQLite does not
|
||||
# enforce it.)
|
||||
assert ManifestPayload.query.count() == 3
|
||||
|
||||
|
||||
def test_build_manifest_has_no_smb_exe_payloads(db):
|
||||
@@ -137,3 +141,42 @@ def test_dispatcher_script_is_ascii():
|
||||
# Plain ASCII only (no smart quotes / em-dashes) so the naming gate stays
|
||||
# green and the on-PC script parses cleanly.
|
||||
build_dispatcher_script().encode('ascii')
|
||||
|
||||
|
||||
def test_watchdog_entry_is_present_and_inline(db):
|
||||
"""The Startup shortcut runs once at logon, so an Edge update/crash leaves
|
||||
the display dead until the next logon. The watchdog is what recovers it."""
|
||||
manifest = build_display_manifest()
|
||||
entry = next(a for a in manifest['Applications']
|
||||
if a['Name'] == 'Display kiosk watchdog (relaunch Edge)')
|
||||
assert entry['Type'] == 'PS1'
|
||||
assert entry['PayloadSource'] == 'inline'
|
||||
assert entry['DetectionMethod'] == 'Always'
|
||||
|
||||
|
||||
def test_watchdog_script_registers_an_interactive_task():
|
||||
"""A SYSTEM task would start Edge in session 0 where nobody can see it, so
|
||||
the principal must be the interactive Users group."""
|
||||
script = build_watchdog_script()
|
||||
assert "New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545'" in script
|
||||
assert 'ShopDB Kiosk Watchdog' in script
|
||||
# The MaxValue repetition trap the kiosk installer documents. Checked
|
||||
# against CODE lines only - the script comments the trap on purpose, so a
|
||||
# naive substring test would fail on its own warning.
|
||||
code = [line for line in script.splitlines()
|
||||
if not line.lstrip().startswith('#')]
|
||||
assert not any('RepetitionDuration' in line for line in code)
|
||||
|
||||
|
||||
def test_watchdog_matches_on_the_command_line_not_the_image_name():
|
||||
"""Edge spawns many msedge.exe children; only the parent carries --kiosk.
|
||||
Testing 'is msedge running' would let a stray renderer mask a dead kiosk."""
|
||||
script = build_watchdog_script()
|
||||
assert "CommandLine -match '--kiosk'" in script
|
||||
|
||||
|
||||
def test_watchdog_does_nothing_without_a_kiosk_shortcut():
|
||||
"""It must never invent a browser launch on a PC that is not a kiosk."""
|
||||
script = build_watchdog_script()
|
||||
assert 'ShopDB Kiosk.lnk' in script
|
||||
assert 'shopdb-kiosk-lastlaunch' in script # debounce stamp
|
||||
|
||||
Reference in New Issue
Block a user