lightman-agent 1.0.23 → 1.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightman-agent",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "LIGHTMAN Agent - System-level daemon for museum display machines",
5
5
  "private": false,
6
6
  "type": "module",
@@ -529,15 +529,25 @@ Set-ItemProperty -Path $SP -Name "InactivityTimeoutSecs" -Value 0 -ErrorAction S
529
529
  try { Disable-ScheduledTask -TaskName "\Microsoft\Windows\Shell\CreateObjectTask" -ErrorAction SilentlyContinue | Out-Null } catch { }
530
530
  Write-Host " Lock screen fully disabled"
531
531
 
532
- # --- 15. Sleep ---
533
- Write-Host "[15/19] Disabling sleep..." -ForegroundColor Yellow
534
- powercfg /change monitor-timeout-ac 0 2>&1 | Out-Null
535
- powercfg /change standby-timeout-ac 0 2>&1 | Out-Null
536
- powercfg /change hibernate-timeout-ac 0 2>&1 | Out-Null
537
-
538
- # --- 16. Harden ---
539
- Write-Host "[16/19] Hardening Windows..." -ForegroundColor Yellow
540
- $WU = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU"
532
+ # --- 15. Sleep ---
533
+ Write-Host "[15/19] Disabling sleep..." -ForegroundColor Yellow
534
+ powercfg /change monitor-timeout-ac 0 2>&1 | Out-Null
535
+ powercfg /change standby-timeout-ac 0 2>&1 | Out-Null
536
+ powercfg /change hibernate-timeout-ac 0 2>&1 | Out-Null
537
+
538
+ # --- 15b. Display topology ---
539
+ Write-Host "[15b/19] Enforcing display extend mode..." -ForegroundColor Yellow
540
+ if (Test-Path "$env:SystemRoot\System32\DisplaySwitch.exe") {
541
+ & "$env:SystemRoot\System32\DisplaySwitch.exe" /extend 2>&1 | Out-Null
542
+ Start-Sleep -Seconds 2
543
+ Write-Host " Display mode set to Extend"
544
+ } else {
545
+ Write-Host " DisplaySwitch.exe not found - skipped" -ForegroundColor DarkYellow
546
+ }
547
+
548
+ # --- 16. Harden ---
549
+ Write-Host "[16/19] Hardening Windows..." -ForegroundColor Yellow
550
+ $WU = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU"
541
551
  if (-not (Test-Path $WU)) { New-Item -Path $WU -Force | Out-Null }
542
552
  Set-ItemProperty -Path $WU -Name "NoAutoRebootWithLoggedOnUsers" -Value 1
543
553
  Set-ItemProperty -Path $WU -Name "AUOptions" -Value 2
