lightman-agent 1.0.18 → 1.0.21

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 (52) hide show
  1. package/agent.config.json +22 -23
  2. package/agent.config.template.json +30 -31
  3. package/bin/cms-agent.js +269 -248
  4. package/package.json +1 -1
  5. package/public/assets/index-CcBNCz6h.css +1 -1
  6. package/public/assets/index-D9QHMG8k.js +1 -1
  7. package/public/assets/index-H-8HDl46.js +1 -1
  8. package/public/assets/index-YodeiCia.css +1 -1
  9. package/public/assets/index-legacy-DWtNM8y7.js +41 -41
  10. package/public/assets/polyfills-legacy-DyVYWHbW.js +4 -4
  11. package/scripts/guardian.ps1 +50 -124
  12. package/scripts/install-windows.ps1 +60 -116
  13. package/scripts/lightman-agent.logrotate +12 -12
  14. package/scripts/lightman-agent.service +38 -38
  15. package/scripts/reinstall-windows.ps1 +26 -26
  16. package/scripts/restore-desktop.ps1 +32 -32
  17. package/scripts/setup.ps1 +17 -22
  18. package/scripts/sync-display.mjs +20 -20
  19. package/scripts/uninstall-windows.ps1 +54 -54
  20. package/src/commands/display.ts +177 -177
  21. package/src/commands/kiosk.ts +113 -113
  22. package/src/commands/maintenance.ts +106 -106
  23. package/src/commands/network.ts +129 -129
  24. package/src/commands/power.ts +163 -163
  25. package/src/commands/rpi.ts +45 -45
  26. package/src/commands/screenshot.ts +166 -166
  27. package/src/commands/serial.ts +17 -17
  28. package/src/commands/update.ts +124 -124
  29. package/src/index.ts +173 -90
  30. package/src/lib/config.ts +2 -3
  31. package/src/lib/identity.ts +40 -40
  32. package/src/lib/logger.ts +137 -137
  33. package/src/lib/platform.ts +10 -10
  34. package/src/lib/rpi.ts +180 -180
  35. package/src/lib/screenMap.ts +135 -0
  36. package/src/lib/screens.ts +128 -128
  37. package/src/lib/types.ts +176 -177
  38. package/src/services/commands.ts +107 -107
  39. package/src/services/health.ts +161 -161
  40. package/src/services/localEvents.ts +60 -60
  41. package/src/services/logForwarder.ts +72 -72
  42. package/src/services/multiScreenKiosk.ts +116 -83
  43. package/src/services/oscBridge.ts +186 -186
  44. package/src/services/powerScheduler.ts +260 -260
  45. package/src/services/provisioning.ts +120 -122
  46. package/src/services/serialBridge.ts +230 -230
  47. package/src/services/serviceLauncher.ts +183 -183
  48. package/src/services/staticServer.ts +226 -226
  49. package/src/services/updater.ts +249 -249
  50. package/src/services/watchdog.ts +310 -310
  51. package/src/services/websocket.ts +152 -152
  52. package/tsconfig.json +28 -28
@@ -1,15 +1,16 @@
1
- import { spawn } from 'child_process';
1
+ import { spawn } from 'child_process';
2
2
  import type { ChildProcess } from 'child_process';
3
3
  import type { KioskConfig, ScreenMapping, MultiScreenKioskStatus, SingleScreenStatus } from '../lib/types.js';
4
4
  import type { DetectedScreen } from '../lib/screens.js';
5
5
  import type { Logger } from '../lib/logger.js';
6
+ import { resolveDetectedScreen, resolveScreenMap } from '../lib/screenMap.js';
6
7
 
