Files
shopdb-flask/plugins/geenforce/seed_display_scope.py
cproudlock 035419fa51 ADR-015: stop shipping one site's values, and make the rule a gate
The scanner has been reporting the same count for weeks, which is what a rule
that only prints becomes. It now FAILS the build, and it looks where the leaks
actually were: PowerShell, the installer, the seeds, generated JSON, the
frontend - case-insensitively, across plugins, shopdb, scripts, deploy, tools.
A line that is deliberate declares itself with an ADR-015-OK marker and a
reason, so the claim is visible in review instead of tolerated in silence.

What it found, fixed here:

- The shadow client wrote one site's ShopDB URL into HKLM whenever the registry
  disagreed. At the site it was written for that reads as healing drift;
  anywhere else it overwrites the site's own address on every enforce cycle,
  and the site cannot win because the cycle repeats. The bay's value now wins,
  an explicit -BaseUrl seeds it, and with neither there is nothing honest to
  write, so it says so and skips.
- The kiosk dispatcher fell back to one plant's host when HKLM was unset, so a
  kiosk elsewhere quietly opened a server it has no business reaching. The
  fallback is now this site's site_base_url, baked in at seed time, and the
  dispatcher refuses rather than guessing when neither is set. Its legacy
  shortcut matcher derives the host from that URL instead of naming one.
- The OpenAPI generator hardcoded a production hostname into every spec it
  generated, which then published to a public wiki. The relative mount is the
  only server it can honestly name; a site passes its own by environment.
- Placeholders and examples in the UI and the client help offered real internal
  subnets and a real production URL. They now use documentation ranges.

Both publication gates - the export scrub and the docs publishability test -
carry the site patterns, which neither did. One plant's hostname, FQDN and
internal networks are out of the documentation and the generated specs.

Comments naming the reference site are reworded rather than deleted: the
reasoning is worth keeping, the plant name is not what makes it true.
2026-08-14 13:47:39 -04:00

944 lines
43 KiB
Python

