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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user