The prior sweep only matched '--kiosk'; the imaging installers (Inno GEAerospaceDashboardSetup / lobby) create Startup shortcuts with single-dash '-kiosk' pointing at /shopdb/shopfloor-dashboard, so they survived. Match any msedge/chrome Startup .lnk whose args contain -kiosk (one or two dashes) OR a shopdb kiosk URL (tsgwp00525 / /shopdb/ / shopfloor-dashboard). Unrelated Startup items are left untouched (VM-verified).
392 lines
17 KiB
Python
392 lines
17 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 {{}}
|
|
|
|
$displayTypeFile = 'C:\\Enrollment\\display-type.txt'
|
|
if (-not (Test-Path -LiteralPath $displayTypeFile)) {{
|
|
Write-Host "display-type.txt not found at $displayTypeFile; nothing to configure."
|
|
return
|
|
}}
|
|
|
|
$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim()
|
|
if ([string]::IsNullOrWhiteSpace($displayType)) {{
|
|
Write-Host 'display-type.txt is empty; nothing to configure.'
|
|
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
|
|
}}
|
|
|
|
$kioskUrl = "$KioskBaseUrl$($DisplayTypeTargets[$matchedKey])"
|
|
|
|
# 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*') {{ $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 {{}}
|
|
}}
|
|
|
|
$edgeArgs = "--kiosk `"$kioskUrl`" --edge-kiosk-type=fullscreen --no-first-run --no-default-browser-check"
|
|
$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,
|
|
}
|