lightman-agent 1.0.5 → 1.0.6

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.
Files changed (115) hide show
  1. package/agent.config.template.json +30 -30
  2. package/package.json +52 -52
  3. package/public/assets/index-CcBNCz6h.css +1 -1
  4. package/public/assets/index-D9QHMG8k.js +1 -0
  5. package/public/assets/index-H-8HDl46.js +1 -1
  6. package/public/assets/index-YodeiCia.css +1 -0
  7. package/public/assets/index-legacy-DWtNM8y7.js +41 -0
  8. package/public/assets/museum-map-CwVDA2z1.svg +4182 -0
  9. package/public/assets/polyfills-legacy-DyVYWHbW.js +4 -0
  10. package/public/index.html +7 -2
  11. package/public/templates/custom08/elements/back-button.svg +20 -0
  12. package/public/templates/custom08/elements/base-map-background.svg +37 -0
  13. package/public/templates/custom08/elements/base-map.svg +1191 -0
  14. package/public/templates/custom08/elements/gallery-1-2-3-info-panel.svg +236 -0
  15. package/public/templates/custom08/elements/gallery-4-5-6-7-info-panel.svg +266 -0
  16. package/public/templates/custom08/elements/gallery-8-9-info-panel.svg +274 -0
  17. package/public/templates/custom08/elements/gallery-labels/_nav-map-styles.css +554 -0
  18. package/public/templates/custom08/elements/gallery-labels/_styles.css +556 -0
  19. package/public/templates/custom08/elements/gallery-labels/gallery-1.svg +35 -0
  20. package/public/templates/custom08/elements/gallery-labels/gallery-2.svg +34 -0
  21. package/public/templates/custom08/elements/gallery-labels/gallery-3.svg +34 -0
  22. package/public/templates/custom08/elements/gallery-labels/gallery-4.svg +37 -0
  23. package/public/templates/custom08/elements/gallery-labels/gallery-5.svg +34 -0
  24. package/public/templates/custom08/elements/gallery-labels/gallery-6.svg +34 -0
  25. package/public/templates/custom08/elements/gallery-labels/gallery-7.svg +34 -0
  26. package/public/templates/custom08/elements/gallery-labels/gallery-8.svg +37 -0
  27. package/public/templates/custom08/elements/gallery-labels/gallery-9.svg +34 -0
  28. package/public/templates/custom08/elements/hand-hint.png +0 -0
  29. package/public/templates/custom08/elements/idle-screen-bg.svg +5 -0
  30. package/public/templates/custom08/elements/idle-screen-map.svg +627 -0
  31. package/public/templates/custom08/elements/idle-screen-text.svg +350 -0
  32. package/public/templates/custom08/elements/key-map-1.svg +986 -0
  33. package/public/templates/custom08/elements/key-map-2.svg +1018 -0
  34. package/public/templates/custom08/elements/key-map-3.svg +1019 -0
  35. package/public/templates/custom08/elements/key-map-combined.svg +1001 -0
  36. package/public/templates/custom08/elements/map-highlight-marker.svg +11 -0
  37. package/public/templates/custom08/elements/map-pin-marker.svg +15 -0
  38. package/public/templates/custom08/elements/map-teardrop-star-marker.svg +13 -0
  39. package/public/templates/custom08/elements/nav-circle-galleries-1-3.svg +21 -0
  40. package/public/templates/custom08/elements/nav-circle-galleries-4-7.svg +24 -0
  41. package/public/templates/custom08/elements/nav-circle-galleries-8-9.svg +20 -0
  42. package/public/templates/custom08/elements/section1-map.svg +1435 -0
  43. package/public/templates/custom08/elements/section2-map.svg +1724 -0
  44. package/public/templates/custom08/elements/section3-map.svg +1295 -0
  45. package/public/templates/custom08/fonts/CabinetGrotesk-Variable.ttf +0 -0
  46. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.12_PM.png +0 -0
  47. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.56_PM.png +0 -0
  48. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.24.24_PM.png +0 -0
  49. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.31.58_PM.jpg +0 -0
  50. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.11_PM.jpg +0 -0
  51. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.36_PM.jpg +0 -0
  52. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.48_PM.jpg +0 -0
  53. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.59_PM.jpg +0 -0
  54. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.15_PM.jpg +0 -0
  55. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.27_PM.jpg +0 -0
  56. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.34_PM.jpg +0 -0
  57. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.42_PM.jpg +0 -0
  58. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.50_PM.jpg +0 -0
  59. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.58_PM.jpg +0 -0
  60. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.04_PM.jpg +0 -0
  61. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.11_PM.jpg +0 -0
  62. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.20_PM.jpg +0 -0
  63. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.57_PM.jpg +0 -0
  64. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.03_PM.jpg +0 -0
  65. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.16_PM.jpg +0 -0
  66. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.23_PM.jpg +0 -0
  67. package/public/templates/custom08/images/highlights/prologue-highlight.png +0 -0
  68. package/scripts/guardian.ps1 +75 -75
  69. package/scripts/install-linux.sh +134 -134
  70. package/scripts/install-rpi.sh +117 -117
  71. package/scripts/install-windows.ps1 +505 -505
  72. package/scripts/launch-kiosk.vbs +101 -101
  73. package/scripts/lightman-agent.logrotate +12 -12
  74. package/scripts/lightman-agent.service +38 -38
  75. package/scripts/lightman-shell.bat +107 -107
  76. package/scripts/reinstall-windows.ps1 +26 -26
  77. package/scripts/restore-desktop.ps1 +32 -32
  78. package/scripts/setup.ps1 +116 -116
  79. package/scripts/setup.sh +115 -115
  80. package/scripts/sync-display.mjs +20 -0
  81. package/scripts/uninstall-linux.sh +50 -50
  82. package/scripts/uninstall-windows.ps1 +54 -54
  83. package/src/commands/display.ts +177 -177
  84. package/src/commands/kiosk.ts +113 -113
  85. package/src/commands/maintenance.ts +106 -106
  86. package/src/commands/network.ts +129 -129
  87. package/src/commands/power.ts +163 -163
  88. package/src/commands/rpi.ts +45 -45
  89. package/src/commands/screenshot.ts +166 -166
  90. package/src/commands/serial.ts +17 -17
  91. package/src/commands/update.ts +124 -124
  92. package/src/index.ts +652 -652
  93. package/src/lib/config.ts +69 -69
  94. package/src/lib/identity.ts +40 -40
  95. package/src/lib/logger.ts +137 -137
  96. package/src/lib/platform.ts +10 -10
  97. package/src/lib/rpi.ts +180 -180
  98. package/src/lib/screens.ts +128 -128
  99. package/src/lib/types.ts +176 -176
  100. package/src/services/commands.ts +107 -107
  101. package/src/services/health.ts +161 -161
  102. package/src/services/kiosk.ts +384 -384
  103. package/src/services/localEvents.ts +60 -60
  104. package/src/services/logForwarder.ts +72 -72
  105. package/src/services/multiScreenKiosk.ts +324 -324
  106. package/src/services/oscBridge.ts +186 -186
  107. package/src/services/powerScheduler.ts +260 -260
  108. package/src/services/provisioning.ts +120 -120
  109. package/src/services/serialBridge.ts +230 -230
  110. package/src/services/serviceLauncher.ts +183 -183
  111. package/src/services/staticServer.ts +226 -226
  112. package/src/services/updater.ts +249 -249
  113. package/src/services/watchdog.ts +310 -310
  114. package/src/services/websocket.ts +152 -152
  115. package/tsconfig.json +28 -28