"""Author the gea-shopfloor-display runtime scope programmatically.
Displays are Intune/Entra-joined, credential-less kiosk PCs that pull their
manifest over HTTPS (no SMB share). The kiosk engine and the kiosk browser are
BAKED INTO THE DISPLAY IMAGE, so this scope does not ship any EXE payloads; it
heals POLICY / CONFIG drift only, plus one dispatcher that points the kiosk at
the right target for the display subtype.
What this scope contains:
- Four Registry drift-heal entries that re-assert the Microsoft Edge kiosk
relaunch policies from the imaging script 09-Setup-Display.ps1 (so a display
that loses those policies self-heals on the next enforce cycle without a
keyboard or mouse on site).
- One inline PS1 dispatcher that reads C:\\Enrollment\\display-type.txt and
launches the kiosk target for the subtype. The display-type -> target map is
a data-driven table (DISPLAY_TYPE_TARGETS) so the targets are easy to edit.
Self-sufficient: the display scope carries EVERYTHING a display enforces and
does NOT inherit the fleet-wide 'common' scope. Displays run the enforcer with
common-merge off (the client default), so common's SMB-backed fleet entries
never reach a share-less display. This keeps the display path simple and needs
no common-payload repackaging.
Authoring path mirrors how every other scope is created: build a manifest dict
and hand it to service.replace_scope_draft (the same call the import-share CLI
uses), then attach the inline dispatcher payload and optionally publish. Re-
running replace_scope_draft is an idempotent draft rebuild.
"""
import hashlib
import os
from shopdb.api import db, DISPLAY_ROLE_PATHS
from . import service
SCOPE_NAME = 'gea-shopfloor-display'
SCOPE_PHASE = 'runtime'
SCOPE_VERSION = '2.0'
# Edge kiosk relaunch policy key. Values below mirror 09-Setup-Display.ps1
# exactly so the imaging-time state and the enforced state never disagree.
EDGE_POLICY_PATH = 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge'
RELAUNCH_WINDOW_JSON = (
'{"entries":[{"start":{"hour":2,"minute":0},"duration_mins":120}]}')
# Data-driven display-type -> kiosk target map. The value of
# C:\Enrollment\display-type.txt selects the row; the target is a route the
# kiosk browser opens against the local kiosk base URL. Keys are matched
# case-insensitively by the dispatcher.
#
# NOT a copy any more: this IS core's DISPLAY_ROLE_PATHS, read through the
# contract surface. It used to be a second map with the same routes under
# different names, which is how a display could report a role core could not
# store. Retarget a subtype in shopdb/core/models/dashboarddefault.py.
#
# TODO-confirm: 3DPrintRoom points at the printedparts /parts-kiosk route as a
# PLACEHOLDER. Confirm the real 3D-print-room kiosk target with the floor team
# before this scope is published to production displays.
DISPLAY_TYPE_TARGETS = DISPLAY_ROLE_PATHS
DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1'
# The enforce client module itself, delivered as an enforced entry so a client
# change no longer means hands on every kiosk. Path matches where
# Install-ShopdbKiosk.ps1 puts it, so the installer stays the BOOTSTRAP and this
# becomes the update path.
CLIENT_MODULE_FILENAME = 'ShopdbEnforceClient.psm1'
CLIENT_MODULE_DEST = r'C:\ProgramData\GE-Enforce\ShopdbEnforceClient.psm1'
# 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 runs it exactly once.
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."""
pairs = ', '.join(f'{name}={route}'
for name, route in DISPLAY_TYPE_TARGETS.items())
return pairs
def _site_base_url():
"""This site's public base URL from settings, or '' when it is unset.
Wrapped because the script builders are also called with no application
context (the seed tests build the text and check it compiles), and a
settings lookup there raises rather than returning a default.
"""
try:
from shopdb.api import Setting
return (Setting.get('site_base_url', '') or '').strip().rstrip('/')
except Exception:
return ''
def build_dispatcher_script():
"""Return the inline dispatcher PowerShell as text.
The display-type -> target map is emitted as a hashtable at the top of the
script (generated from DISPLAY_TYPE_TARGETS) so the on-PC script and the
manifest metadata agree and both stay easy to edit.
"""
table_lines = []
for display_type, route in DISPLAY_TYPE_TARGETS.items():
table_lines.append(f" '{display_type}' = '{route}'")
table_body = ';\n'.join(table_lines)
# Baked in at seed time because the emitted script runs on a kiosk with no
# route back to the database. Blank when the site has not set it, and the
# dispatcher refuses rather than guessing - see the comment on $KioskBaseUrl.
kiosk_base_url = _site_base_url()
return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher.
#
# Reads C:\\Enrollment\\display-type.txt and ensures an all-users Startup
# shortcut that launches Edge in kiosk fullscreen at the route mapped for that
# subtype. The kiosk browser is baked into the display image; this only points
# the launch at the right target and heals the shortcut on a URL/type change.
#
# WHY a Startup shortcut, not Start-Process: this runs as SYSTEM under the
# enforce task. SYSTEM has no interactive desktop, so Start-Process msedge.exe
# opens INVISIBLY in session 0. A shortcut in the all-users Startup folder is
# instead launched by the auto-login user at logon, in their own visible
# session. Idempotent: prior ShopDB kiosk shortcuts are cleared first, then the
# one for the resolved target is written, so a display-type/URL change self-heals.
#
# The DisplayTypeTargets table below is the single source of truth for the
# subtype -> route map. Edit a row to retarget a subtype.
$ErrorActionPreference = 'Continue'
# --- Data-driven subtype -> kiosk route map ---
$DisplayTypeTargets = @{{
{table_body}
}}
# Base URL the kiosk browser opens; the route from the table is appended. Read
# from HKLM (the value the GE-Enforce client is configured with) so it stays
# site-agnostic. The fallback is THIS site's site_base_url setting, baked in
# when the scope was seeded - it used to be one particular plant's host, which
# meant a kiosk anywhere else with no registry value quietly opened a server it
# has no business reaching.
$KioskBaseUrl = '{kiosk_base_url}'
try {{
$shopdbConfig = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name BaseUrl -ErrorAction Stop
if ($shopdbConfig.BaseUrl) {{ $KioskBaseUrl = ([string]$shopdbConfig.BaseUrl).TrimEnd('/') }}
}} catch {{}}
if (-not $KioskBaseUrl) {{
Write-Host 'no ShopDB base URL: set HKLM:\\SOFTWARE\\GE\\ShopDB BaseUrl, or set site_base_url in Settings and re-publish the display scope.'
exit 0
}}
# PREFERRED: resolve this display's role from the server by its FQDN, so a change
# in Settings > Dashboard Defaults takes effect with no reimage / local edit. The
# FQDN is F<BIOS serial>.<domain> (GE device naming); domain from HKLM
# DisplayFqdnDomain or the default. display-role is a PUBLIC endpoint (no token).
$path = $null
$serial = ''
try {{ $serial = ([string](Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber).Trim() }} catch {{}}
$fqdnDomain = 'device.geaerospace.net' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
try {{
$ddom = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name DisplayFqdnDomain -ErrorAction Stop
if ($ddom.DisplayFqdnDomain) {{ $fqdnDomain = ([string]$ddom.DisplayFqdnDomain).Trim().Trim('.') }}
}} catch {{}}
if ($serial) {{
$fqdn = ('F' + $serial + '.' + $fqdnDomain).ToLower()
try {{
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$roleUri = "$KioskBaseUrl/api/dashboarddefaults/display-role?fqdn=$([uri]::EscapeDataString($fqdn))"
$resp = Invoke-RestMethod -Uri $roleUri -TimeoutSec 20 -ErrorAction Stop
if ($resp -and $resp.data -and $resp.data.path) {{
$path = [string]$resp.data.path
Write-Host "server role for $fqdn -> $path"
}}
}} catch {{ Write-Host "display-role lookup failed for $fqdn : $($_.Exception.Message)" }}
}}
# FALLBACK: the local display-type.txt map (offline, or no server mapping yet).
if (-not $path) {{
$displayTypeFile = 'C:\\Enrollment\\display-type.txt'
if (Test-Path -LiteralPath $displayTypeFile) {{
$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim()
if (-not [string]::IsNullOrWhiteSpace($displayType)) {{
$matchedKey = $DisplayTypeTargets.Keys | Where-Object {{ $_ -ieq $displayType }} | Select-Object -First 1
if ($matchedKey) {{ $path = [string]$DisplayTypeTargets[$matchedKey]; Write-Host "local display-type '$displayType' -> $path" }}
}}
}}
}}
if (-not $path) {{
Write-Host 'No display role from server or display-type.txt; nothing to configure.'
return
}}
$kioskUrl = "$KioskBaseUrl$path"
# Resolve msedge.exe (baked into the display image).
$edge = $null
foreach ($p in @("${{env:ProgramFiles(x86)}}\\Microsoft\\Edge\\Application\\msedge.exe",
"$env:ProgramFiles\\Microsoft\\Edge\\Application\\msedge.exe")) {{
if (Test-Path -LiteralPath $p) {{ $edge = $p; break }}
}}
if (-not $edge) {{ Write-Host 'msedge.exe not found; cannot write kiosk startup shortcut.'; return }}
$startup = Join-Path $env:ProgramData 'Microsoft\\Windows\\Start Menu\\Programs\\Startup'
if (-not (Test-Path $startup)) {{ New-Item -ItemType Directory -Path $startup -Force | Out-Null }}
$shell = New-Object -ComObject WScript.Shell
# Clear leftover kiosk launchers from THIS or PRIOR installs so two kiosks do
# not fight: our own 'ShopDB Kiosk*.lnk', ANY .lnk that launches Edge in --kiosk
# mode, and any .url pointing at a shopdb kiosk page. Unrelated Startup items are
# left alone.
Get-ChildItem -LiteralPath $startup -Filter '*.lnk' -ErrorAction SilentlyContinue | ForEach-Object {{
$drop = $false
if ($_.Name -like 'ShopDB Kiosk*' -or
$_.Name -like 'GE Aerospace Dashboard*' -or
$_.Name -like 'GE Aerospace Lobby*') {{ $drop = $true }}
else {{
try {{
$existing = $shell.CreateShortcut($_.FullName)
$isBrowser = $existing.TargetPath -match '(?i)(msedge|chrome)\\.exe$'
# -kiosk matches both '-kiosk' and '--kiosk'. Also catch any browser
# shortcut pointing at a shopdb kiosk URL (incl. the dead
# shopfloor-dashboard route + --app variants).
# The host comes from $KioskBaseUrl, not from a literal: this used
# to name one site's server, so at any other site a legacy shortcut
# pointing at that site's own ShopDB was not recognised and never
# cleaned up.
$kioskHost = ''
try {{ $kioskHost = ([uri]$KioskBaseUrl).Host }} catch {{}}
$kioskArgs = ($existing.Arguments -match '(?i)-kiosk') -or `
($kioskHost -and $existing.Arguments -like "*$kioskHost*") -or `
($existing.Arguments -match '(?i)/shopdb/|shopfloor-dashboard')
if ($isBrowser -and $kioskArgs) {{ $drop = $true }}
}} catch {{}}
}}
if ($drop) {{ Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }}
}}
Get-ChildItem -LiteralPath $startup -Filter '*.url' -ErrorAction SilentlyContinue | ForEach-Object {{
try {{
$body = Get-Content -LiteralPath $_.FullName -Raw -ErrorAction Stop
if ($body -match '/shopdb/(tv|shopfloor|parts-kiosk)' -or $body -match 'shopfloor-dashboard') {{
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue
}}
}} catch {{}}
}}
# Legacy autostarts the OLD Dashboard/Lobby Inno installers planted keep
# relaunching the dead old URL (-> 404 white screen) and BEAT our shortcut. They
# hide in several places: the 32-bit Inno installer's HKLM Run write is
# WOW64-redirected into Wow6432Node (invisible to 64-bit tooling), and copies
# can sit in per-user hives, RunOnce, or the policy Run key. Sweep ALL of them
# by legacy NAME and by VALUE (any Run entry whose data points at the old URLs),
# across the native + Wow6432Node views and every loaded user hive, plus every
# per-user + common Startup folder.
$legacyNames = @('GE Aerospace Dashboard','GE Aerospace Lobby Display')
$oldUrlPattern = 'tv-dashboard|shopfloor-dashboard'
$regRoots = @('HKLM:\\SOFTWARE', 'HKLM:\\SOFTWARE\\Wow6432Node')
if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {{
New-PSDrive -Name HKU -PSProvider Registry -Root HKEY_USERS -ErrorAction SilentlyContinue | Out-Null
}}
try {{
Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction Stop |
Where-Object {{ $_.PSChildName -notmatch '_Classes$' }} |
ForEach-Object {{
$regRoots += "HKU:\\$($_.PSChildName)\\SOFTWARE"
$regRoots += "HKU:\\$($_.PSChildName)\\SOFTWARE\\Wow6432Node"
}}
}} catch {{}}
$runSubkeys = @(
'Microsoft\\Windows\\CurrentVersion\\Run',
'Microsoft\\Windows\\CurrentVersion\\RunOnce',
'Microsoft\\Windows\\CurrentVersion\\Policies\\Explorer\\Run'
)
foreach ($root in $regRoots) {{
foreach ($sub in $runSubkeys) {{
$key = Join-Path $root $sub
if (-not (Test-Path -LiteralPath $key)) {{ continue }}
$props = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue
if (-not $props) {{ continue }}
foreach ($prop in $props.PSObject.Properties) {{
if ($prop.Name -like 'PS*') {{ continue }}
if (($legacyNames -contains $prop.Name) -or ("$($prop.Value)" -match $oldUrlPattern)) {{
Remove-ItemProperty -LiteralPath $key -Name $prop.Name -Force -ErrorAction SilentlyContinue
Write-Host "removed legacy autostart $key -> $($prop.Name)"
}}
}}
}}
}}
$startupDirs = @($startup, (Join-Path $env:Public 'Desktop'))
Get-ChildItem 'C:\\Users' -Directory -ErrorAction SilentlyContinue | ForEach-Object {{
$startupDirs += (Join-Path $_.FullName 'AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup')
}}
foreach ($dir in ($startupDirs | Select-Object -Unique)) {{
if (-not (Test-Path -LiteralPath $dir)) {{ continue }}
Get-ChildItem -LiteralPath $dir -Filter '*.lnk' -ErrorAction SilentlyContinue | ForEach-Object {{
$drop = ($_.Name -like 'GE Aerospace *')
if (-not $drop) {{
try {{
$existing = $shell.CreateShortcut($_.FullName)
if ("$($existing.Arguments)" -match $oldUrlPattern) {{ $drop = $true }}
}} catch {{}}
}}
if ($drop) {{
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue
Write-Host "removed legacy startup shortcut $($_.FullName)"
}}
}}
}}
try {{
Get-CimInstance Win32_Process -Filter "Name = 'msedge.exe'" -ErrorAction SilentlyContinue |
Where-Object {{ $_.CommandLine -match 'tv-dashboard|shopfloor-dashboard' }} |
ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; Write-Host "killed old-URL kiosk Edge pid $($_.ProcessId)" }}
}} catch {{}}
# remove a stale VBS launcher from an earlier build (kiosk now uses a direct
# Edge shortcut again). Harmless if absent.
$staleVbs = Join-Path (Join-Path $env:ProgramData 'ShopDB') 'Start-ShopdbKiosk.vbs'
if (Test-Path -LiteralPath $staleVbs) {{ Remove-Item -LiteralPath $staleVbs -Force -ErrorAction SilentlyContinue }}
$edgeArgs = "--kiosk `"$kioskUrl`" --edge-kiosk-type=fullscreen"
$lnkPath = Join-Path $startup 'ShopDB Kiosk.lnk'
$lnk = $shell.CreateShortcut($lnkPath)
$lnk.TargetPath = $edge
$lnk.Arguments = $edgeArgs
$lnk.WorkingDirectory = Split-Path -Parent $edge
$lnk.WindowStyle = 3
$lnk.Description = "ShopDB kiosk display: $kioskUrl"
$lnk.Save()
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)
}
# REFUSE to run as SYSTEM. MainWindowHandle is session-scoped: a SYSTEM caller
# reads 0 for a perfectly healthy kiosk in the user's session, so this script
# would judge it dead, kill it and relaunch - every single cycle. The scheduled
# task uses an interactive Users principal precisely so this cannot happen, and
# this guard makes a mis-registered task fail loudly instead of thrashing the
# display.
if ([Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) {
Log 'refusing to run as SYSTEM: window state is not readable across sessions.'
return
}
# 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
}
# A kiosk counts as UP only when a --kiosk process still owns a VISIBLE WINDOW.
#
# Two traps, and the obvious checks fall into one or the other. Testing "is
# msedge running" lets a stray renderer or crashpad child mask a dead kiosk.
# Testing the command line alone is what shipped first, and it is not enough
# either: after an Edge update the window can be gone while a --kiosk process
# lingers, so the watchdog saw "kiosk is up" and never relaunched - observed on
# a real display, Edge closed on the desktop but still listed in Task Manager.
#
# MainWindowHandle is the discriminator. A process with no window cannot be
# showing anything to the floor, whatever its command line says.
$kioskProcs = @()
try {
$kioskProcs = @(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
}
$visible = @()
foreach ($p in $kioskProcs) {
$proc = Get-Process -Id $p.ProcessId -EA SilentlyContinue
if ($proc -and $proc.MainWindowHandle -ne 0) { $visible += $p }
}
if ($visible.Count -gt 0) { return }
# Windowless --kiosk processes are orphans. They must be killed BEFORE
# relaunching: leaving them would keep the next cycle's check satisfied again,
# and a second browser would fight the first for the display.
if ($kioskProcs.Count -gt 0) {
Log ("found {0} windowless --kiosk process(es) - killing before relaunch" -f $kioskProcs.Count)
foreach ($p in $kioskProcs) {
try {
Stop-Process -Id $p.ProcessId -Force -EA Stop
Log (" killed pid {0}" -f $p.ProcessId)
} catch {
Log (" could not kill pid {0}: {1}" -f $p.ProcessId, $_)
}
}
}
# 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'
def build_alwayson_script():
"""Return the inline always-on PowerShell as text.
Keeps a display awake 24/7 (kiosk page always visible): powercfg never-off on
AC+DC, screensaver + auto-lock disabled. Plain raw string - no table
interpolation, so no brace escaping.
"""
return r'''# Set-DisplayAlwaysOn.ps1 -- gea-shopfloor-display always-on.
# Keeps the display awake 24/7 so the kiosk page stays visible: powercfg
# never-off (AC+DC) + screensaver/auto-lock disabled. SYSTEM, every cycle.
$ErrorActionPreference = 'Continue'
foreach ($c in @(
'monitor-timeout-ac 0','monitor-timeout-dc 0',
'standby-timeout-ac 0','standby-timeout-dc 0',
'hibernate-timeout-ac 0','hibernate-timeout-dc 0',
'disk-timeout-ac 0','disk-timeout-dc 0')) {
try { Start-Process -FilePath powercfg -ArgumentList "/change $c" -NoNewWindow -Wait -ErrorAction Stop } catch {}
}
# disable screensaver + secure lock for future profiles (.DEFAULT) + loaded hives.
if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {
New-PSDrive -Name HKU -PSProvider Registry -Root HKEY_USERS -ErrorAction SilentlyContinue | Out-Null
}
$hives = @('HKU:\.DEFAULT')
try {
Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction Stop |
Where-Object { $_.PSChildName -match '^S-1-5-21' -and $_.PSChildName -notmatch '_Classes$' } |
ForEach-Object { $hives += "HKU:\$($_.PSChildName)" }
} catch {}
foreach ($h in $hives) {
$cp = "$h\Control Panel\Desktop"
try {
if (-not (Test-Path $cp)) { New-Item -Path $cp -Force -ErrorAction Stop | Out-Null }
Set-ItemProperty -Path $cp -Name ScreenSaveActive -Value '0' -ErrorAction Stop
Set-ItemProperty -Path $cp -Name ScreenSaverIsSecure -Value '0' -ErrorAction SilentlyContinue
Set-ItemProperty -Path $cp -Name ScreenSaveTimeOut -Value '0' -ErrorAction SilentlyContinue
} catch {}
}
# never auto-lock the kiosk console.
try {
$pol = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization'
if (-not (Test-Path $pol)) { New-Item -Path $pol -Force | Out-Null }
Set-ItemProperty -Path $pol -Name NoLockScreen -Value 1 -Type DWord -ErrorAction SilentlyContinue
} catch {}
Write-Host 'kiosk always-on enforced (power never-off + screensaver/lock disabled).'
'''
def read_client_module():
"""The enforce client module's bytes, read from this repo.
Shipping it as a manifest entry closes a real gap: the module is installed
once by Install-ShopdbKiosk.ps1 and never refreshed, so a client change
reached the server with the code deploy and then sat one directory away from
where kiosks fetch from, waiting for someone to re-stage the installer
bundle by hand. Displays are share-less by design; hands on every kiosk is
the wrong cost for a client change.
"""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'client', CLIENT_MODULE_FILENAME)
with open(path, 'rb') as handle:
return handle.read()
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.
DetectionValue is stored as a string; the engine string-coerces for
ValueMatches, so a DWord value of 2 detects against '2'.
"""
return {
'_comment': comment,
'Name': name,
'Type': 'Registry',
'RegPath': EDGE_POLICY_PATH,
'RegName': regname,
'RegValue': regvalue,
'RegType': regtype,
'DetectionMethod': 'ValueMatches',
'DetectionPath': EDGE_POLICY_PATH,
'DetectionName': regname,
'DetectionValue': str(regvalue),
}
def build_display_manifest():
"""Return the gea-shopfloor-display manifest dict (Applications in order).
Four Edge kiosk relaunch-policy drift-heal entries, then the one dispatcher
entry. The dispatcher is declared PayloadSource=inline with no hash yet; the
seed attaches the real payload bytes (and its sha256) after the draft rows
exist. Kept payload-free otherwise: the kiosk engine and browser are baked
into the display image, not shipped over HTTPS.
"""
clientbytes = read_client_module()
applications = [
{
'_comment': (
'The GE-Enforce client module itself. Install-ShopdbKiosk.ps1 '
'lays this down once at bootstrap and never refreshes it, so a '
'client change reached the server with the code deploy and then '
'sat one directory away from where kiosks fetch, waiting for '
'someone to re-stage the installer bundle by hand. Displays are '
'share-less by design; hands on every kiosk is the wrong cost '
'for a client change. Hash detection against the shipped bytes, '
'so a matching module is left alone and only a changed one is '
'rewritten. SELF-MODIFYING BY DESIGN: this module is what stages '
'payloads, but PowerShell loads it into memory at start, so '
'rewriting the file mid-run is harmless and takes effect on the '
'NEXT cycle. Pilot a client change on ONE kiosk before the fleet '
'- a broken module cannot fetch its own replacement, and on a '
'share-less display that means a site visit.'),
'Name': 'GE-Enforce client module (self-update)',
'Type': 'File',
'Source': CLIENT_MODULE_FILENAME,
'Destination': CLIENT_MODULE_DEST,
'PayloadSource': 'inline',
'PayloadRef': CLIENT_MODULE_FILENAME,
'DetectionMethod': 'Hash',
'DetectionPath': CLIENT_MODULE_DEST,
'DetectionValue': hashlib.sha256(clientbytes).hexdigest(),
},
_registry_drift_heal_entry(
'Edge kiosk RelaunchNotification (Required auto-restart)',
'RelaunchNotification', 2, 'DWord',
'RelaunchNotification=2 (Required): Edge auto-restarts after the '
'notification period. Displays have no operator to dismiss the '
'update dialog, so this is the only mode that recovers unattended. '
'Heals drift of the policy set at imaging by 09-Setup-Display.ps1.'),
_registry_drift_heal_entry(
'Edge kiosk RelaunchNotificationPeriod (1 hour)',
'RelaunchNotificationPeriod', 3600000, 'DWord',
'Milliseconds before the forced auto-restart. 3600000 ms = 1 hour.'),
_registry_drift_heal_entry(
'Edge kiosk RelaunchHeadsUpPeriod (1 minute)',
'RelaunchHeadsUpPeriod', 60000, 'DWord',
'Milliseconds of final warning before auto-restart. 60000 ms = 1 '
'minute.'),
_registry_drift_heal_entry(
'Edge kiosk RelaunchWindow (02:00-04:00)',
'RelaunchWindow', RELAUNCH_WINDOW_JSON, 'String',
'Overnight forced-restart window (02:00 start, 120 minute '
'duration) so business-hour updates wait until off-hours and the '
'dialog stays invisible during the day.'),
{
'_comment': (
'Kiosk dispatcher. Reads C:\\Enrollment\\display-type.txt and '
'writes an all-users Startup shortcut that launches Edge kiosk '
'fullscreen at the subtype target. Data-driven map: '
+ _relaunch_window_targets_comment()
+ '. Base URL from HKLM BaseUrl, else the WJ host. Runs as SYSTEM '
'(no interactive desktop), so it does NOT Start-Process Edge '
'(that opens invisibly in session 0) - the auto-login user runs '
'the shortcut at logon in a visible session. Delivered inline over '
'HTTPS (share-less displays); Always re-asserts, idempotent '
'(rewrites only the resolved-target shortcut, self-heals a '
'type/URL change).'),
'Name': 'Display kiosk dispatcher (display-type.txt)',
'Type': 'PS1',
'Script': DISPATCHER_FILENAME,
'PayloadSource': 'inline',
'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': (
'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: '
'powercfg never-off (AC+DC) + screensaver/auto-lock disabled. '
'Delivered inline over HTTPS; Always re-asserts + self-heals a '
'power-plan drift each cycle.'),
'Name': 'Display always-on (no sleep/blank/lock)',
'Type': 'PS1',
'Script': ALWAYSON_FILENAME,
'PayloadSource': 'inline',
'PayloadRef': ALWAYSON_FILENAME,
'DetectionMethod': 'Always',
},
]
return {
'Version': SCOPE_VERSION,
'_comment': (
'gea-shopfloor-display runtime scope. Heals Edge kiosk relaunch '
'policy drift and dispatches the kiosk to the subtype target. No '
'EXE payloads: kiosk engine and browser are baked into the display '
'image. Self-sufficient: displays do NOT inherit the common '
'scope.'),
'Applications': applications,
}
def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
"""Create/refresh the gea-shopfloor-display draft scope and its entries.
Idempotent for the draft: replace_scope_draft rebuilds the draft rows, and
the inline dispatcher payload is content-addressed (a re-run stores the same
bytes to the same sha256). Set publish=True to also freeze a published
snapshot (that step is NOT idempotent: it always creates a new version).
Commits the session. Returns a summary dict:
{scopeid, entrycount, entrytypes, dispatchersha256, publishedversion}.
"""
manifest = build_display_manifest()
scope = service.replace_scope_draft(SCOPE_NAME, SCOPE_PHASE, manifest)
# Flush so the new entries get entryids before the inline payload attaches.
db.session.flush()
dispatcher = next(entry for entry in scope.entries
if entry.name == 'Display kiosk dispatcher (display-type.txt)')
scriptbytes = build_dispatcher_script().encode('utf-8')
payload = service.store_inline_payload(
dispatcher, DISPATCHER_FILENAME,
'text/plain; charset=utf-8', scriptbytes)
alwayson = next(entry for entry in scope.entries
if entry.name == 'Display always-on (no sleep/blank/lock)')
alwaysonbytes = build_alwayson_script().encode('utf-8')
alwaysonpayload = service.store_inline_payload(
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)
clientmodule = next(entry for entry in scope.entries
if entry.name.startswith('GE-Enforce client module'))
clientbytes = read_client_module()
clientpayload = service.store_inline_payload(
clientmodule, CLIENT_MODULE_FILENAME,
'text/plain; charset=utf-8', clientbytes)
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(
SCOPE_NAME, SCOPE_PHASE, notes=notes)
db.session.commit()
return {
'scopeid': scope.scopeid,
'entrycount': len(scope.entries),
'entrytypes': [entry.entrytype for entry in scope.entries],
'dispatchersha256': payload.payloadsha256,
'alwaysonsha256': alwaysonpayload.payloadsha256,
'watchdogsha256': watchdogpayload.payloadsha256,
'forceupdatesha256': forceupdatepayload.payloadsha256,
'clientmodulesha256': clientpayload.payloadsha256,
'publishedversion': publishedversion,
}