Per decision: displays need none of the fleet-wide common scope's software, so the gea-shopfloor-display scope carries everything it enforces and does not inherit common. This avoids repackaging common's SMB-backed payloads for a share-less display. - Invert the client common-merge switch: -NoCommon (default-on) becomes -IncludeCommon (default OFF). A scope now enforces alone unless opted in. The capability stays for a future share-less non-display PC; displays omit it. - Drop the common SMB-payload audit + inheritance sections from the display seed comments and docs (GE-ENFORCE-DISPLAY.md); document self-sufficiency. - GE-ENFORCE-CLIENT.md: common-scope inheritance is now opt-in.
265 lines
11 KiB
Python
265 lines
11 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 launches the kiosk browser at the
|
|
# route mapped for that display subtype. The kiosk browser is baked into the
|
|
# display image; this script only points it at the right target.
|
|
#
|
|
# The DisplayTypeTargets table below is the single source of truth for the
|
|
# subtype -> route map. Edit a row to retarget a subtype.
|
|
#
|
|
# TODO-confirm: 3DPrintRoom uses the printedparts /parts-kiosk route as a
|
|
# PLACEHOLDER. Confirm the real 3D-print-room target before production use.
|
|
|
|
$ErrorActionPreference = 'Continue'
|
|
|
|
# --- Data-driven subtype -> kiosk route map ---
|
|
$DisplayTypeTargets = @{{
|
|
{table_body}
|
|
}}
|
|
|
|
# Base URL the kiosk browser opens; the route from the table is appended. Edit
|
|
# to point at this site's shopdb host. Kept here so the map above stays pure.
|
|
$KioskBaseUrl = 'https://localhost'
|
|
|
|
$displayTypeFile = 'C:\\Enrollment\\display-type.txt'
|
|
if (-not (Test-Path -LiteralPath $displayTypeFile)) {{
|
|
Write-Host "display-type.txt not found at $displayTypeFile; nothing to launch."
|
|
return
|
|
}}
|
|
|
|
$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim()
|
|
if ([string]::IsNullOrWhiteSpace($displayType)) {{
|
|
Write-Host 'display-type.txt is empty; nothing to launch.'
|
|
return
|
|
}}
|
|
|
|
# Case-insensitive lookup so 'lobby' and 'Lobby' both resolve.
|
|
$matchedKey = $DisplayTypeTargets.Keys |
|
|
Where-Object {{ $_ -ieq $displayType }} |
|
|
Select-Object -First 1
|
|
if (-not $matchedKey) {{
|
|
Write-Host "Unknown display-type '$displayType'; known types: $($DisplayTypeTargets.Keys -join ', ')."
|
|
return
|
|
}}
|
|
|
|
$targetRoute = $DisplayTypeTargets[$matchedKey]
|
|
$kioskUrl = "$KioskBaseUrl$targetRoute"
|
|
Write-Host "display-type '$displayType' -> kiosk target $kioskUrl"
|
|
|
|
# Idempotent: if an Edge kiosk process is already serving this URL, leave it be
|
|
# so an enforce cycle does not relaunch the kiosk every run.
|
|
$alreadyRunning = Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" -ErrorAction SilentlyContinue |
|
|
Where-Object {{ $_.CommandLine -and $_.CommandLine.Contains($kioskUrl) }}
|
|
if ($alreadyRunning) {{
|
|
Write-Host 'Kiosk already running for this target; leaving it in place.'
|
|
return
|
|
}}
|
|
|
|
$edgeArguments = @("--kiosk", $kioskUrl, "--edge-kiosk-type=fullscreen", "--no-first-run")
|
|
Start-Process -FilePath 'msedge.exe' -ArgumentList $edgeArguments
|
|
Write-Host 'Launched kiosk browser.'
|
|
"""
|
|
|
|
|
|
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 '
|
|
'launches the kiosk target for the subtype. Data-driven map: '
|
|
+ _relaunch_window_targets_comment()
|
|
+ '. 3DPrintRoom target is a PLACEHOLDER (/parts-kiosk); '
|
|
'TODO-confirm the real target. Delivered inline over HTTPS '
|
|
'(share-less displays); DetectionMethod Always so it re-asserts '
|
|
'each cycle, but the script is idempotent (skips if the kiosk is '
|
|
'already serving the target URL).'),
|
|
'Name': 'Display kiosk dispatcher (display-type.txt)',
|
|
'Type': 'PS1',
|
|
'Script': DISPATCHER_FILENAME,
|
|
'PayloadSource': 'inline',
|
|
'PayloadRef': DISPATCHER_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)
|
|
|
|
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,
|
|
'publishedversion': publishedversion,
|
|
}
|