From 787f475208d345281f26037ee19a02a003798c88 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 12 Aug 2026 12:26:29 -0400 Subject: [PATCH] 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. --- plugins/geenforce/seed_display_scope.py | 98 +++++++++++++++++++ .../test_geenforce_display_seed.py | 74 ++++++++++++-- 2 files changed, 164 insertions(+), 8 deletions(-) diff --git a/plugins/geenforce/seed_display_scope.py b/plugins/geenforce/seed_display_scope.py index 31970c7..74d2ee8 100644 --- a/plugins/geenforce/seed_display_scope.py +++ b/plugins/geenforce/seed_display_scope.py @@ -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, } diff --git a/tests/test_plugins/test_geenforce_display_seed.py b/tests/test_plugins/test_geenforce_display_seed.py index d253bce..80f0164 100644 --- a/tests/test_plugins/test_geenforce_display_seed.py +++ b/tests/test_plugins/test_geenforce_display_seed.py @@ -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')