7
8
  interface ScreenInstance {
8
9
  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
10
+ mappingId: string;
11
+ mappingUrl: string;
12
+ screenIndex: number;
13
+ url: string;
13
14
  screen: DetectedScreen;
14
15
  process: ChildProcess | null;
15
16
  startedAt: number | null;
@@ -17,14 +18,15 @@ interface ScreenInstance {
17
18
  }
18
19
 
19
20
  /**
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.
21
+ * Manages multiple Chrome kiosk instances, one per physical display.
22
22
  */
23
23
  export class MultiScreenKioskManager {
24
24
  private config: KioskConfig;
25
25
  private logger: Logger;
26
26
  private instances: Map<string, ScreenInstance> = new Map();
27
27
  private detectedScreens: DetectedScreen[] = [];
28
+ private desiredScreenMap: ScreenMapping[] = [];
29
+ private desiredIdentity: { deviceId: string; apiKey: string } | null = null;
28
30
  private pollTimer: NodeJS.Timeout | null = null;
29
31
  private applying = false;
30
32
 
@@ -45,11 +47,14 @@ export class MultiScreenKioskManager {
45
47
  * and relaunches those whose URL changed.
46
48
  */
47
49
  async applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
48
- // Guard against concurrent calls
50
+ this.desiredScreenMap = screenMap.map((m) => ({ ...m }));
51
+ this.desiredIdentity = { ...identity };
52
+
49
53
  if (this.applying) {
50
54
  this.logger.warn('[MultiKiosk] applyScreenMap already in progress, skipping');
51
55
  return this.getStatus();
52
56
  }
57
+
53
58
  this.applying = true;
54
59
  try {
55
60
  return await this._applyScreenMap(screenMap, identity);
@@ -59,47 +64,66 @@ export class MultiScreenKioskManager {
59
64
  }
60
65
 
61
66
  private async _applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
62
- this.logger.info(`[MultiKiosk] Applying screen map: ${screenMap.length} mapping(s)`);
67
+ const resolvedMap = resolveScreenMap({
68
+ requestedScreenMap: screenMap,
69
+ detectedScreens: this.detectedScreens,
70
+ totalScreens: screenMap.length,
71
+ });
63
72
 
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);
73
+ this.logger.info(
74
+ `[MultiKiosk] Applying screen map: ${resolvedMap.screenMap.length} mapping(s), mode=${resolvedMap.mode}`
75
+ );
76
+
77
+ const resolvedEntries: Array<{ mapping: ScreenMapping; screen: DetectedScreen; index: number }> = [];
78
+ const resolvedHardwareIds = new Set<string>();
79
+
80
+ for (let idx = 0; idx < resolvedMap.screenMap.length; idx++) {
81
+ const mapping = resolvedMap.screenMap[idx];
82
+ if (!mapping.hardwareId) {
83
+ this.logger.warn(`[MultiKiosk] Mapping index ${idx} has no hardwareId, skipping`);
84
+ continue;
71
85
  }
72
- }
73
86
 
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
87
  const screen = this.findScreen(mapping.hardwareId);
79
88
  if (!screen) {
80
89
  this.logger.warn(`[MultiKiosk] Screen ${mapping.hardwareId} not detected, skipping`);
81
90
  continue;
82
91
  }
83
92
 
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
93
+ if (resolvedHardwareIds.has(screen.hardwareId)) {
94
+ this.logger.warn(`[MultiKiosk] Screen ${screen.hardwareId} is assigned more than once, skipping duplicate`);
95
+ continue;
96
+ }
97
+
98
+ resolvedHardwareIds.add(screen.hardwareId);
99
+ resolvedEntries.push({ mapping, screen, index: idx });
100
+ }
101
+
102
+ const mappedIds = new Set(resolvedEntries.map((e) => e.screen.hardwareId));
103
+ for (const [hwId, instance] of this.instances) {
104
+ if (!mappedIds.has(hwId)) {
105
+ this.logger.info(`[MultiKiosk] Screen ${hwId} removed from map, killing Chrome`);
106
+ this.killInstance(instance);
107
+ this.instances.delete(hwId);
108
+ }
109
+ }
110
+
111
+ for (const entry of resolvedEntries) {
112
+ const { mapping, screen, index } = entry;
86
113
  const basePath = mapping.url || this.config.defaultUrl;
87
- const url = this.buildUrl(basePath, identity, idx);
114
+ const url = this.buildUrl(basePath, identity, index);
88
115
 
89
- const existing = this.instances.get(mapping.hardwareId);
116
+ const existing = this.instances.get(screen.hardwareId);
90
117
  if (existing && existing.url === url && existing.process && existing.process.exitCode === null) {
91
- // Same URL, Chrome still running — skip
92
118
  this.logger.debug(`[MultiKiosk] Screen ${mapping.hardwareId} unchanged, skipping`);
93
119
  continue;
94
120
  }
95
121
 
96
- // Kill existing if URL changed
97
122
  if (existing) {
98
123
  this.killInstance(existing);
99
124
  }
100
125
 
101
- // Launch new Chrome on this screen
102
- await this.launchOnScreen(screen.hardwareId, url, screen, identity, mapping.hardwareId, mapping.url || '', idx);
126
+ await this.launchOnScreen(screen.hardwareId, url, screen, identity, mapping.hardwareId, mapping.url || '', index);
103
127
  }
104
128
 
105
129
  this.startPoll();
@@ -116,11 +140,8 @@ export class MultiScreenKioskManager {
116
140
  mappingUrl: string = '',
117
141
  screenIndex: number = 0
118
142
  ): Promise<void> {
119
- // Each screen gets its own user-data-dir to avoid profile lock conflicts
120
143
  const sep = process.platform === 'win32' ? '\\' : '/';
121
- const basePath = process.platform === 'win32'
122
- ? 'C:\\ProgramData\\Lightman'
123
- : '/tmp/lightman';
144
+ const basePath = process.platform === 'win32' ? 'C:\\ProgramData\\Lightman' : '/tmp/lightman';
124
145
  const userDataDir = `${basePath}${sep}chrome-kiosk-screen-${screen.index}`;
125
146
 
126
147
  const args = [
@@ -133,11 +154,13 @@ export class MultiScreenKioskManager {
133
154
  `--window-position=${screen.x},${screen.y}`,
134
155
  `--window-size=${screen.width},${screen.height}`,
135
156
  `--user-data-dir=${userDataDir}`,
136
- ...this.config.extraArgs.filter(a => !a.startsWith('--user-data-dir')),
157
+ ...this.config.extraArgs.filter((a) => !a.startsWith('--user-data-dir')),
137
158
  url,
138
159
  ];
139
160
 
140
- this.logger.info(`[MultiKiosk] Launching Chrome on ${hardwareId} (${screen.width}x${screen.height} @ ${screen.x},${screen.y}): ${url}`);
161
+ this.logger.info(
162
+ `[MultiKiosk] Launching Chrome on ${hardwareId} (${screen.width}x${screen.height} @ ${screen.x},${screen.y}): ${url}`
163
+ );
141
164
 
142
165
  const proc = spawn(this.config.browserPath, args, {
143
166
  stdio: 'ignore',
@@ -158,15 +181,15 @@ export class MultiScreenKioskManager {
158
181
  };
159
182
 
160
183
  proc.on('exit', (code) => {
161
- // Only auto-restart if this instance is still current (not replaced by a newer applyScreenMap)
162
184
  const current = this.instances.get(hardwareId);
163
185
  if (!current || current.process !== proc) return;
186
+
164
187
  this.logger.warn(`[MultiKiosk] Chrome on ${hardwareId} exited with code ${code}`);
165
188
  setTimeout(() => {
166
189
  const stillCurrent = this.instances.get(hardwareId);
167
190
  if (stillCurrent && stillCurrent === instance) {
168
191
  this.logger.info(`[MultiKiosk] Auto-restarting Chrome on ${hardwareId}`);
169
- this.launchOnScreen(hardwareId, url, screen, identity, mappingId, mappingUrl, screenIndex).catch(err => {
192
+ this.launchOnScreen(hardwareId, url, screen, identity, mappingId, mappingUrl, screenIndex).catch((err) => {
170
193
  this.logger.error(`[MultiKiosk] Failed to restart Chrome on ${hardwareId}:`, err);
171
194
  });
172
195
  }
@@ -182,12 +205,10 @@ export class MultiScreenKioskManager {
182
205
 
183
206
  /** Build the full URL with device credentials and screenIndex */
184
207
  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
208
  let fullUrl: string;
187
209
  if (path.startsWith('http://') || path.startsWith('https://')) {
188
210
  fullUrl = path;
189
211
  } else {
190
- // Ensure display URL format: /display/{slug}
191
212
  const displayPath = path.startsWith('/display/') ? path : `/display/${path.replace(/^\//, '')}`;
192
213
  fullUrl = `http://localhost:3403${displayPath}`;
193
214
  }
@@ -198,73 +219,83 @@ export class MultiScreenKioskManager {
198
219
  if (screenIndex !== undefined) {
199
220
  url.searchParams.set('screenIndex', String(screenIndex));
200
221
  }
222
+
201
223
  return url.toString();
202
224
  }
203
225
 
204
226
  /** Navigate a single screen to a new URL */
205
227
  async navigateScreen(hardwareId: string, url: string, identity: { deviceId: string; apiKey: string }): Promise<void> {
206
- const existing = this.instances.get(hardwareId);
228
+ const resolvedHardwareId = resolveDetectedScreen(hardwareId, this.detectedScreens)?.hardwareId || hardwareId;
229
+ const existing = this.instances.get(resolvedHardwareId);
207
230
  if (!existing) {
208
- this.logger.warn(`[MultiKiosk] Cannot navigate ${hardwareId} not in instance map`);
231
+ this.logger.warn(`[MultiKiosk] Cannot navigate ${hardwareId} - not in instance map`);
209
232
  return;
210
233
  }
211
234
 
212
235
  this.killInstance(existing);
213
- const fullUrl = this.buildUrl(url, identity);
214
- await this.launchOnScreen(hardwareId, fullUrl, existing.screen, identity);
236
+ const fullUrl = this.buildUrl(url, identity, existing.screenIndex);
237
+ await this.launchOnScreen(existing.hardwareId, fullUrl, existing.screen, identity, existing.mappingId, url, existing.screenIndex);
215
238
  }
216
239
 
217
240
  /** Kill Chrome for a specific screen instance */
218
241
  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;
242
+ if (!instance.process) return;
243
+
244
+ instance.process.removeAllListeners();
245
+ try {
246
+ instance.process.kill('SIGTERM');
247
+ } catch {
248
+ // Already dead.
227
249
  }
250
+ instance.process = null;
228
251
  }
229
252
 
230
253
  /** Find a detected screen by display number or full hardware ID */
231
254
  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;
255
+ return resolveDetectedScreen(id, this.detectedScreens);
246
256
  }
247
257
 
248
258
  /** Kill all Chrome instances */
249
- async killAll(): Promise<void> {
259
+ async killAll(options?: { clearDesired?: boolean }): Promise<void> {
250
260
  for (const [, instance] of this.instances) {
251
261
  this.killInstance(instance);
252
262
  }
263
+ this.instances.clear();
253
264
  this.stopPoll();
265
+
266
+ if (options?.clearDesired !== false) {
267
+ this.desiredScreenMap = [];
268
+ this.desiredIdentity = null;
269
+ }
254
270
  }
255
271
 
256
272
  /** Restart all Chrome instances */
257
273
  async restartAll(identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
258
274
  this.logger.info('[MultiKiosk] Restarting all Chrome instances');
275
+
259
276
  const mappings: ScreenMapping[] = [];
260
277
  for (const [, instance] of this.instances) {
261
- mappings.push({ hardwareId: instance.mappingId, url: instance.mappingUrl });
278
+ mappings.push({ hardwareId: instance.mappingId, url: instance.mappingUrl, label: undefined });
262
279
  }
263
- await this.killAll();
264
- await new Promise(r => setTimeout(r, 2_000));
280
+
281
+ await this.killAll({ clearDesired: false });
282
+ await new Promise((r) => setTimeout(r, 2_000));
265
283
  return this.applyScreenMap(mappings, identity);
266
284
  }
267
285
 
286
+ hasDesiredScreenMap(): boolean {
287
+ return this.desiredScreenMap.length > 0;
288
+ }
289
+
290
+ async reapplyDesiredMap(identity?: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
291
+ const id = identity || this.desiredIdentity;
292
+ if (!id || this.desiredScreenMap.length === 0) {
293
+ return this.getStatus();
294
+ }
295
+
296
+ return this.applyScreenMap(this.desiredScreenMap, id);
297
+ }
298
+
268
299
  /** Get status of all screen instances */
269
300
  getStatus(): MultiScreenKioskStatus {
270
301
  const screens: SingleScreenStatus[] = [];
@@ -278,6 +309,7 @@ export class MultiScreenKioskManager {
278
309
  uptimeMs: running && instance.startedAt ? Date.now() - instance.startedAt : null,
279
310
  });
280
311
  }
312
+
281
313
  return { screens };
282
314
  }
283
315
 
@@ -290,35 +322,36 @@ export class MultiScreenKioskManager {
290
322
  destroy(): void {
291
323
  this.stopPoll();
292
324
  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;
325
+ if (!instance.process) continue;
326
+ try {
327
+ instance.process.removeAllListeners();
328
+ instance.process.kill('SIGKILL');
329
+ } catch {
330
+ // Already dead.
301
331
  }
332
+ instance.process = null;
302
333
  }
303
334
  this.instances.clear();
335
+ this.desiredScreenMap = [];
336
+ this.desiredIdentity = null;
304
337
  }
305
338
 
306
339
  private startPoll(): void {
307
340
  if (this.pollTimer) return;
341
+
308
342
  this.pollTimer = setInterval(() => {
309
343
  for (const [, instance] of this.instances) {
310
344
  if (instance.process && instance.process.exitCode !== null) {
311
345
  this.logger.warn(`[MultiKiosk] Poll: Chrome on ${instance.hardwareId} died`);
312
- // exit handler will auto-restart
346
+ // Exit handler will auto-restart.
313
347
  }
314
348
  }
315
349
  }, this.config.pollIntervalMs);
316
350
  }
317
351
 
318
352
  private stopPoll(): void {
319
- if (this.pollTimer) {
320
- clearInterval(this.pollTimer);
321
- this.pollTimer = null;
322
- }
353
+ if (!this.pollTimer) return;
354
+ clearInterval(this.pollTimer);
355
+ this.pollTimer = null;
323
356
  }
324
- }
357
+ }