@@ -0,0 +1,185 @@
1
+ # LIGHTMAN shell multi-screen launcher
2
+ # Runs in the interactive shell session and launches one browser window per display.
3
+
4
+ param(
5
+ [Parameter(Mandatory = $true)][string]$BrowserPath,
6
+ [Parameter(Mandatory = $true)][string]$MultiConfigPath,
7
+ [Parameter(Mandatory = $true)][string]$FallbackUrl,
8
+ [string]$LogFile = "C:\ProgramData\Lightman\logs\shell.log"
9
+ )
10
+
11
+ $ErrorActionPreference = "Stop"
12
+
13
+ function Write-Log {
14
+ param([string]$Message)
15
+ try {
16
+ Add-Content -Path $LogFile -Value "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')] $Message"
17
+ } catch {
18
+ # Logging should never block kiosk startup.
19
+ }
20
+ }
21
+
22
+ function Resolve-DisplayNumber {
23
+ param([string]$HardwareId)
24
+ if ([string]::IsNullOrWhiteSpace($HardwareId)) { return $null }
25
+ $trimmed = $HardwareId.Trim()
26
+ if ($trimmed -match '^\d+$') { return [int]$trimmed }
27
+ if ($trimmed -match 'DISPLAY(\d+)$') { return [int]$matches[1] }
28
+ return $null
29
+ }
30
+
31
+ function Pick-Screen {
32
+ param(
33
+ [array]$Screens,
34
+ [string]$HardwareId,
35
+ [int]$ScreenIndex,
36
+ [System.Collections.Generic.HashSet[string]]$Used
37
+ )
38
+
39
+ $displayNo = Resolve-DisplayNumber -HardwareId $HardwareId
40
+ if ($displayNo) {
41
+ $suffix = "DISPLAY$displayNo"
42
+ foreach ($s in $Screens) {
43
+ if ($Used.Contains($s.DeviceName)) { continue }
44
+ if ($s.DeviceName.ToUpperInvariant().EndsWith($suffix)) { return $s }
45
+ }
46
+ }
47
+
48
+ if (-not [string]::IsNullOrWhiteSpace($HardwareId)) {
49
+ foreach ($s in $Screens) {
50
+ if ($Used.Contains($s.DeviceName)) { continue }
51
+ if ($s.DeviceName.Equals($HardwareId, [System.StringComparison]::OrdinalIgnoreCase)) { return $s }
52
+ }
53
+ }
54
+
55
+ if ($ScreenIndex -ge 0 -and $ScreenIndex -lt $Screens.Count) {
56
+ $preferred = $Screens[$ScreenIndex]
57
+ if ($preferred -and -not $Used.Contains($preferred.DeviceName)) { return $preferred }
58
+ }
59
+
60
+ foreach ($s in $Screens) {
61
+ if (-not $Used.Contains($s.DeviceName)) { return $s }
62
+ }
63
+
64
+ return $null
65
+ }
66
+
67
+ if (-not (Test-Path $BrowserPath)) {
68
+ Write-Log "ERROR: Browser path not found: $BrowserPath"
69
+ exit 1
70
+ }
71
+
72
+ Add-Type -AssemblyName System.Windows.Forms
73
+ $screens = [System.Windows.Forms.Screen]::AllScreens | Sort-Object { $_.DeviceName }
74
+ Write-Log "Detected $($screens.Count) display(s) in shell session"
75
+
76
+ if (-not (Test-Path $MultiConfigPath)) {
77
+ Write-Log "Multi config not found. Launching fallback URL."
78
+ $fallback = Start-Process -FilePath $BrowserPath -ArgumentList @(
79
+ "--kiosk",
80
+ "--noerrdialogs",
81
+ "--disable-infobars",
82
+ "--disable-session-crashed-bubble",
83
+ "--no-first-run",
84
+ "--no-default-browser-check",
85
+ "--autoplay-policy=no-user-gesture-required",
86
+ "--disable-features=TranslateUI",
87
+ "--user-data-dir=C:\ProgramData\Lightman\chrome-kiosk",
88
+ $FallbackUrl
89
+ ) -PassThru
90
+ if ($fallback) { Wait-Process -Id $fallback.Id -ErrorAction SilentlyContinue }
91
+ exit 0
92
+ }
93
+
94
+ $json = Get-Content -Raw $MultiConfigPath | ConvertFrom-Json
95
+ $entries = @($json.entries)
96
+ if ($entries.Count -le 1) {
97
+ Write-Log "Multi config has $($entries.Count) entry. Launching fallback URL."
98
+ $fallback = Start-Process -FilePath $BrowserPath -ArgumentList @(
99
+ "--kiosk",
100
+ "--noerrdialogs",
101
+ "--disable-infobars",
102
+ "--disable-session-crashed-bubble",
103
+ "--no-first-run",
104
+ "--no-default-browser-check",
105
+ "--autoplay-policy=no-user-gesture-required",
106
+ "--disable-features=TranslateUI",
107
+ "--user-data-dir=C:\ProgramData\Lightman\chrome-kiosk",
108
+ $FallbackUrl
109
+ ) -PassThru
110
+ if ($fallback) { Wait-Process -Id $fallback.Id -ErrorAction SilentlyContinue }
111
+ exit 0
112
+ }
113
+
114
+ $usedScreens = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
115
+ $processes = @()
116
+
117
+ for ($i = 0; $i -lt $entries.Count; $i++) {
118
+ $entry = $entries[$i]
119
+ $entryIndex = 0
120
+ try { $entryIndex = [int]$entry.screenIndex } catch { $entryIndex = $i }
121
+ $entryHardwareId = [string]$entry.hardwareId
122
+ $entryUrl = [string]$entry.url
123
+ if ([string]::IsNullOrWhiteSpace($entryUrl)) { $entryUrl = $FallbackUrl }
124
+
125
+ $screen = Pick-Screen -Screens $screens -HardwareId $entryHardwareId -ScreenIndex $entryIndex -Used $usedScreens
126
+ if (-not $screen) {
127
+ Write-Log "WARN: No available screen for mapping index $i (hardwareId='$entryHardwareId')."
128
+ continue
129
+ }
130
+
131
+ $null = $usedScreens.Add($screen.DeviceName)
132
+
133
+ $screenSlot = [Array]::IndexOf($screens, $screen)
134
+ if ($screenSlot -lt 0) { $screenSlot = $entryIndex }
135
+ $userDataDir = "C:\ProgramData\Lightman\chrome-kiosk-screen-$screenSlot"
136
+ New-Item -ItemType Directory -Path $userDataDir -Force | Out-Null
137
+
138
+ $x = [int]$screen.Bounds.X
139
+ $y = [int]$screen.Bounds.Y
140
+ $w = [int]$screen.Bounds.Width
141
+ $h = [int]$screen.Bounds.Height
142
+
143
+ Write-Log "Launching $($screen.DeviceName) at ${w}x${h}@${x},${y} -> $entryUrl"
144
+
145
+ $proc = Start-Process -FilePath $BrowserPath -ArgumentList @(
146
+ "--kiosk",
147
+ "--noerrdialogs",
148
+ "--disable-infobars",
149
+ "--disable-session-crashed-bubble",
150
+ "--no-first-run",
151
+ "--no-default-browser-check",
152
+ "--autoplay-policy=no-user-gesture-required",
153
+ "--disable-features=TranslateUI",
154
+ "--window-position=$x,$y",
155
+ "--window-size=$w,$h",
156
+ "--user-data-dir=$userDataDir",
157
+ $entryUrl
158
+ ) -PassThru
159
+
160
+ if ($proc) { $processes += $proc }
161
+ Start-Sleep -Milliseconds 300
162
+ }
163
+
164
+ if ($processes.Count -eq 0) {
165
+ Write-Log "WARN: Multi launch created no browser process. Starting fallback."
166
+ $fallback = Start-Process -FilePath $BrowserPath -ArgumentList @(
167
+ "--kiosk",
168
+ "--noerrdialogs",
169
+ "--disable-infobars",
170
+ "--disable-session-crashed-bubble",
171
+ "--no-first-run",
172
+ "--no-default-browser-check",
173
+ "--autoplay-policy=no-user-gesture-required",
174
+ "--disable-features=TranslateUI",
175
+ "--user-data-dir=C:\ProgramData\Lightman\chrome-kiosk",
176
+ $FallbackUrl
177
+ ) -PassThru
178
+ if ($fallback) { Wait-Process -Id $fallback.Id -ErrorAction SilentlyContinue }
179
+ exit 0
180
+ }
181
+
182
+ $pids = $processes | ForEach-Object { $_.Id }
183
+ Write-Log "Waiting on multi-screen browser processes: $($pids -join ', ')"
184
+ Wait-Process -Id $pids -ErrorAction SilentlyContinue
185
+ Write-Log "Multi-screen browser session ended"
@@ -13,11 +13,13 @@ REM 5. Launches Chrome fullscreen
13
13
  REM 6. If Chrome crashes, relaunches in 3 seconds (infinite loop)
