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'
|
ALWAYSON_FILENAME = 'Set-DisplayAlwaysOn.ps1'
|
||||||
|
|
||||||
|
|
||||||
@@ -416,6 +611,26 @@ def build_display_manifest():
|
|||||||
'PayloadRef': DISPATCHER_FILENAME,
|
'PayloadRef': DISPATCHER_FILENAME,
|
||||||
'DetectionMethod': 'Always',
|
'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': (
|
'_comment': (
|
||||||
'Keep the display awake 24/7 so the kiosk page stays visible: '
|
'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,
|
alwayson, ALWAYSON_FILENAME,
|
||||||
'text/plain; charset=utf-8', alwaysonbytes)
|
'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
|
publishedversion = None
|
||||||
if publish:
|
if publish:
|
||||||
publishedversion = service.publish_scope(
|
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],
|
'entrytypes': [entry.entrytype for entry in scope.entries],
|
||||||
'dispatchersha256': payload.payloadsha256,
|
'dispatchersha256': payload.payloadsha256,
|
||||||
'alwaysonsha256': alwaysonpayload.payloadsha256,
|
'alwaysonsha256': alwaysonpayload.payloadsha256,
|
||||||
|
'watchdogsha256': watchdogpayload.payloadsha256,
|
||||||
'publishedversion': publishedversion,
|
'publishedversion': publishedversion,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from plugins.geenforce.models import (
|
|||||||
)
|
)
|
||||||
from plugins.geenforce.seed_display_scope import (
|
from plugins.geenforce.seed_display_scope import (
|
||||||
seed_display_scope, build_display_manifest, build_dispatcher_script,
|
seed_display_scope, build_display_manifest, build_dispatcher_script,
|
||||||
|
build_watchdog_script,
|
||||||
DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME, ALWAYSON_FILENAME,
|
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.
|
# and do NOT inherit common.
|
||||||
assert scope.iscommon is False
|
assert scope.iscommon is False
|
||||||
|
|
||||||
# Four Registry drift-heal entries + two inline PS1 (dispatcher, always-on).
|
# Four Registry drift-heal entries + three inline PS1 (dispatcher,
|
||||||
assert summary['entrycount'] == 6
|
# watchdog, always-on).
|
||||||
|
assert summary['entrycount'] == 7
|
||||||
assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry',
|
assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry',
|
||||||
'Registry', 'PS1', 'PS1']
|
'Registry', 'PS1', 'PS1', 'PS1']
|
||||||
|
|
||||||
|
|
||||||
def test_registry_entries_use_valuematches_detection(db):
|
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['entrycount'] == second['entrycount']
|
||||||
assert first['dispatchersha256'] == second['dispatchersha256']
|
assert first['dispatchersha256'] == second['dispatchersha256']
|
||||||
assert first['alwaysonsha256'] == second['alwaysonsha256']
|
assert first['alwaysonsha256'] == second['alwaysonsha256']
|
||||||
|
assert first['watchdogsha256'] == second['watchdogsha256']
|
||||||
|
|
||||||
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
|
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
|
||||||
assert len(entries) == 6
|
assert len(entries) == 7
|
||||||
# Exactly two inline payloads (dispatcher + always-on) exist after a rebuild,
|
# Exactly three inline payloads (dispatcher + watchdog + always-on) exist
|
||||||
# not more - a payload-count invariant. (The underlying re-publish FK crash
|
# after a rebuild, not more - a payload-count invariant. (The underlying
|
||||||
# only reproduces on MySQL, which enforces the manifestpayloads FK; it was
|
# re-publish FK crash only reproduces on MySQL, which enforces the
|
||||||
# verified there directly. SQLite does not enforce it.)
|
# manifestpayloads FK; it was verified there directly. SQLite does not
|
||||||
assert ManifestPayload.query.count() == 2
|
# enforce it.)
|
||||||
|
assert ManifestPayload.query.count() == 3
|
||||||
|
|
||||||
|
|
||||||
def test_build_manifest_has_no_smb_exe_payloads(db):
|
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
|
# Plain ASCII only (no smart quotes / em-dashes) so the naming gate stays
|
||||||
# green and the on-PC script parses cleanly.
|
# green and the on-PC script parses cleanly.
|
||||||
build_dispatcher_script().encode('ascii')
|
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