Reported from a real display: Edge closed on the desktop but still listed in Task Manager, and the watchdog never relaunched it. That is this watchdog's own bug. It asked only whether a --kiosk process existed. After an Edge update the window can be gone while the process lingers, so the check said "kiosk is up" and returned - every cycle, forever. Matching on the command line was chosen to stop a stray renderer masking a dead kiosk; it does not help when the orphan is the parent. A kiosk now counts as up only when a --kiosk process still owns a visible window (MainWindowHandle). Windowless ones are killed BEFORE relaunching: leaving them would satisfy the next cycle's check again, and a second browser would fight the first for the display. Also refuses to run as SYSTEM. MainWindowHandle is session-scoped, so a SYSTEM caller reads 0 for a perfectly healthy kiosk and would kill and relaunch it on every cycle. The task uses an interactive Users principal so this cannot normally happen; the guard makes a mis-registered task fail loudly instead of thrashing a display in a hallway. Verified on Windows this time by letting the SCHEDULED TASK do the work rather than invoking the script by hand - which is what the first version was missing: - task fired unattended (rc=0) and launched the kiosk into session 1 - the next cycle saw the healthy kiosk and did nothing, no relaunch loop - a windowless --kiosk process was killed and replaced - run as SYSTEM, it refused and the healthy kiosk survived
752 lines
34 KiB
Python
752 lines
34 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 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 _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': (
|
|
'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': (
|
|
'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)
|
|
|
|
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,
|
|
'publishedversion': publishedversion,
|
|
}
|