Files
shopdb-flask/plugins/geenforce/seed_display_scope.py
cproudlock e6533d5205 geenforce display dispatcher: purge legacy HKLM Run autostart
The old LobbyDisplay/Dashboard Inno installers planted an HKLM
...\CurrentVersion\Run value (plus a Startup .lnk). The dispatcher already
swept the stale .lnk/.url launchers but never the Run value, so a display
with our new ShopDB Kiosk.lnk still relaunched the old kiosk URL at logon
(the Run key beats the Startup shortcut). Remove the two legacy Run values
and kill any running old-URL Edge so the display self-heals to the resolved
target on the next enforce cycle.
2026-07-30 14:28:44 -04:00

434 lines
20 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.
"""
from shopdb.api import db
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. Edit here to retarget a
# subtype. Keys are matched case-insensitively by the dispatcher.
#
# 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 = {
'Dashboard': '/shopfloor',
'Lobby': '/tv',
'3DPrintRoom': '/parts-kiosk',
}
DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1'
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 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)
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; falls back to the West Jefferson shopdb host.
$KioskBaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'
try {{
$shopdbConfig = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name BaseUrl -ErrorAction Stop
if ($shopdbConfig.BaseUrl) {{ $KioskBaseUrl = ([string]$shopdbConfig.BaseUrl).TrimEnd('/') }}
}} catch {{}}
# 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'
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).
$kioskArgs = ($existing.Arguments -match '(?i)-kiosk') -or `
($existing.Arguments -match '(?i)tsgwp00525|/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 HKLM Run autostarts the OLD Dashboard/Lobby Inno installers planted.
# The .lnk/.url sweep above misses these -- a Run value relaunches the old kiosk
# URL at every logon and BEATS our Startup shortcut (this is why a display with
# our new .lnk still opened the old page). Purge them, then kill any running
# old-URL Edge so the display self-heals to the resolved target this cycle.
$legacyRun = 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run'
foreach ($legacyName in @('GE Aerospace Dashboard','GE Aerospace Lobby Display')) {{
try {{
if ($null -ne (Get-ItemProperty -LiteralPath $legacyRun -Name $legacyName -ErrorAction SilentlyContinue)) {{
Remove-ItemProperty -LiteralPath $legacyRun -Name $legacyName -Force -ErrorAction SilentlyContinue
Write-Host "removed legacy Run value $legacyName"
}}
}} catch {{}}
}}
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 {{}}
$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"
"""
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 _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.
"""
applications = [
_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': (
'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)
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,
'publishedversion': publishedversion,
}