14
14
  REM ================================================================
15
15
 
16
- set INSTALL_DIR=C:\Program Files\Lightman\Agent
17
- set CONFIG_FILE=%INSTALL_DIR%\agent.config.json
18
- set URL_SIDECAR=C:\ProgramData\Lightman\kiosk-url.txt
19
- set CHROME_DATA=C:\ProgramData\Lightman\chrome-kiosk
20
- set LOG_FILE=C:\ProgramData\Lightman\logs\shell.log
16
+ set INSTALL_DIR=C:\Program Files\Lightman\Agent
17
+ set CONFIG_FILE=%INSTALL_DIR%\agent.config.json
18
+ set URL_SIDECAR=C:\ProgramData\Lightman\kiosk-url.txt
19
+ set MULTI_SIDECAR=C:\ProgramData\Lightman\kiosk-multi.json
20
+ set MULTI_LAUNCHER=%INSTALL_DIR%\scripts\launch-multi-kiosk.ps1
21
+ set CHROME_DATA=C:\ProgramData\Lightman\chrome-kiosk
22
+ set LOG_FILE=C:\ProgramData\Lightman\logs\shell.log
21
23
 
22
24
  REM Ensure directories exist
23
25
  if not exist "C:\ProgramData\Lightman\logs" mkdir "C:\ProgramData\Lightman\logs"
@@ -116,8 +118,14 @@ set MAX_WAIT=60
116
118
  timeout /t 1 /nobreak >nul
117
119
  goto wait_for_agent
118
120
 
119
- :agent_ready
120
- echo [%date% %time%] Agent ready >> "%LOG_FILE%"
121
+ :agent_ready
122
+ echo [%date% %time%] Agent ready >> "%LOG_FILE%"
123
+
124
+ REM Force Windows into Extend mode so all connected displays are usable.
125
+ if exist "%SystemRoot%\System32\DisplaySwitch.exe" (
126
+ "%SystemRoot%\System32\DisplaySwitch.exe" /extend >nul 2>&1
127
+ timeout /t 2 /nobreak >nul
128
+ )
121
129
 
122
130
  REM ----------------------------------------------------------------
123
131
  REM Infinite Chrome loop
@@ -139,9 +147,18 @@ REM ----------------------------------------------------------------
139
147
  )
140
148
  )
141
149
 
142
- echo [%date% %time%] Launching browser: %URL% >> "%LOG_FILE%"
143
-
144
- start /wait "" "%BROWSER%" --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble --no-first-run --no-default-browser-check --start-fullscreen --disable-translate --disable-extensions --autoplay-policy=no-user-gesture-required --disable-features=TranslateUI --user-data-dir="%CHROME_DATA%" "%URL%"
150
+ set MULTI_COUNT=0
151
+ if exist "%MULTI_SIDECAR%" (
152
+ for /f "delims=" %%c in ('node -e "try{const fs=require('fs');const p=String.raw`%MULTI_SIDECAR%`;const j=JSON.parse(fs.readFileSync(p,'utf8'));const n=Array.isArray(j&&j.entries)?j.entries.length:0;console.log(n)}catch(e){console.log(0)}" 2^>nul') do set MULTI_COUNT=%%c
153
+ )
154
+
155
+ if %MULTI_COUNT% gtr 1 if exist "%MULTI_LAUNCHER%" (
156
+ echo [%date% %time%] Launching multi-screen shell session (%MULTI_COUNT% screens) >> "%LOG_FILE%"
157
+ powershell -ExecutionPolicy Bypass -NoProfile -File "%MULTI_LAUNCHER%" -BrowserPath "%BROWSER%" -MultiConfigPath "%MULTI_SIDECAR%" -FallbackUrl "%URL%" -LogFile "%LOG_FILE%"
158
+ ) else (
159
+ echo [%date% %time%] Launching browser: %URL% >> "%LOG_FILE%"
160
+ start /wait "" "%BROWSER%" --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble --no-first-run --no-default-browser-check --start-fullscreen --disable-translate --disable-extensions --autoplay-policy=no-user-gesture-required --disable-features=TranslateUI --user-data-dir="%CHROME_DATA%" "%URL%"
161
+ )
145
162
 
146
163
  echo [%date% %time%] Browser exited (code: %errorlevel%). Restarting in 3s... >> "%LOG_FILE%"
147
164
  timeout /t 3 /nobreak >nul