@@ -1,324 +1,324 @@
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
-
7
- interface ScreenInstance {
8
- hardwareId: string;
9
- mappingId: string; // original hardwareId from admin ("1","2","3")
10
- mappingUrl: string; // original URL from mapping (before buildUrl)
11
- screenIndex: number; // position in the screenMap array
12
- url: string; // fully built URL with credentials
13
- screen: DetectedScreen;
14
- process: ChildProcess | null;
15
- startedAt: number | null;
16
- userDataDir: string;
17
- }
18
-
19
- /**
20
- * Manages multiple Chrome kiosk instances — one per physical display.
21
- * Each Chrome gets its own --user-data-dir and --window-position to target the correct screen.
22
- */
23
- export class MultiScreenKioskManager {
24
- private config: KioskConfig;
25
- private logger: Logger;
26
- private instances: Map<string, ScreenInstance> = new Map();
27
- private detectedScreens: DetectedScreen[] = [];
28
- private pollTimer: NodeJS.Timeout | null = null;
29
- private applying = false;
30
-
31
- constructor(config: KioskConfig, logger: Logger) {
32
- this.config = config;
33
- this.logger = logger;
34
- }
35
-
36
- /** Update the list of detected physical screens */
37
- setDetectedScreens(screens: DetectedScreen[]): void {
38
- this.detectedScreens = screens;
39
- this.logger.info(`[MultiKiosk] Updated detected screens: ${screens.length} display(s)`);
40
- }
41
-
42
- /**
43
- * Apply a screen mapping from the server/admin.
44
- * Launches Chrome on screens that have new URLs, kills those that were removed,
45
- * and relaunches those whose URL changed.
46
- */
47
- async applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
48
- // Guard against concurrent calls
49
- if (this.applying) {
50
- this.logger.warn('[MultiKiosk] applyScreenMap already in progress, skipping');
51
- return this.getStatus();
52
- }
53
- this.applying = true;
54
- try {
55
- return await this._applyScreenMap(screenMap, identity);
56
- } finally {
57
- this.applying = false;
58
- }
59
- }
60
-
61
- private async _applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
62
- this.logger.info(`[MultiKiosk] Applying screen map: ${screenMap.length} mapping(s)`);
63
-
64
- // Find which hardwareIds are no longer mapped — kill those
65
- const mappedIds = new Set(screenMap.map(m => m.hardwareId));
66
- for (const [hwId, instance] of this.instances) {
67
- if (!mappedIds.has(hwId)) {
68
- this.logger.info(`[MultiKiosk] Screen ${hwId} removed from map, killing Chrome`);
69
- this.killInstance(instance);
70
- this.instances.delete(hwId);
71
- }
72
- }
73
-
74
- // Launch or relaunch for each mapping
75
- for (let idx = 0; idx < screenMap.length; idx++) {
76
- const mapping = screenMap[idx];
77
- // Match by display number: admin saves "1","2","3" — agent detects "\\.\DISPLAY1" etc.
78
- const screen = this.findScreen(mapping.hardwareId);
79
- if (!screen) {
80
- this.logger.warn(`[MultiKiosk] Screen ${mapping.hardwareId} not detected, skipping`);
81
- continue;
82
- }
83
-
84
- // Use the mapping's URL if provided, otherwise use the default kiosk URL
85
- // The screenIndex is the mapping's position (idx) in the array
86
- const basePath = mapping.url || this.config.defaultUrl;
87
- const url = this.buildUrl(basePath, identity, idx);
88
-
89
- const existing = this.instances.get(mapping.hardwareId);
90
- if (existing && existing.url === url && existing.process && existing.process.exitCode === null) {
91
- // Same URL, Chrome still running — skip
92
- this.logger.debug(`[MultiKiosk] Screen ${mapping.hardwareId} unchanged, skipping`);
93
- continue;
94
- }
95
-
96
- // Kill existing if URL changed
97
- if (existing) {
98
- this.killInstance(existing);
99
- }
100
-
101
- // Launch new Chrome on this screen
102
- await this.launchOnScreen(screen.hardwareId, url, screen, identity, mapping.hardwareId, mapping.url || '', idx);
103
- }
104
-
105
- this.startPoll();
106
- return this.getStatus();
107
- }
108
-
109
- /** Launch a single Chrome instance on a specific screen */
110
- private async launchOnScreen(
111
- hardwareId: string,
112
- url: string,
113
- screen: DetectedScreen,
114
- identity: { deviceId: string; apiKey: string },
115
- mappingId: string = hardwareId,
116
- mappingUrl: string = '',
117
- screenIndex: number = 0
118
- ): Promise<void> {
119
- // Each screen gets its own user-data-dir to avoid profile lock conflicts
120
- const sep = process.platform === 'win32' ? '\\' : '/';
121
- const basePath = process.platform === 'win32'
122
- ? 'C:\\ProgramData\\Lightman'
123
- : '/tmp/lightman';
124
- const userDataDir = `${basePath}${sep}chrome-kiosk-screen-${screen.index}`;
125
-
126
- const args = [
127
- '--kiosk',
128
- '--noerrdialogs',
129
- '--disable-infobars',
130
- '--disable-session-crashed-bubble',
131
- '--no-first-run',
132
- '--no-default-browser-check',
133
- `--window-position=${screen.x},${screen.y}`,
134
- `--window-size=${screen.width},${screen.height}`,
135
- `--user-data-dir=${userDataDir}`,
136
- ...this.config.extraArgs.filter(a => !a.startsWith('--user-data-dir')),
137
- url,
138
- ];
139
-
140
- this.logger.info(`[MultiKiosk] Launching Chrome on ${hardwareId} (${screen.width}x${screen.height} @ ${screen.x},${screen.y}): ${url}`);
141
-
142
- const proc = spawn(this.config.browserPath, args, {
143
- stdio: 'ignore',
144
- detached: true,
145
- });
146
- proc.unref();
147
-
148
- const instance: ScreenInstance = {
149
- hardwareId,
150
- mappingId,
151
- mappingUrl,
152
- screenIndex,
153
- url,
154
- screen,
155
- process: proc,
156
- startedAt: Date.now(),
157
- userDataDir,
158
- };
159
-
160
- proc.on('exit', (code) => {
161
- // Only auto-restart if this instance is still current (not replaced by a newer applyScreenMap)
162
- const current = this.instances.get(hardwareId);
163
- if (!current || current.process !== proc) return;
164
- this.logger.warn(`[MultiKiosk] Chrome on ${hardwareId} exited with code ${code}`);
165
- setTimeout(() => {
166
- const stillCurrent = this.instances.get(hardwareId);
167
- if (stillCurrent && stillCurrent === instance) {
168
- this.logger.info(`[MultiKiosk] Auto-restarting Chrome on ${hardwareId}`);
169
- this.launchOnScreen(hardwareId, url, screen, identity, mappingId, mappingUrl, screenIndex).catch(err => {
170
- this.logger.error(`[MultiKiosk] Failed to restart Chrome on ${hardwareId}:`, err);
171
- });
172
- }
173
- }, 3_000);
174
- });
175
-
176
- proc.on('error', (err) => {
177
- this.logger.error(`[MultiKiosk] Chrome error on ${hardwareId}:`, err.message);
178
- });
179
-
180
- this.instances.set(hardwareId, instance);
181
- }
182
-
183
- /** Build the full URL with device credentials and screenIndex */
184
- private buildUrl(path: string, identity: { deviceId: string; apiKey: string }, screenIndex?: number): string {
185
- // If path is already a full URL, use it; otherwise prepend the local static server
186
- let fullUrl: string;
187
- if (path.startsWith('http://') || path.startsWith('https://')) {
188
- fullUrl = path;
189
- } else {
190
- // Ensure display URL format: /display/{slug}
191
- const displayPath = path.startsWith('/display/') ? path : `/display/${path.replace(/^\//, '')}`;
192
- fullUrl = `http://localhost:3403${displayPath}`;
193
- }
194
-
195
- const url = new URL(fullUrl);
196
- url.searchParams.set('deviceId', identity.deviceId);
197
- url.searchParams.set('apiKey', identity.apiKey);
198
- if (screenIndex !== undefined) {
199
- url.searchParams.set('screenIndex', String(screenIndex));
200
- }
201
- return url.toString();
202
- }
203
-
204
- /** Navigate a single screen to a new URL */
205
- async navigateScreen(hardwareId: string, url: string, identity: { deviceId: string; apiKey: string }): Promise<void> {
206
- const existing = this.instances.get(hardwareId);
207
- if (!existing) {
208
- this.logger.warn(`[MultiKiosk] Cannot navigate ${hardwareId} — not in instance map`);
209
- return;
210
- }
211
-
212
- this.killInstance(existing);
213
- const fullUrl = this.buildUrl(url, identity);
214
- await this.launchOnScreen(hardwareId, fullUrl, existing.screen, identity);
215
- }
216
-
217
- /** Kill Chrome for a specific screen instance */
218
- private killInstance(instance: ScreenInstance): void {
219
- if (instance.process) {
220
- instance.process.removeAllListeners();
221
- try {
222
- instance.process.kill('SIGTERM');
223
- } catch {
224
- // Already dead
225
- }
226
- instance.process = null;
227
- }
228
- }
229
-
230
- /** Find a detected screen by display number or full hardware ID */
231
- private findScreen(id: string): DetectedScreen | undefined {
232
- // Direct match (full hardware ID like "\\.\DISPLAY1")
233
- const direct = this.detectedScreens.find(s => s.hardwareId === id);
234
- if (direct) return direct;
235
-
236
- // Match by display number ("1" → "\\.\DISPLAY1") — anchored to avoid "1" matching "DISPLAY11"
237
- if (/^\d+$/.test(id)) {
238
- const suffix = 'DISPLAY' + id;
239
- return this.detectedScreens.find(s => {
240
- const name = s.hardwareId.toUpperCase();
241
- return name.endsWith(suffix) && (name.length === suffix.length || name[name.length - suffix.length - 1] === '\\');
242
- });
243
- }
244
-
245
- return undefined;
246
- }
247
-
248
- /** Kill all Chrome instances */
249
- async killAll(): Promise<void> {
250
- for (const [, instance] of this.instances) {
251
- this.killInstance(instance);
252
- }
253
- this.stopPoll();
254
- }
255
-
256
- /** Restart all Chrome instances */
257
- async restartAll(identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
258
- this.logger.info('[MultiKiosk] Restarting all Chrome instances');
259
- const mappings: ScreenMapping[] = [];
260
- for (const [, instance] of this.instances) {
261
- mappings.push({ hardwareId: instance.mappingId, url: instance.mappingUrl });
262
- }
263
- await this.killAll();
264
- await new Promise(r => setTimeout(r, 2_000));
265
- return this.applyScreenMap(mappings, identity);
266
- }
267
-
268
- /** Get status of all screen instances */
269
- getStatus(): MultiScreenKioskStatus {
270
- const screens: SingleScreenStatus[] = [];
271
- for (const [, instance] of this.instances) {
272
- const running = instance.process !== null && instance.process.exitCode === null;
273
- screens.push({
274
- hardwareId: instance.hardwareId,
275
- url: instance.url,
276
- running,
277
- pid: running && instance.process ? instance.process.pid ?? null : null,
278
- uptimeMs: running && instance.startedAt ? Date.now() - instance.startedAt : null,
279
- });
280
- }
281
- return { screens };
282
- }
283
-
284
- /** Check if any screens are actively managed */
285
- isActive(): boolean {
286
- return this.instances.size > 0;
287
- }
288
-
289
- /** Cleanup on agent shutdown */
290
- destroy(): void {
291
- this.stopPoll();
292
- for (const [, instance] of this.instances) {
293
- if (instance.process) {
294
- try {
295
- instance.process.removeAllListeners();
296
- instance.process.kill('SIGKILL');
297
- } catch {
298
- // Already dead
299
- }
300
- instance.process = null;
301
- }
302
- }
303
- this.instances.clear();
304
- }
305
-
306
- private startPoll(): void {
307
- if (this.pollTimer) return;
308
- this.pollTimer = setInterval(() => {
309
- for (const [, instance] of this.instances) {
310
- if (instance.process && instance.process.exitCode !== null) {
311
- this.logger.warn(`[MultiKiosk] Poll: Chrome on ${instance.hardwareId} died`);
312
- // exit handler will auto-restart
313
- }
314
- }
315
- }, this.config.pollIntervalMs);
316
- }
317
-
318
- private stopPoll(): void {
319
- if (this.pollTimer) {
320
- clearInterval(this.pollTimer);
321
- this.pollTimer = null;
322
- }
323
- }
324
- }
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
+
7
+ interface ScreenInstance {
8
+ hardwareId: string;
9
+ mappingId: string; // original hardwareId from admin ("1","2","3")
10
+ mappingUrl: string; // original URL from mapping (before buildUrl)
11
+ screenIndex: number; // position in the screenMap array
12
+ url: string; // fully built URL with credentials
13
+ screen: DetectedScreen;
14
+ process: ChildProcess | null;
15
+ startedAt: number | null;
16
+ userDataDir: string;
17
+ }
18
+
19
+ /**
20
+ * Manages multiple Chrome kiosk instances — one per physical display.
21
+ * Each Chrome gets its own --user-data-dir and --window-position to target the correct screen.
22
+ */
23
+ export class MultiScreenKioskManager {
24
+ private config: KioskConfig;
25
+ private logger: Logger;
26
+ private instances: Map<string, ScreenInstance> = new Map();
27
+ private detectedScreens: DetectedScreen[] = [];
28
+ private pollTimer: NodeJS.Timeout | null = null;
29
+ private applying = false;
30
+
31
+ constructor(config: KioskConfig, logger: Logger) {
32
+ this.config = config;
33
+ this.logger = logger;
34
+ }
35
+
36
+ /** Update the list of detected physical screens */
37
+ setDetectedScreens(screens: DetectedScreen[]): void {
38
+ this.detectedScreens = screens;
39
+ this.logger.info(`[MultiKiosk] Updated detected screens: ${screens.length} display(s)`);
40
+ }
41
+
42
+ /**
43
+ * Apply a screen mapping from the server/admin.
44
+ * Launches Chrome on screens that have new URLs, kills those that were removed,
45
+ * and relaunches those whose URL changed.
46
+ */
47
+ async applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
48
+ // Guard against concurrent calls
49
+ if (this.applying) {
50
+ this.logger.warn('[MultiKiosk] applyScreenMap already in progress, skipping');
51
+ return this.getStatus();
52
+ }
53
+ this.applying = true;
54
+ try {
55
+ return await this._applyScreenMap(screenMap, identity);
56
+ } finally {
57
+ this.applying = false;
58
+ }
59
+ }
60
+
61
+ private async _applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
62
+ this.logger.info(`[MultiKiosk] Applying screen map: ${screenMap.length} mapping(s)`);
63
+
64
+ // Find which hardwareIds are no longer mapped — kill those
65
+ const mappedIds = new Set(screenMap.map(m => m.hardwareId));
66
+ for (const [hwId, instance] of this.instances) {
67
+ if (!mappedIds.has(hwId)) {
68
+ this.logger.info(`[MultiKiosk] Screen ${hwId} removed from map, killing Chrome`);
69
+ this.killInstance(instance);
70
+ this.instances.delete(hwId);
71
+ }
72
+ }
73
+
74
+ // Launch or relaunch for each mapping
75
+ for (let idx = 0; idx < screenMap.length; idx++) {
76
+ const mapping = screenMap[idx];
77
+ // Match by display number: admin saves "1","2","3" — agent detects "\\.\DISPLAY1" etc.
78
+ const screen = this.findScreen(mapping.hardwareId);
79
+ if (!screen) {
80
+ this.logger.warn(`[MultiKiosk] Screen ${mapping.hardwareId} not detected, skipping`);
81
+ continue;
82
+ }
83
+
84
+ // Use the mapping's URL if provided, otherwise use the default kiosk URL
85
+ // The screenIndex is the mapping's position (idx) in the array
86
+ const basePath = mapping.url || this.config.defaultUrl;
87
+ const url = this.buildUrl(basePath, identity, idx);
88
+
89
+ const existing = this.instances.get(mapping.hardwareId);
90
+ if (existing && existing.url === url && existing.process && existing.process.exitCode === null) {
91
+ // Same URL, Chrome still running — skip
92
+ this.logger.debug(`[MultiKiosk] Screen ${mapping.hardwareId} unchanged, skipping`);
93
+ continue;
94
+ }
95
+
96
+ // Kill existing if URL changed
97
+ if (existing) {
98
+ this.killInstance(existing);
99
+ }
100
+
101
+ // Launch new Chrome on this screen
102
+ await this.launchOnScreen(screen.hardwareId, url, screen, identity, mapping.hardwareId, mapping.url || '', idx);
103
+ }
104
+
105
+ this.startPoll();
106
+ return this.getStatus();
107
+ }
108
+
109
+ /** Launch a single Chrome instance on a specific screen */
110
+ private async launchOnScreen(
111
+ hardwareId: string,
112
+ url: string,
113
+ screen: DetectedScreen,
114
+ identity: { deviceId: string; apiKey: string },
115
+ mappingId: string = hardwareId,
116
+ mappingUrl: string = '',
117
+ screenIndex: number = 0
118
+ ): Promise<void> {
119
+ // Each screen gets its own user-data-dir to avoid profile lock conflicts
120
+ const sep = process.platform === 'win32' ? '\\' : '/';
121
+ const basePath = process.platform === 'win32'
122
+ ? 'C:\\ProgramData\\Lightman'
123
+ : '/tmp/lightman';
124
+ const userDataDir = `${basePath}${sep}chrome-kiosk-screen-${screen.index}`;
125
+
126
+ const args = [
127
+ '--kiosk',
128
+ '--noerrdialogs',
129
+ '--disable-infobars',
130
+ '--disable-session-crashed-bubble',
131
+ '--no-first-run',
132
+ '--no-default-browser-check',
133
+ `--window-position=${screen.x},${screen.y}`,
134
+ `--window-size=${screen.width},${screen.height}`,
135
+ `--user-data-dir=${userDataDir}`,
136
+ ...this.config.extraArgs.filter(a => !a.startsWith('--user-data-dir')),
137
+ url,
138
+ ];
139
+
140
+ this.logger.info(`[MultiKiosk] Launching Chrome on ${hardwareId} (${screen.width}x${screen.height} @ ${screen.x},${screen.y}): ${url}`);
141
+
142
+ const proc = spawn(this.config.browserPath, args, {
143
+ stdio: 'ignore',
144
+ detached: true,
145
+ });
146
+ proc.unref();
147
+
148
+ const instance: ScreenInstance = {
149
+ hardwareId,
150
+ mappingId,
151
+ mappingUrl,
152
+ screenIndex,
153
+ url,
154
+ screen,
155
+ process: proc,
156
+ startedAt: Date.now(),
157
+ userDataDir,
158
+ };
159
+
160
+ proc.on('exit', (code) => {
161
+ // Only auto-restart if this instance is still current (not replaced by a newer applyScreenMap)
162
+ const current = this.instances.get(hardwareId);
163
+ if (!current || current.process !== proc) return;
164
+ this.logger.warn(`[MultiKiosk] Chrome on ${hardwareId} exited with code ${code}`);
165
+ setTimeout(() => {
166
+ const stillCurrent = this.instances.get(hardwareId);
167
+ if (stillCurrent && stillCurrent === instance) {
168
+ this.logger.info(`[MultiKiosk] Auto-restarting Chrome on ${hardwareId}`);
169
+ this.launchOnScreen(hardwareId, url, screen, identity, mappingId, mappingUrl, screenIndex).catch(err => {
170
+ this.logger.error(`[MultiKiosk] Failed to restart Chrome on ${hardwareId}:`, err);
171
+ });
172
+ }
173
+ }, 3_000);
174
+ });
175
+
176
+ proc.on('error', (err) => {
177
+ this.logger.error(`[MultiKiosk] Chrome error on ${hardwareId}:`, err.message);
178
+ });
179
+
180
+ this.instances.set(hardwareId, instance);
181
+ }
182
+
183
+ /** Build the full URL with device credentials and screenIndex */
184
+ private buildUrl(path: string, identity: { deviceId: string; apiKey: string }, screenIndex?: number): string {
185
+ // If path is already a full URL, use it; otherwise prepend the local static server
186
+ let fullUrl: string;
187
+ if (path.startsWith('http://') || path.startsWith('https://')) {
188
+ fullUrl = path;
189
+ } else {
190
+ // Ensure display URL format: /display/{slug}
191
+ const displayPath = path.startsWith('/display/') ? path : `/display/${path.replace(/^\//, '')}`;
192
+ fullUrl = `http://localhost:3403${displayPath}`;
193
+ }
194
+
195
+ const url = new URL(fullUrl);
196
+ url.searchParams.set('deviceId', identity.deviceId);
197
+ url.searchParams.set('apiKey', identity.apiKey);
198
+ if (screenIndex !== undefined) {
199
+ url.searchParams.set('screenIndex', String(screenIndex));
200
+ }
201
+ return url.toString();
202
+ }
203
+
204
+ /** Navigate a single screen to a new URL */
205
+ async navigateScreen(hardwareId: string, url: string, identity: { deviceId: string; apiKey: string }): Promise<void> {
206
+ const existing = this.instances.get(hardwareId);
207
+ if (!existing) {
208
+ this.logger.warn(`[MultiKiosk] Cannot navigate ${hardwareId} — not in instance map`);
209
+ return;
210
+ }
211
+
212
+ this.killInstance(existing);
213
+ const fullUrl = this.buildUrl(url, identity);
214
+ await this.launchOnScreen(hardwareId, fullUrl, existing.screen, identity);
215
+ }
216
+
217
+ /** Kill Chrome for a specific screen instance */
218
+ private killInstance(instance: ScreenInstance): void {
219
+ if (instance.process) {
220
+ instance.process.removeAllListeners();
221
+ try {
222
+ instance.process.kill('SIGTERM');
223
+ } catch {
224
+ // Already dead
225
+ }
226
+ instance.process = null;
227
+ }
228
+ }
229
+
230
+ /** Find a detected screen by display number or full hardware ID */
231
+ private findScreen(id: string): DetectedScreen | undefined {
232
+ // Direct match (full hardware ID like "\\.\DISPLAY1")
233
+ const direct = this.detectedScreens.find(s => s.hardwareId === id);
234
+ if (direct) return direct;
235
+
236
+ // Match by display number ("1" → "\\.\DISPLAY1") — anchored to avoid "1" matching "DISPLAY11"
237
+ if (/^\d+$/.test(id)) {
238
+ const suffix = 'DISPLAY' + id;
239
+ return this.detectedScreens.find(s => {
240
+ const name = s.hardwareId.toUpperCase();
241
+ return name.endsWith(suffix) && (name.length === suffix.length || name[name.length - suffix.length - 1] === '\\');
242
+ });
243
+ }
244
+
245
+ return undefined;
246
+ }
247
+
248
+ /** Kill all Chrome instances */
249
+ async killAll(): Promise<void> {
250
+ for (const [, instance] of this.instances) {
251
+ this.killInstance(instance);
252
+ }
253
+ this.stopPoll();
254
+ }
255
+
256
+ /** Restart all Chrome instances */
257
+ async restartAll(identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
258
+ this.logger.info('[MultiKiosk] Restarting all Chrome instances');
259
+ const mappings: ScreenMapping[] = [];
260
+ for (const [, instance] of this.instances) {
261
+ mappings.push({ hardwareId: instance.mappingId, url: instance.mappingUrl });
262
+ }
263
+ await this.killAll();
264
+ await new Promise(r => setTimeout(r, 2_000));
265
+ return this.applyScreenMap(mappings, identity);
266
+ }
267
+
268
+ /** Get status of all screen instances */
269
+ getStatus(): MultiScreenKioskStatus {
270
+ const screens: SingleScreenStatus[] = [];
271
+ for (const [, instance] of this.instances) {
272
+ const running = instance.process !== null && instance.process.exitCode === null;
273
+ screens.push({
274
+ hardwareId: instance.hardwareId,
275
+ url: instance.url,
276
+ running,
277
+ pid: running && instance.process ? instance.process.pid ?? null : null,
278
+ uptimeMs: running && instance.startedAt ? Date.now() - instance.startedAt : null,
279
+ });
280
+ }
281
+ return { screens };
282
+ }
283
+
284
+ /** Check if any screens are actively managed */
285
+ isActive(): boolean {
286
+ return this.instances.size > 0;
287
+ }
288
+
289
+ /** Cleanup on agent shutdown */
290
+ destroy(): void {
291
+ this.stopPoll();
292
+ for (const [, instance] of this.instances) {
293
+ if (instance.process) {
294
+ try {
295
+ instance.process.removeAllListeners();
296
+ instance.process.kill('SIGKILL');
297
+ } catch {
298
+ // Already dead
299
+ }
300
+ instance.process = null;
301
+ }
302
+ }
303
+ this.instances.clear();
304
+ }
305
+
306
+ private startPoll(): void {
307
+ if (this.pollTimer) return;
308
+ this.pollTimer = setInterval(() => {
309
+ for (const [, instance] of this.instances) {
310
+ if (instance.process && instance.process.exitCode !== null) {
311
+ this.logger.warn(`[MultiKiosk] Poll: Chrome on ${instance.hardwareId} died`);
312
+ // exit handler will auto-restart
313
+ }
314
+ }
315
+ }, this.config.pollIntervalMs);
316
+ }
317
+
318
+ private stopPoll(): void {
319
+ if (this.pollTimer) {
320
+ clearInterval(this.pollTimer);
321
+ this.pollTimer = null;
322
+ }
323
+ }
324
+ }