lightman-agent 1.0.24 → 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.24",
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
@@ -524,6 +524,13 @@ async function main(): Promise<void> {
524
524
  lastKnownTotalScreens = totalScreens;
525
525
  const effectiveRequestedMap = normalizeScreenMapForTotalScreens(requestedScreenMap, totalScreens);
526
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
+
527
534
  if (effectiveRequestedMap.length > 0) {
528
535
  const resolved = resolveScreenMap({
529
536
  requestedScreenMap: effectiveRequestedMap,
@@ -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 {