displays: clear a pending Edge update without waiting for 02:00

Every kiosk was sitting on Edge's restart-to-update prompt. The scope
already sets RelaunchNotification=2 so Edge restarts unattended, but
RelaunchWindow defers that restart to 02:00-04:00, so during the day the
update waits and there is nobody on site to dismiss the prompt.

A one-shot entry runs the Edge updater and stops the browser. It does not
relaunch it - the enforce task is SYSTEM in session 0, where a launched
browser is invisible - so it leans on the watchdog that already relaunches
the kiosk from the Startup shortcut. That relaunch is what applies the
staged update.

One-shot is DetectionMethod=MarkerFile. The engine writes the marker only
after a 0 exit, so a failed run retries next cycle instead of being
recorded as done, and the script exits 0 when no Edge was running - that
is a success, and failing it would withhold the marker and re-kill Edge on
every cycle from then on. The marker path carries a date, which is the
re-arm mechanism for a future update.

Ordered after the watchdog entry: a display seeing both for the first time
must have its relauncher registered before anything stops the browser.
This commit is contained in:
cproudlock
2026-08-12 12:26:29 -04:00
parent 457349d258
commit 787f475208
2 changed files with 164 additions and 8 deletions

View File

@@ -58,6 +58,14 @@ DISPLAY_TYPE_TARGETS = {
DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1'
# One-shot: apply a pending Edge update and bounce the kiosk browser.
# The marker path carries a DATE. That is the whole re-arm mechanism: change the
# date and every display runs it once more. Leave it alone and each display runs
# it exactly once, ever.
FORCE_EDGE_UPDATE_FILENAME = 'Invoke-EdgeForceUpdate.ps1'
FORCE_EDGE_UPDATE_MARKER = (
r'C:\ProgramData\ShopDB\markers\edge-force-update-2026-08-12.done')
def _relaunch_window_targets_comment():
"""Human note that lists the data-driven targets, for the manifest comment."""
@@ -576,6 +584,68 @@ Write-Host 'kiosk always-on enforced (power never-off + screensaver/lock disable
'''
def build_forceedgeupdate_script():
"""Return the inline one-shot Edge force-update PowerShell as text.
WHY THIS EXISTS: a pending Edge update parks a restart prompt on every
display and RelaunchWindow defers the forced restart to 02:00-04:00, so the
prompt can sit on screen all day with nobody on site to dismiss it.
This does not relaunch the browser itself. It cannot: the enforce task runs
as SYSTEM in session 0, where a launched browser is invisible (the same
constraint the dispatcher and the watchdog are built around). It stops Edge
and lets the EXISTING kiosk watchdog relaunch it from the Startup shortcut
within its interval, which is also what applies the staged update.
One-shot via DetectionMethod=MarkerFile: the engine writes the marker after
a 0 exit, so this runs once per display and is skipped on every later cycle.
A FAILED run writes no marker and is retried next cycle. To force another
round later, bump the date in FORCE_EDGE_UPDATE_MARKER.
"""
return r'''# Invoke-EdgeForceUpdate.ps1 -- one-shot: apply a pending Edge update now.
#
# Runs as SYSTEM under the enforce task. Stops Edge; the kiosk watchdog
# relaunches it (that relaunch is what completes a staged update).
$ErrorActionPreference = 'Continue'
$updater = Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeUpdate\MicrosoftEdgeUpdate.exe'
if (Test-Path $updater) {
# /ua = update-all check; runs the same path Edge's own scheduled task uses,
# so a staged update is downloaded rather than waited for.
try {
Start-Process -FilePath $updater -ArgumentList '/ua','/installsource','scheduler' `
-NoNewWindow -Wait -ErrorAction Stop
Write-Host 'edge updater ran'
} catch {
Write-Host "edge updater failed: $_"
}
} else {
Write-Host "edge updater not found at $updater"
}
# Stop every Edge process, kiosk or not. The watchdog notices no --kiosk
# process and relaunches from the Startup shortcut, which is the ONE source of
# truth for the target URL.
$edge = Get-Process -Name msedge -ErrorAction SilentlyContinue
if ($edge) {
try {
$edge | Stop-Process -Force -ErrorAction Stop
Write-Host "stopped $($edge.Count) edge process(es); watchdog will relaunch the kiosk"
} catch {
# Do NOT exit non-zero here. A process that vanished between the Get and
# the Stop is a success, not a failure - failing would withhold the
# marker and re-kill Edge on every cycle from now on.
Write-Host "stop-process reported: $_"
}
} else {
Write-Host 'no edge process running; watchdog will relaunch the kiosk'
}
exit 0
'''
def _registry_drift_heal_entry(name, regname, regvalue, regtype, comment):
"""One Type=Registry entry that writes a value and detects drift via
ValueMatches against that same path/name.
@@ -670,6 +740,26 @@ def build_display_manifest():
'PayloadRef': WATCHDOG_FILENAME,
'DetectionMethod': 'Always',
},
{
'_comment': (
'ONE-SHOT. Applies a pending Edge update now instead of waiting '
'for the 02:00-04:00 RelaunchWindow, which can leave the update '
'prompt on screen all day with nobody on site to dismiss it. '
'Ordered AFTER the watchdog entry on purpose: this stops Edge '
'and relies on the watchdog to bring the kiosk back, so the '
'watchdog must be registered first on a display seeing both for '
'the first time. DetectionMethod=MarkerFile makes it run once '
'per display - the engine writes the marker only after a 0 exit, '
'so a failed run retries next cycle. Re-arm for a future update '
'by bumping the date in FORCE_EDGE_UPDATE_MARKER.'),
'Name': 'Force pending Edge update and bounce the kiosk (one-shot)',
'Type': 'PS1',
'Script': FORCE_EDGE_UPDATE_FILENAME,
'PayloadSource': 'inline',
'PayloadRef': FORCE_EDGE_UPDATE_FILENAME,
'DetectionMethod': 'MarkerFile',
'DetectionPath': FORCE_EDGE_UPDATE_MARKER,
},
{
'_comment': (
'Keep the display awake 24/7 so the kiosk page stays visible: '
@@ -733,6 +823,13 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
watchdog, WATCHDOG_FILENAME,
'text/plain; charset=utf-8', watchdogbytes)
forceupdate = next(entry for entry in scope.entries
if entry.name.startswith('Force pending Edge update'))
forceupdatebytes = build_forceedgeupdate_script().encode('utf-8')
forceupdatepayload = service.store_inline_payload(
forceupdate, FORCE_EDGE_UPDATE_FILENAME,
'text/plain; charset=utf-8', forceupdatebytes)
publishedversion = None
if publish:
publishedversion = service.publish_scope(
@@ -747,5 +844,6 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
'dispatchersha256': payload.payloadsha256,
'alwaysonsha256': alwaysonpayload.payloadsha256,
'watchdogsha256': watchdogpayload.payloadsha256,
'forceupdatesha256': forceupdatepayload.payloadsha256,
'publishedversion': publishedversion,
}

View File

@@ -11,8 +11,9 @@ from plugins.geenforce.models import (
)
from plugins.geenforce.seed_display_scope import (
seed_display_scope, build_display_manifest, build_dispatcher_script,
build_watchdog_script,
build_watchdog_script, build_forceedgeupdate_script,
DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME, ALWAYSON_FILENAME,
FORCE_EDGE_UPDATE_FILENAME, FORCE_EDGE_UPDATE_MARKER,
)
@@ -26,11 +27,11 @@ def test_seed_creates_display_scope(db):
# and do NOT inherit common.
assert scope.iscommon is False
# Four Registry drift-heal entries + three inline PS1 (dispatcher,
# watchdog, always-on).
assert summary['entrycount'] == 7
# Four Registry drift-heal entries + four inline PS1 (dispatcher, watchdog,
# one-shot Edge force-update, always-on).
assert summary['entrycount'] == 8
assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry',
'Registry', 'PS1', 'PS1', 'PS1']
'Registry', 'PS1', 'PS1', 'PS1', 'PS1']
def test_registry_entries_use_valuematches_detection(db):
@@ -115,15 +116,17 @@ def test_seed_draft_is_idempotent(db):
assert first['dispatchersha256'] == second['dispatchersha256']
assert first['alwaysonsha256'] == second['alwaysonsha256']
assert first['watchdogsha256'] == second['watchdogsha256']
assert first['forceupdatesha256'] == second['forceupdatesha256']
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
assert len(entries) == 7
# Exactly three inline payloads (dispatcher + watchdog + always-on) exist
assert len(entries) == 8
# Exactly four inline payloads (dispatcher + watchdog + force-update +
# 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
assert ManifestPayload.query.count() == 4
def test_build_manifest_has_no_smb_exe_payloads(db):
@@ -203,3 +206,58 @@ def test_watchdog_refuses_to_run_as_system():
so a mis-registered task would kill and relaunch the display every cycle."""
script = build_watchdog_script()
assert 'IsSystem' in script
def _forceupdate_entry():
return next(a for a in build_display_manifest()['Applications']
if a['Name'].startswith('Force pending Edge update'))
def test_forceupdate_is_a_one_shot_marker_entry(db):
"""MarkerFile is what makes it one-shot. With Always it would kill Edge on
every enforce cycle forever, which is a kiosk that never stays up."""
entry = _forceupdate_entry()
assert entry['Type'] == 'PS1'
assert entry['PayloadSource'] == 'inline'
assert entry['DetectionMethod'] == 'MarkerFile'
# The engine only writes the marker when DetectionPath is set; without it
# the entry silently degrades to running every cycle.
assert entry['DetectionPath'] == FORCE_EDGE_UPDATE_MARKER
def test_forceupdate_runs_after_the_watchdog_is_registered(db):
"""It stops Edge and relies on the watchdog to bring the kiosk back, so on a
display seeing both for the first time the watchdog must come first."""
names = [a['Name'] for a in build_display_manifest()['Applications']]
watchdog = names.index('Display kiosk watchdog (relaunch Edge)')
forceupdate = next(i for i, n in enumerate(names)
if n.startswith('Force pending Edge update'))
assert watchdog < forceupdate
def test_forceupdate_payload_is_stored(db):
"""An entry whose payload never stored would enforce nothing at all."""
summary = seed_display_scope()
payload = ManifestPayload.query.filter_by(
filename=FORCE_EDGE_UPDATE_FILENAME).one()
assert payload.payloadsha256 == summary['forceupdatesha256']
def test_forceupdate_script_exits_zero_so_the_marker_is_written():
"""The engine writes the marker only on a 0 exit. A script that failed on
'no Edge running' would never mark done and would re-kill Edge forever."""
script = build_forceedgeupdate_script()
assert script.rstrip().endswith('exit 0')
assert 'exit 1' not in script
def test_forceupdate_script_does_not_launch_the_browser_itself():
"""SYSTEM runs in session 0, where a launched browser is invisible. Relaunch
is the watchdog's job, from the Startup shortcut."""
script = build_forceedgeupdate_script()
assert 'msedge' in script
assert 'Start-Process' not in script.split('# Stop every Edge process')[1]
def test_forceupdate_script_is_ascii():
build_forceedgeupdate_script().encode('ascii')