package/src/index.ts CHANGED
@@ -111,10 +111,26 @@ async function main(): Promise<void> {
111
111
  serverUrl: config.serverUrl,
112
112
  identity,
113
113
  logger,
114
- onMessage: (msg: WsMessage) => {
115
- handleServerMessage(msg, commandExecutor, logger, powerScheduler, startSerialBridge, stopSerialBridge, startOscBridge, stopOscBridge, multiScreenKiosk, getIdentity, kioskManager, watchdog, startPresenceSensor, stopPresenceSensor);
116
- },
117
- });
114
+ onMessage: (msg: WsMessage) => {
115
+ handleServerMessage(
116
+ msg,
117
+ commandExecutor,
118
+ logger,
119
+ powerScheduler,
120
+ startSerialBridge,
121
+ stopSerialBridge,
122
+ startOscBridge,
123
+ stopOscBridge,
124
+ multiScreenKiosk,
125
+ getIdentity,
126
+ kioskManager,
127
+ watchdog,
128
+ startPresenceSensor,
129
+ stopPresenceSensor,
130
+ () => lastKnownTotalScreens
131
+ );
132
+ },
133
+ });
118
134
 
119
135
  // 4. Start health monitor
120
136
  const healthMonitor = new HealthMonitor(
@@ -163,14 +179,15 @@ async function main(): Promise<void> {
163
179
  const kioskManager = new KioskManager(kioskConfig, logger);
164
180
  registerKioskCommands(commandExecutor.register.bind(commandExecutor), kioskManager, logger);
165
181
 
166
- // Multi-screen kiosk manager — handles multiple Chrome instances on multi-display devices
167
- const multiScreenKiosk = new MultiScreenKioskManager(kioskConfig, logger);
168
- const getIdentity = () => identity!;
169
- registerMultiScreenKioskCommands(commandExecutor.register.bind(commandExecutor), multiScreenKiosk, getIdentity, logger);
170
-
171
- // Detect physical screens and keep them fresh (multi-display setups can change after boot).
172
- let detectedScreens = detectScreens(logger);
173
- multiScreenKiosk.setDetectedScreens(detectedScreens);
182
+ // Multi-screen kiosk manager — handles multiple Chrome instances on multi-display devices
183
+ const multiScreenKiosk = new MultiScreenKioskManager(kioskConfig, logger);
184
+ const getIdentity = () => identity!;
185
+ registerMultiScreenKioskCommands(commandExecutor.register.bind(commandExecutor), multiScreenKiosk, getIdentity, logger);
186
+
187
+ // Detect physical screens and keep them fresh (multi-display setups can change after boot).
188
+ let lastKnownTotalScreens = 0;
189
+ let detectedScreens = detectScreens(logger);
190
+ multiScreenKiosk.setDetectedScreens(detectedScreens);
174
191
 
175
192
  const toScreenPayload = (screens: DetectedScreen[]) => (
176
193
  screens.map((s) => ({
@@ -502,24 +519,30 @@ async function main(): Promise<void> {
502
519
  // Multi-screen handling:
503
520
  // 1) Use explicit screenMap when present.
504
521
  // 2) For multi-screen apps without a saved map, auto-create placeholders.
505
- const requestedScreenMap = deviceCfg?.screenMap || [];
506
- const isMultiScreenApp = !!deviceCfg && deviceCfg.totalScreens > 1;
507
- const effectiveRequestedMap = requestedScreenMap.length > 0
508
- ? requestedScreenMap
509
- : (isMultiScreenApp ? createPlaceholderScreenMap(deviceCfg.totalScreens) : []);
510
-
511
- if (effectiveRequestedMap.length > 0) {
512
- const resolved = resolveScreenMap({
513
- requestedScreenMap: effectiveRequestedMap,
514
- detectedScreens,
515
- totalScreens: Math.max(deviceCfg?.totalScreens || 0, effectiveRequestedMap.length),
516
- });
517
- logger.info(
518
- `[MultiKiosk] Effective map ready: requested=${effectiveRequestedMap.length}, mode=${resolved.mode}, totalScreens=${deviceCfg?.totalScreens || 0}`
519
- );
520
- watchdog.setMultiScreenActive(true);
521
- multiScreenKiosk.applyScreenMap(effectiveRequestedMap, identity).catch((err) => {
522
- logger.error('[MultiKiosk] Failed to apply effective screenMap from config:', err);
522
+ const requestedScreenMap = deviceCfg?.screenMap || [];
523
+ const totalScreens = Math.max(deviceCfg?.totalScreens || 0, requestedScreenMap.length);
524
+ lastKnownTotalScreens = totalScreens;
525
+ const effectiveRequestedMap = normalizeScreenMapForTotalScreens(requestedScreenMap, totalScreens);
526
+
527
+ if (totalScreens > 1 && detectedScreens.length < totalScreens) {
528
+ logger.warn(
529
+ `[MultiKiosk] App expects ${totalScreens} screen(s) but agent detected ${detectedScreens.length}. ` +
530
+ 'Remaining screens may stay black until Windows reports all displays.'
531
+ );
532
+ }
533
+
534
+ if (effectiveRequestedMap.length > 0) {
535
+ const resolved = resolveScreenMap({
536
+ requestedScreenMap: effectiveRequestedMap,
537
+ detectedScreens,
538
+ totalScreens,
539
+ });
540
+ logger.info(
541
+ `[MultiKiosk] Effective map ready: requested=${requestedScreenMap.length}, effective=${effectiveRequestedMap.length}, mode=${resolved.mode}, totalScreens=${totalScreens}`
542
+ );
543
+ watchdog.setMultiScreenActive(true);
544
+ multiScreenKiosk.applyScreenMap(effectiveRequestedMap, identity).catch((err) => {
545
+ logger.error('[MultiKiosk] Failed to apply effective screenMap from config:', err);
523
546
  });
524
547
  return;
525
548
  }
@@ -606,10 +629,33 @@ async function main(): Promise<void> {
606
629
  logger.info('LIGHTMAN Agent running.');
607
630
  }
608
631
 
609
- function createPlaceholderScreenMap(totalScreens: number): ScreenMapping[] {
610
- const count = Math.max(0, Math.floor(totalScreens || 0));
611
- return Array.from({ length: count }, () => ({ hardwareId: '', url: '' }));
612
- }
632
+ function createPlaceholderScreenMap(totalScreens: number): ScreenMapping[] {
633
+ const count = Math.max(0, Math.floor(totalScreens || 0));
634
+ return Array.from({ length: count }, () => ({ hardwareId: '', url: '' }));
635
+ }
636
+
637
+ function normalizeScreenMapForTotalScreens(
638
+ screenMap: ScreenMapping[] | undefined,
639
+ totalScreens: number
640
+ ): ScreenMapping[] {
641
+ const requested = Array.isArray(screenMap)
642
+ ? screenMap.map((m) => ({
643
+ hardwareId: String(m.hardwareId || ''),
644
+ url: String(m.url || ''),
645
+ ...(m.label ? { label: String(m.label) } : {}),
646
+ }))
647
+ : [];
648
+
649
+ const targetCount = Math.max(requested.length, Math.max(0, Math.floor(totalScreens || 0)));
650
+ if (targetCount === 0) return [];
651
+
652
+ if (requested.length >= targetCount) return requested;
653
+
654
+ return [
655
+ ...requested,
656
+ ...createPlaceholderScreenMap(targetCount - requested.length),
657
+ ];
658
+ }
613
659
 
614
660
  function haveScreensChanged(prev: DetectedScreen[], next: DetectedScreen[]): boolean {
615
661
  if (prev.length !== next.length) return true;
@@ -631,22 +677,23 @@ function haveScreensChanged(prev: DetectedScreen[], next: DetectedScreen[]): boo
631
677
  return false;
632
678
  }
633
679
 
634
- function handleServerMessage(
635
- msg: WsMessage,
636
- commandExecutor: CommandExecutor,
680
+ function handleServerMessage(
681
+ msg: WsMessage,
682
+ commandExecutor: CommandExecutor,
637
683
  logger: Logger,
638
684
  powerScheduler?: PowerScheduler,
639
685
  startSerialBridge?: (comPort: string, controllerId: string, baudRate?: number) => void,
640
686
  stopSerialBridge?: () => void,
641
687
  startOscBridgeFn?: (oscPort: number, oscAddress: string, oscHost?: string) => void,
642
688
  stopOscBridgeFn?: () => void,
643
- multiScreenKiosk?: MultiScreenKioskManager,
644
- getIdentity?: () => Identity,
645
- kioskManager?: KioskManager,
646
- watchdog?: Watchdog,
647
- startPresenceSensorFn?: (port?: string) => void,
648
- stopPresenceSensorFn?: () => void
649
- ): void {
689
+ multiScreenKiosk?: MultiScreenKioskManager,
690
+ getIdentity?: () => Identity,
691
+ kioskManager?: KioskManager,
692
+ watchdog?: Watchdog,
693
+ startPresenceSensorFn?: (port?: string) => void,
694
+ stopPresenceSensorFn?: () => void,
695
+ getTotalScreensHint?: () => number
696
+ ): void {
650
697
  switch (msg.type) {
651
698
  case 'connected':
652
699
  logger.info('Server acknowledged connection');
@@ -698,16 +745,25 @@ function handleServerMessage(
698
745
  }
699
746
 
700
747
  // Admin pushed updated screenMap via device config save
701
- const screenMap = msg.payload.screenMap as ScreenMapping[] | undefined;
702
- if (screenMap && Array.isArray(screenMap) && multiScreenKiosk && getIdentity) {
703
- if (screenMap.length > 0) {
704
- logger.info(`[MultiKiosk] Received screenMap update: ${screenMap.length} mapping(s) — killing single kiosk`);
705
- if (kioskManager) kioskManager.kill().catch(() => {});
706
- if (watchdog) watchdog.setMultiScreenActive(true);
707
- multiScreenKiosk.applyScreenMap(screenMap, getIdentity()).catch((err) => {
708
- logger.error('[MultiKiosk] Failed to apply screenMap:', err);
709
- });
710
- } else {
748
+ const screenMap = msg.payload.screenMap as ScreenMapping[] | undefined;
749
+ if (screenMap && Array.isArray(screenMap) && multiScreenKiosk && getIdentity) {
750
+ const payloadTotalScreens = Number(msg.payload.totalScreens || 0);
751
+ const hintTotalScreens = Math.max(
752
+ Number.isFinite(payloadTotalScreens) ? payloadTotalScreens : 0,
753
+ getTotalScreensHint ? getTotalScreensHint() : 0
754
+ );
755
+ const effectiveScreenMap = screenMap.length > 0
756
+ ? normalizeScreenMapForTotalScreens(screenMap, hintTotalScreens)
757
+ : screenMap;
758
+
759
+ if (screenMap.length > 0) {
760
+ logger.info(`[MultiKiosk] Received screenMap update: requested=${screenMap.length}, effective=${effectiveScreenMap.length}, totalScreens=${hintTotalScreens} — killing single kiosk`);
761
+ if (kioskManager) kioskManager.kill().catch(() => {});
762
+ if (watchdog) watchdog.setMultiScreenActive(true);
763
+ multiScreenKiosk.applyScreenMap(effectiveScreenMap, getIdentity()).catch((err) => {
764
+ logger.error('[MultiKiosk] Failed to apply screenMap:', err);
765
+ });
766
+ } else {
711
767
  // Empty screenMap — deactivate multi-screen, resume single kiosk
712
768
  logger.info('[MultiKiosk] Empty screenMap received — deactivating multi-screen');
713
769
  multiScreenKiosk.killAll().catch(() => {});
@@ -719,16 +775,25 @@ function handleServerMessage(
719
775
  case 'agent:screenMap':
720
776
  // Direct screenMap push from server
721
777
  if (msg.payload && multiScreenKiosk && getIdentity) {
722
- const screenMap = msg.payload.screenMap as ScreenMapping[] | undefined;
723
- if (screenMap && Array.isArray(screenMap)) {
724
- if (screenMap.length > 0) {
725
- logger.info(`[MultiKiosk] Received agent:screenMap: ${screenMap.length} mapping(s) — killing single kiosk`);
726
- if (kioskManager) kioskManager.kill().catch(() => {});
727
- if (watchdog) watchdog.setMultiScreenActive(true);
728
- multiScreenKiosk.applyScreenMap(screenMap, getIdentity()).catch((err) => {
729
- logger.error('[MultiKiosk] Failed to apply screenMap:', err);
730
- });
731
- } else {
778
+ const screenMap = msg.payload.screenMap as ScreenMapping[] | undefined;
779
+ if (screenMap && Array.isArray(screenMap)) {
780
+ const payloadTotalScreens = Number(msg.payload.totalScreens || 0);
781
+ const hintTotalScreens = Math.max(
782
+ Number.isFinite(payloadTotalScreens) ? payloadTotalScreens : 0,
783
+ getTotalScreensHint ? getTotalScreensHint() : 0
784
+ );
785
+ const effectiveScreenMap = screenMap.length > 0
786
+ ? normalizeScreenMapForTotalScreens(screenMap, hintTotalScreens)
787
+ : screenMap;
788
+
789
+ if (screenMap.length > 0) {
790
+ logger.info(`[MultiKiosk] Received agent:screenMap: requested=${screenMap.length}, effective=${effectiveScreenMap.length}, totalScreens=${hintTotalScreens} — killing single kiosk`);
791
+ if (kioskManager) kioskManager.kill().catch(() => {});
792
+ if (watchdog) watchdog.setMultiScreenActive(true);
793
+ multiScreenKiosk.applyScreenMap(effectiveScreenMap, getIdentity()).catch((err) => {
794
+ logger.error('[MultiKiosk] Failed to apply screenMap:', err);
795
+ });
796
+ } else {
732
797
  logger.info('[MultiKiosk] Empty agent:screenMap — deactivating multi-screen');
733
798
  multiScreenKiosk.killAll().catch(() => {});
734
799
  if (watchdog) watchdog.setMultiScreenActive(false);
@@ -1,11 +1,17 @@
1
- import { spawn } from 'child_process';
2
- import type { ChildProcess } from 'child_process';
3
- import type { KioskConfig, ScreenMapping, MultiScreenKioskStatus, SingleScreenStatus } from '../lib/types.js';
4
- import type { DetectedScreen } from '../lib/screens.js';
5
- import type { Logger } from '../lib/logger.js';
6
- import { resolveDetectedScreen, resolveScreenMap } from '../lib/screenMap.js';
7
-
8
- interface ScreenInstance {
1
+ import { spawn } from 'child_process';
2
+ import type { ChildProcess } from 'child_process';
3
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
4
+ import { basename, dirname } from 'path';
5
+ import type { KioskConfig, ScreenMapping, MultiScreenKioskStatus, SingleScreenStatus } from '../lib/types.js';
6
+ import type { DetectedScreen } from '../lib/screens.js';
7
+ import type { Logger } from '../lib/logger.js';
8
+ import { resolveDetectedScreen, resolveScreenMap } from '../lib/screenMap.js';
9
+
10
+ const SHELL_MULTI_CONFIG_PATH = process.platform === 'win32'
11
+ ? 'C:\\ProgramData\\Lightman\\kiosk-multi.json'
12
+ : '/tmp/lightman-kiosk-multi.json';
13
+
14
+ interface ScreenInstance {
9
15
  hardwareId: string;
10
16
  mappingId: string;
11
17
  mappingUrl: string;
@@ -30,10 +36,14 @@ export class MultiScreenKioskManager {
30
36
  private pollTimer: NodeJS.Timeout | null = null;
31
37
  private applying = false;
32
38
 
33
- constructor(config: KioskConfig, logger: Logger) {
34
- this.config = config;
35
- this.logger = logger;
36
- }
39
+ constructor(config: KioskConfig, logger: Logger) {
40
+ this.config = config;
41
+ this.logger = logger;
42
+ }
43
+
44
+ private isShellModeOnWindows(): boolean {
45
+ return process.platform === 'win32' && !!this.config.shellMode;
46
+ }
37
47
 
38
48
  /** Update the list of detected physical screens */
39
49
  setDetectedScreens(screens: DetectedScreen[]): void {
@@ -63,12 +73,18 @@ export class MultiScreenKioskManager {
63
73
  }
64
74
  }
65
75
 
66
- private async _applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
67
- const resolvedMap = resolveScreenMap({
68
- requestedScreenMap: screenMap,
69
- detectedScreens: this.detectedScreens,
70
- totalScreens: screenMap.length,
71
- });
76
+ private async _applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
77
+ if (this.isShellModeOnWindows()) {
78
+ this.applyShellMultiConfig(screenMap, identity);
79
+ this.stopPoll();
80
+ return this.getStatus();
81
+ }
82
+
83
+ const resolvedMap = resolveScreenMap({
84
+ requestedScreenMap: screenMap,
85
+ detectedScreens: this.detectedScreens,
86
+ totalScreens: screenMap.length,
87
+ });
72
88
 
73
89
  this.logger.info(
74
90
  `[MultiKiosk] Applying screen map: ${resolvedMap.screenMap.length} mapping(s), mode=${resolvedMap.mode}`
@@ -204,7 +220,7 @@ export class MultiScreenKioskManager {
204
220
  }
205
221
 
206
222
  /** Build the full URL with device credentials and screenIndex */
207
- private buildUrl(path: string, identity: { deviceId: string; apiKey: string }, screenIndex?: number): string {
223
+ private buildUrl(path: string, identity: { deviceId: string; apiKey: string }, screenIndex?: number): string {
208
224
  let fullUrl: string;
209
225
  if (path.startsWith('http://') || path.startsWith('https://')) {
210
226
  fullUrl = path;
@@ -220,16 +236,95 @@ export class MultiScreenKioskManager {
220
236
  url.searchParams.set('screenIndex', String(screenIndex));
221
237
  }
222
238
 
223
- return url.toString();
224
- }
225
-
226
- /** Navigate a single screen to a new URL */
227
- async navigateScreen(hardwareId: string, url: string, identity: { deviceId: string; apiKey: string }): Promise<void> {
228
- const resolvedHardwareId = resolveDetectedScreen(hardwareId, this.detectedScreens)?.hardwareId || hardwareId;
229
- const existing = this.instances.get(resolvedHardwareId);
230
- if (!existing) {
231
- this.logger.warn(`[MultiKiosk] Cannot navigate ${hardwareId} - not in instance map`);
232
- return;
239
+ return url.toString();
240
+ }
241
+
242
+ private applyShellMultiConfig(
243
+ screenMap: ScreenMapping[],
244
+ identity: { deviceId: string; apiKey: string }
245
+ ): void {
246
+ const entries = screenMap.map((mapping, index) => {
247
+ const resolvedHardwareId = String(mapping.hardwareId || '').trim() || String(index + 1);
248
+ const basePath = mapping.url || this.config.defaultUrl;
249
+ return {
250
+ hardwareId: resolvedHardwareId,
251
+ screenIndex: index,
252
+ url: this.buildUrl(basePath, identity, index),
253
+ };
254
+ });
255
+
256
+ try {
257
+ const dir = dirname(SHELL_MULTI_CONFIG_PATH);
258
+ if (!existsSync(dir)) {
259
+ mkdirSync(dir, { recursive: true });
260
+ }
261
+
262
+ writeFileSync(
263
+ SHELL_MULTI_CONFIG_PATH,
264
+ JSON.stringify({ updatedAt: Date.now(), entries }, null, 2),
265
+ 'utf-8'
266
+ );
267
+ this.logger.info(`[MultiKiosk] Shell multi config written (${entries.length} screen(s))`);
268
+ } catch (err) {
269
+ this.logger.error('[MultiKiosk] Failed to write shell multi config:', err);
270
+ return;
271
+ }
272
+
273
+ this.killShellManagedBrowsers();
274
+ }
275
+
276
+ private clearShellMultiConfig(): void {
277
+ try {
278
+ if (existsSync(SHELL_MULTI_CONFIG_PATH)) {
279
+ rmSync(SHELL_MULTI_CONFIG_PATH, { force: true });
280
+ }
281
+ } catch (err) {
282
+ this.logger.warn('[MultiKiosk] Failed to clear shell multi config:', err);
283
+ }
284
+ }
285
+
286
+ private killShellManagedBrowsers(): void {
287
+ if (process.platform !== 'win32') return;
288
+
289
+ const browserExe = basename(this.config.browserPath || '').toLowerCase();
290
+ const targets = new Set<string>(['chrome.exe', 'msedge.exe']);
291
+ if (browserExe.endsWith('.exe')) {
292
+ targets.add(browserExe);
293
+ }
294
+
295
+ for (const exeName of targets) {
296
+ try {
297
+ const killer = spawn('taskkill', ['/IM', exeName, '/F'], {
298
+ stdio: 'ignore',
299
+ windowsHide: true,
300
+ });
301
+ killer.unref();
302
+ } catch {
303
+ // Ignore process-kill failures; shell loop can still recover.
304
+ }
305
+ }
306
+ }
307
+
308
+ /** Navigate a single screen to a new URL */
309
+ async navigateScreen(hardwareId: string, url: string, identity: { deviceId: string; apiKey: string }): Promise<void> {
310
+ if (this.isShellModeOnWindows()) {
311
+ const idx = this.desiredScreenMap.findIndex((m) => String(m.hardwareId || '').trim() === String(hardwareId || '').trim());
312
+ if (idx === -1) {
313
+ this.logger.warn(`[MultiKiosk] Cannot navigate ${hardwareId} - not in desired map`);
314
+ return;
315
+ }
316
+
317
+ const nextMap = this.desiredScreenMap.map((m) => ({ ...m }));
318
+ nextMap[idx] = { ...nextMap[idx], url };
319
+ await this.applyScreenMap(nextMap, identity);
320
+ return;
321
+ }
322
+
323
+ const resolvedHardwareId = resolveDetectedScreen(hardwareId, this.detectedScreens)?.hardwareId || hardwareId;
324
+ const existing = this.instances.get(resolvedHardwareId);
325
+ if (!existing) {
326
+ this.logger.warn(`[MultiKiosk] Cannot navigate ${hardwareId} - not in instance map`);
327
+ return;
233
328
  }
234
329
 
235
330
  this.killInstance(existing);
@@ -256,27 +351,41 @@ export class MultiScreenKioskManager {
256
351
  }
257
352
 
258
353
  /** Kill all Chrome instances */
259
- async killAll(options?: { clearDesired?: boolean }): Promise<void> {
260
- for (const [, instance] of this.instances) {
261
- this.killInstance(instance);
262
- }
263
- this.instances.clear();
264
- this.stopPoll();
265
-
266
- if (options?.clearDesired !== false) {
267
- this.desiredScreenMap = [];
268
- this.desiredIdentity = null;
269
- }
354
+ async killAll(options?: { clearDesired?: boolean }): Promise<void> {
355
+ for (const [, instance] of this.instances) {
356
+ this.killInstance(instance);
357
+ }
358
+ this.instances.clear();
359
+ this.stopPoll();
360
+
361
+ if (this.isShellModeOnWindows()) {
362
+ this.clearShellMultiConfig();
363
+ this.killShellManagedBrowsers();
364
+ }
365
+
366
+ if (options?.clearDesired !== false) {
367
+ this.desiredScreenMap = [];
368
+ this.desiredIdentity = null;
369
+ }
270
370
  }
271
371
 
272
- /** Restart all Chrome instances */
273
- async restartAll(identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
274
- this.logger.info('[MultiKiosk] Restarting all Chrome instances');
275
-
276
- const mappings: ScreenMapping[] = [];
277
- for (const [, instance] of this.instances) {
278
- mappings.push({ hardwareId: instance.mappingId, url: instance.mappingUrl, label: undefined });
279
- }
372
+ /** Restart all Chrome instances */
373
+ async restartAll(identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
374
+ this.logger.info('[MultiKiosk] Restarting all Chrome instances');
375
+
376
+ if (this.isShellModeOnWindows()) {
377
+ if (this.desiredScreenMap.length === 0) {
378
+ return this.getStatus();
379
+ }
380
+ this.killShellManagedBrowsers();
381
+ await new Promise((r) => setTimeout(r, 2_000));
382
+ return this.applyScreenMap(this.desiredScreenMap, identity);
383
+ }
384
+
385
+ const mappings: ScreenMapping[] = [];
386
+ for (const [, instance] of this.instances) {
387
+ mappings.push({ hardwareId: instance.mappingId, url: instance.mappingUrl, label: undefined });
388
+ }
280
389
 
281
390
  await this.killAll({ clearDesired: false });
282
391
  await new Promise((r) => setTimeout(r, 2_000));
@@ -296,12 +405,24 @@ export class MultiScreenKioskManager {
296
405
  return this.applyScreenMap(this.desiredScreenMap, id);
297
406
  }
298
407
 
299
- /** Get status of all screen instances */
300
- getStatus(): MultiScreenKioskStatus {
301
- const screens: SingleScreenStatus[] = [];
302
- for (const [, instance] of this.instances) {
303
- const running = instance.process !== null && instance.process.exitCode === null;
304
- screens.push({
408
+ /** Get status of all screen instances */
409
+ getStatus(): MultiScreenKioskStatus {
410
+ if (this.isShellModeOnWindows() && this.desiredIdentity && this.desiredScreenMap.length > 0) {
411
+ return {
412
+ screens: this.desiredScreenMap.map((mapping, index) => ({
413
+ hardwareId: String(mapping.hardwareId || '').trim() || String(index + 1),
414
+ url: this.buildUrl(mapping.url || this.config.defaultUrl, this.desiredIdentity!, index),
415
+ running: false,
416
+ pid: null,
417
+ uptimeMs: null,
418
+ })),
419
+ };
420
+ }
421
+
422
+ const screens: SingleScreenStatus[] = [];
423
+ for (const [, instance] of this.instances) {
424
+ const running = instance.process !== null && instance.process.exitCode === null;
425
+ screens.push({
305
426
  hardwareId: instance.hardwareId,
306
427
  url: instance.url,
307
428
  running,
@@ -313,10 +434,13 @@ export class MultiScreenKioskManager {
313
434
  return { screens };
314
435
  }
315
436
 
316
- /** Check if any screens are actively managed */
317
- isActive(): boolean {
318
- return this.instances.size > 0;
319
- }
437
+ /** Check if any screens are actively managed */
438
+ isActive(): boolean {
439
+ if (this.isShellModeOnWindows()) {
440
+ return this.desiredScreenMap.length > 0;
441
+ }
442
+ return this.instances.size > 0;
443
+ }
320
444
 
321
445
  /** Cleanup on agent shutdown */
322
446
  destroy(): void {