lightman-agent 1.0.22 → 1.0.23
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/bin/cms-agent.js +53 -87
- package/package.json +1 -1
- package/scripts/guardian.ps1 +25 -6
- package/scripts/install-linux.sh +4 -4
- package/scripts/install-windows.ps1 +165 -58
- package/scripts/launch-kiosk.vbs +23 -7
- package/scripts/lightman-shell.bat +30 -9
- package/scripts/setup.ps1 +25 -9
- package/scripts/setup.sh +4 -4
- package/src/index.ts +142 -142
- package/src/lib/screenMap.ts +135 -135
- package/src/services/multiScreenKiosk.ts +356 -356
|
@@ -1,357 +1,357 @@
|
|
|
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 {
|
|
9
|
-
hardwareId: string;
|
|
10
|
-
mappingId: string;
|
|
11
|
-
mappingUrl: string;
|
|
12
|
-
screenIndex: number;
|
|
13
|
-
url: string;
|
|
14
|
-
screen: DetectedScreen;
|
|
15
|
-
process: ChildProcess | null;
|
|
16
|
-
startedAt: number | null;
|
|
17
|
-
userDataDir: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Manages multiple Chrome kiosk instances, one per physical display.
|
|
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 desiredScreenMap: ScreenMapping[] = [];
|
|
29
|
-
private desiredIdentity: { deviceId: string; apiKey: string } | null = null;
|
|
30
|
-
private pollTimer: NodeJS.Timeout | null = null;
|
|
31
|
-
private applying = false;
|
|
32
|
-
|
|
33
|
-
constructor(config: KioskConfig, logger: Logger) {
|
|
34
|
-
this.config = config;
|
|
35
|
-
this.logger = logger;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Update the list of detected physical screens */
|
|
39
|
-
setDetectedScreens(screens: DetectedScreen[]): void {
|
|
40
|
-
this.detectedScreens = screens;
|
|
41
|
-
this.logger.info(`[MultiKiosk] Updated detected screens: ${screens.length} display(s)`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Apply a screen mapping from the server/admin.
|
|
46
|
-
* Launches Chrome on screens that have new URLs, kills those that were removed,
|
|
47
|
-
* and relaunches those whose URL changed.
|
|
48
|
-
*/
|
|
49
|
-
async applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
|
|
50
|
-
this.desiredScreenMap = screenMap.map((m) => ({ ...m }));
|
|
51
|
-
this.desiredIdentity = { ...identity };
|
|
52
|
-
|
|
53
|
-
if (this.applying) {
|
|
54
|
-
this.logger.warn('[MultiKiosk] applyScreenMap already in progress, skipping');
|
|
55
|
-
return this.getStatus();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
this.applying = true;
|
|
59
|
-
try {
|
|
60
|
-
return await this._applyScreenMap(screenMap, identity);
|
|
61
|
-
} finally {
|
|
62
|
-
this.applying = false;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
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
|
-
});
|
|
72
|
-
|
|
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;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const screen = this.findScreen(mapping.hardwareId);
|
|
88
|
-
if (!screen) {
|
|
89
|
-
this.logger.warn(`[MultiKiosk] Screen ${mapping.hardwareId} not detected, skipping`);
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
|
|
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;
|
|
113
|
-
const basePath = mapping.url || this.config.defaultUrl;
|
|
114
|
-
const url = this.buildUrl(basePath, identity, index);
|
|
115
|
-
|
|
116
|
-
const existing = this.instances.get(screen.hardwareId);
|
|
117
|
-
if (existing && existing.url === url && existing.process && existing.process.exitCode === null) {
|
|
118
|
-
this.logger.debug(`[MultiKiosk] Screen ${mapping.hardwareId} unchanged, skipping`);
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (existing) {
|
|
123
|
-
this.killInstance(existing);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
await this.launchOnScreen(screen.hardwareId, url, screen, identity, mapping.hardwareId, mapping.url || '', index);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
this.startPoll();
|
|
130
|
-
return this.getStatus();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Launch a single Chrome instance on a specific screen */
|
|
134
|
-
private async launchOnScreen(
|
|
135
|
-
hardwareId: string,
|
|
136
|
-
url: string,
|
|
137
|
-
screen: DetectedScreen,
|
|
138
|
-
identity: { deviceId: string; apiKey: string },
|
|
139
|
-
mappingId: string = hardwareId,
|
|
140
|
-
mappingUrl: string = '',
|
|
141
|
-
screenIndex: number = 0
|
|
142
|
-
): Promise<void> {
|
|
143
|
-
const sep = process.platform === 'win32' ? '\\' : '/';
|
|
144
|
-
const basePath = process.platform === 'win32' ? 'C:\\ProgramData\\Lightman' : '/tmp/lightman';
|
|
145
|
-
const userDataDir = `${basePath}${sep}chrome-kiosk-screen-${screen.index}`;
|
|
146
|
-
|
|
147
|
-
const args = [
|
|
148
|
-
'--kiosk',
|
|
149
|
-
'--noerrdialogs',
|
|
150
|
-
'--disable-infobars',
|
|
151
|
-
'--disable-session-crashed-bubble',
|
|
152
|
-
'--no-first-run',
|
|
153
|
-
'--no-default-browser-check',
|
|
154
|
-
`--window-position=${screen.x},${screen.y}`,
|
|
155
|
-
`--window-size=${screen.width},${screen.height}`,
|
|
156
|
-
`--user-data-dir=${userDataDir}`,
|
|
157
|
-
...this.config.extraArgs.filter((a) => !a.startsWith('--user-data-dir')),
|
|
158
|
-
url,
|
|
159
|
-
];
|
|
160
|
-
|
|
161
|
-
this.logger.info(
|
|
162
|
-
`[MultiKiosk] Launching Chrome on ${hardwareId} (${screen.width}x${screen.height} @ ${screen.x},${screen.y}): ${url}`
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
const proc = spawn(this.config.browserPath, args, {
|
|
166
|
-
stdio: 'ignore',
|
|
167
|
-
detached: true,
|
|
168
|
-
});
|
|
169
|
-
proc.unref();
|
|
170
|
-
|
|
171
|
-
const instance: ScreenInstance = {
|
|
172
|
-
hardwareId,
|
|
173
|
-
mappingId,
|
|
174
|
-
mappingUrl,
|
|
175
|
-
screenIndex,
|
|
176
|
-
url,
|
|
177
|
-
screen,
|
|
178
|
-
process: proc,
|
|
179
|
-
startedAt: Date.now(),
|
|
180
|
-
userDataDir,
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
proc.on('exit', (code) => {
|
|
184
|
-
const current = this.instances.get(hardwareId);
|
|
185
|
-
if (!current || current.process !== proc) return;
|
|
186
|
-
|
|
187
|
-
this.logger.warn(`[MultiKiosk] Chrome on ${hardwareId} exited with code ${code}`);
|
|
188
|
-
setTimeout(() => {
|
|
189
|
-
const stillCurrent = this.instances.get(hardwareId);
|
|
190
|
-
if (stillCurrent && stillCurrent === instance) {
|
|
191
|
-
this.logger.info(`[MultiKiosk] Auto-restarting Chrome on ${hardwareId}`);
|
|
192
|
-
this.launchOnScreen(hardwareId, url, screen, identity, mappingId, mappingUrl, screenIndex).catch((err) => {
|
|
193
|
-
this.logger.error(`[MultiKiosk] Failed to restart Chrome on ${hardwareId}:`, err);
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
}, 3_000);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
proc.on('error', (err) => {
|
|
200
|
-
this.logger.error(`[MultiKiosk] Chrome error on ${hardwareId}:`, err.message);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
this.instances.set(hardwareId, instance);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** Build the full URL with device credentials and screenIndex */
|
|
207
|
-
private buildUrl(path: string, identity: { deviceId: string; apiKey: string }, screenIndex?: number): string {
|
|
208
|
-
let fullUrl: string;
|
|
209
|
-
if (path.startsWith('http://') || path.startsWith('https://')) {
|
|
210
|
-
fullUrl = path;
|
|
211
|
-
} else {
|
|
212
|
-
const displayPath = path.startsWith('/display/') ? path : `/display/${path.replace(/^\//, '')}`;
|
|
213
|
-
fullUrl = `http://localhost:3403${displayPath}`;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const url = new URL(fullUrl);
|
|
217
|
-
url.searchParams.set('deviceId', identity.deviceId);
|
|
218
|
-
url.searchParams.set('apiKey', identity.apiKey);
|
|
219
|
-
if (screenIndex !== undefined) {
|
|
220
|
-
url.searchParams.set('screenIndex', String(screenIndex));
|
|
221
|
-
}
|
|
222
|
-
|
|
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;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
this.killInstance(existing);
|
|
236
|
-
const fullUrl = this.buildUrl(url, identity, existing.screenIndex);
|
|
237
|
-
await this.launchOnScreen(existing.hardwareId, fullUrl, existing.screen, identity, existing.mappingId, url, existing.screenIndex);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** Kill Chrome for a specific screen instance */
|
|
241
|
-
private killInstance(instance: ScreenInstance): void {
|
|
242
|
-
if (!instance.process) return;
|
|
243
|
-
|
|
244
|
-
instance.process.removeAllListeners();
|
|
245
|
-
try {
|
|
246
|
-
instance.process.kill('SIGTERM');
|
|
247
|
-
} catch {
|
|
248
|
-
// Already dead.
|
|
249
|
-
}
|
|
250
|
-
instance.process = null;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** Find a detected screen by display number or full hardware ID */
|
|
254
|
-
private findScreen(id: string): DetectedScreen | undefined {
|
|
255
|
-
return resolveDetectedScreen(id, this.detectedScreens);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** 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
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
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
|
-
}
|
|
280
|
-
|
|
281
|
-
await this.killAll({ clearDesired: false });
|
|
282
|
-
await new Promise((r) => setTimeout(r, 2_000));
|
|
283
|
-
return this.applyScreenMap(mappings, identity);
|
|
284
|
-
}
|
|
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
|
-
|
|
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({
|
|
305
|
-
hardwareId: instance.hardwareId,
|
|
306
|
-
url: instance.url,
|
|
307
|
-
running,
|
|
308
|
-
pid: running && instance.process ? instance.process.pid ?? null : null,
|
|
309
|
-
uptimeMs: running && instance.startedAt ? Date.now() - instance.startedAt : null,
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
return { screens };
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/** Check if any screens are actively managed */
|
|
317
|
-
isActive(): boolean {
|
|
318
|
-
return this.instances.size > 0;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/** Cleanup on agent shutdown */
|
|
322
|
-
destroy(): void {
|
|
323
|
-
this.stopPoll();
|
|
324
|
-
for (const [, instance] of this.instances) {
|
|
325
|
-
if (!instance.process) continue;
|
|
326
|
-
try {
|
|
327
|
-
instance.process.removeAllListeners();
|
|
328
|
-
instance.process.kill('SIGKILL');
|
|
329
|
-
} catch {
|
|
330
|
-
// Already dead.
|
|
331
|
-
}
|
|
332
|
-
instance.process = null;
|
|
333
|
-
}
|
|
334
|
-
this.instances.clear();
|
|
335
|
-
this.desiredScreenMap = [];
|
|
336
|
-
this.desiredIdentity = null;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
private startPoll(): void {
|
|
340
|
-
if (this.pollTimer) return;
|
|
341
|
-
|
|
342
|
-
this.pollTimer = setInterval(() => {
|
|
343
|
-
for (const [, instance] of this.instances) {
|
|
344
|
-
if (instance.process && instance.process.exitCode !== null) {
|
|
345
|
-
this.logger.warn(`[MultiKiosk] Poll: Chrome on ${instance.hardwareId} died`);
|
|
346
|
-
// Exit handler will auto-restart.
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}, this.config.pollIntervalMs);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
private stopPoll(): void {
|
|
353
|
-
if (!this.pollTimer) return;
|
|
354
|
-
clearInterval(this.pollTimer);
|
|
355
|
-
this.pollTimer = null;
|
|
356
|
-
}
|
|
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 {
|
|
9
|
+
hardwareId: string;
|
|
10
|
+
mappingId: string;
|
|
11
|
+
mappingUrl: string;
|
|
12
|
+
screenIndex: number;
|
|
13
|
+
url: string;
|
|
14
|
+
screen: DetectedScreen;
|
|
15
|
+
process: ChildProcess | null;
|
|
16
|
+
startedAt: number | null;
|
|
17
|
+
userDataDir: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Manages multiple Chrome kiosk instances, one per physical display.
|
|
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 desiredScreenMap: ScreenMapping[] = [];
|
|
29
|
+
private desiredIdentity: { deviceId: string; apiKey: string } | null = null;
|
|
30
|
+
private pollTimer: NodeJS.Timeout | null = null;
|
|
31
|
+
private applying = false;
|
|
32
|
+
|
|
33
|
+
constructor(config: KioskConfig, logger: Logger) {
|
|
34
|
+
this.config = config;
|
|
35
|
+
this.logger = logger;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Update the list of detected physical screens */
|
|
39
|
+
setDetectedScreens(screens: DetectedScreen[]): void {
|
|
40
|
+
this.detectedScreens = screens;
|
|
41
|
+
this.logger.info(`[MultiKiosk] Updated detected screens: ${screens.length} display(s)`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Apply a screen mapping from the server/admin.
|
|
46
|
+
* Launches Chrome on screens that have new URLs, kills those that were removed,
|
|
47
|
+
* and relaunches those whose URL changed.
|
|
48
|
+
*/
|
|
49
|
+
async applyScreenMap(screenMap: ScreenMapping[], identity: { deviceId: string; apiKey: string }): Promise<MultiScreenKioskStatus> {
|
|
50
|
+
this.desiredScreenMap = screenMap.map((m) => ({ ...m }));
|
|
51
|
+
this.desiredIdentity = { ...identity };
|
|
52
|
+
|
|
53
|
+
if (this.applying) {
|
|
54
|
+
this.logger.warn('[MultiKiosk] applyScreenMap already in progress, skipping');
|
|
55
|
+
return this.getStatus();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.applying = true;
|
|
59
|
+
try {
|
|
60
|
+
return await this._applyScreenMap(screenMap, identity);
|
|
61
|
+
} finally {
|
|
62
|
+
this.applying = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
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
|
+
});
|
|
72
|
+
|
|
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;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const screen = this.findScreen(mapping.hardwareId);
|
|
88
|
+
if (!screen) {
|
|
89
|
+
this.logger.warn(`[MultiKiosk] Screen ${mapping.hardwareId} not detected, skipping`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
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;
|
|
113
|
+
const basePath = mapping.url || this.config.defaultUrl;
|
|
114
|
+
const url = this.buildUrl(basePath, identity, index);
|
|
115
|
+
|
|
116
|
+
const existing = this.instances.get(screen.hardwareId);
|
|
117
|
+
if (existing && existing.url === url && existing.process && existing.process.exitCode === null) {
|
|
118
|
+
this.logger.debug(`[MultiKiosk] Screen ${mapping.hardwareId} unchanged, skipping`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (existing) {
|
|
123
|
+
this.killInstance(existing);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await this.launchOnScreen(screen.hardwareId, url, screen, identity, mapping.hardwareId, mapping.url || '', index);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.startPoll();
|
|
130
|
+
return this.getStatus();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Launch a single Chrome instance on a specific screen */
|
|
134
|
+
private async launchOnScreen(
|
|
135
|
+
hardwareId: string,
|
|
136
|
+
url: string,
|
|
137
|
+
screen: DetectedScreen,
|
|
138
|
+
identity: { deviceId: string; apiKey: string },
|
|
139
|
+
mappingId: string = hardwareId,
|
|
140
|
+
mappingUrl: string = '',
|
|
141
|
+
screenIndex: number = 0
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
const sep = process.platform === 'win32' ? '\\' : '/';
|
|
144
|
+
const basePath = process.platform === 'win32' ? 'C:\\ProgramData\\Lightman' : '/tmp/lightman';
|
|
145
|
+
const userDataDir = `${basePath}${sep}chrome-kiosk-screen-${screen.index}`;
|
|
146
|
+
|
|
147
|
+
const args = [
|
|
148
|
+
'--kiosk',
|
|
149
|
+
'--noerrdialogs',
|
|
150
|
+
'--disable-infobars',
|
|
151
|
+
'--disable-session-crashed-bubble',
|
|
152
|
+
'--no-first-run',
|
|
153
|
+
'--no-default-browser-check',
|
|
154
|
+
`--window-position=${screen.x},${screen.y}`,
|
|
155
|
+
`--window-size=${screen.width},${screen.height}`,
|
|
156
|
+
`--user-data-dir=${userDataDir}`,
|
|
157
|
+
...this.config.extraArgs.filter((a) => !a.startsWith('--user-data-dir')),
|
|
158
|
+
url,
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
this.logger.info(
|
|
162
|
+
`[MultiKiosk] Launching Chrome on ${hardwareId} (${screen.width}x${screen.height} @ ${screen.x},${screen.y}): ${url}`
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const proc = spawn(this.config.browserPath, args, {
|
|
166
|
+
stdio: 'ignore',
|
|
167
|
+
detached: true,
|
|
168
|
+
});
|
|
169
|
+
proc.unref();
|
|
170
|
+
|
|
171
|
+
const instance: ScreenInstance = {
|
|
172
|
+
hardwareId,
|
|
173
|
+
mappingId,
|
|
174
|
+
mappingUrl,
|
|
175
|
+
screenIndex,
|
|
176
|
+
url,
|
|
177
|
+
screen,
|
|
178
|
+
process: proc,
|
|
179
|
+
startedAt: Date.now(),
|
|
180
|
+
userDataDir,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
proc.on('exit', (code) => {
|
|
184
|
+
const current = this.instances.get(hardwareId);
|
|
185
|
+
if (!current || current.process !== proc) return;
|
|
186
|
+
|
|
187
|
+
this.logger.warn(`[MultiKiosk] Chrome on ${hardwareId} exited with code ${code}`);
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
const stillCurrent = this.instances.get(hardwareId);
|
|
190
|
+
if (stillCurrent && stillCurrent === instance) {
|
|
191
|
+
this.logger.info(`[MultiKiosk] Auto-restarting Chrome on ${hardwareId}`);
|
|
192
|
+
this.launchOnScreen(hardwareId, url, screen, identity, mappingId, mappingUrl, screenIndex).catch((err) => {
|
|
193
|
+
this.logger.error(`[MultiKiosk] Failed to restart Chrome on ${hardwareId}:`, err);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}, 3_000);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
proc.on('error', (err) => {
|
|
200
|
+
this.logger.error(`[MultiKiosk] Chrome error on ${hardwareId}:`, err.message);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
this.instances.set(hardwareId, instance);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Build the full URL with device credentials and screenIndex */
|
|
207
|
+
private buildUrl(path: string, identity: { deviceId: string; apiKey: string }, screenIndex?: number): string {
|
|
208
|
+
let fullUrl: string;
|
|
209
|
+
if (path.startsWith('http://') || path.startsWith('https://')) {
|
|
210
|
+
fullUrl = path;
|
|
211
|
+
} else {
|
|
212
|
+
const displayPath = path.startsWith('/display/') ? path : `/display/${path.replace(/^\//, '')}`;
|
|
213
|
+
fullUrl = `http://localhost:3403${displayPath}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const url = new URL(fullUrl);
|
|
217
|
+
url.searchParams.set('deviceId', identity.deviceId);
|
|
218
|
+
url.searchParams.set('apiKey', identity.apiKey);
|
|
219
|
+
if (screenIndex !== undefined) {
|
|
220
|
+
url.searchParams.set('screenIndex', String(screenIndex));
|
|
221
|
+
}
|
|
222
|
+
|
|
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;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.killInstance(existing);
|
|
236
|
+
const fullUrl = this.buildUrl(url, identity, existing.screenIndex);
|
|
237
|
+
await this.launchOnScreen(existing.hardwareId, fullUrl, existing.screen, identity, existing.mappingId, url, existing.screenIndex);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Kill Chrome for a specific screen instance */
|
|
241
|
+
private killInstance(instance: ScreenInstance): void {
|
|
242
|
+
if (!instance.process) return;
|
|
243
|
+
|
|
244
|
+
instance.process.removeAllListeners();
|
|
245
|
+
try {
|
|
246
|
+
instance.process.kill('SIGTERM');
|
|
247
|
+
} catch {
|
|
248
|
+
// Already dead.
|
|
249
|
+
}
|
|
250
|
+
instance.process = null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Find a detected screen by display number or full hardware ID */
|
|
254
|
+
private findScreen(id: string): DetectedScreen | undefined {
|
|
255
|
+
return resolveDetectedScreen(id, this.detectedScreens);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 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
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
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
|
+
}
|
|
280
|
+
|
|
281
|
+
await this.killAll({ clearDesired: false });
|
|
282
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
283
|
+
return this.applyScreenMap(mappings, identity);
|
|
284
|
+
}
|
|
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
|
+
|
|
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({
|
|
305
|
+
hardwareId: instance.hardwareId,
|
|
306
|
+
url: instance.url,
|
|
307
|
+
running,
|
|
308
|
+
pid: running && instance.process ? instance.process.pid ?? null : null,
|
|
309
|
+
uptimeMs: running && instance.startedAt ? Date.now() - instance.startedAt : null,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { screens };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Check if any screens are actively managed */
|
|
317
|
+
isActive(): boolean {
|
|
318
|
+
return this.instances.size > 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Cleanup on agent shutdown */
|
|
322
|
+
destroy(): void {
|
|
323
|
+
this.stopPoll();
|
|
324
|
+
for (const [, instance] of this.instances) {
|
|
325
|
+
if (!instance.process) continue;
|
|
326
|
+
try {
|
|
327
|
+
instance.process.removeAllListeners();
|
|
328
|
+
instance.process.kill('SIGKILL');
|
|
329
|
+
} catch {
|
|
330
|
+
// Already dead.
|
|
331
|
+
}
|
|
332
|
+
instance.process = null;
|
|
333
|
+
}
|
|
334
|
+
this.instances.clear();
|
|
335
|
+
this.desiredScreenMap = [];
|
|
336
|
+
this.desiredIdentity = null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private startPoll(): void {
|
|
340
|
+
if (this.pollTimer) return;
|
|
341
|
+
|
|
342
|
+
this.pollTimer = setInterval(() => {
|
|
343
|
+
for (const [, instance] of this.instances) {
|
|
344
|
+
if (instance.process && instance.process.exitCode !== null) {
|
|
345
|
+
this.logger.warn(`[MultiKiosk] Poll: Chrome on ${instance.hardwareId} died`);
|
|
346
|
+
// Exit handler will auto-restart.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}, this.config.pollIntervalMs);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private stopPoll(): void {
|
|
353
|
+
if (!this.pollTimer) return;
|
|
354
|
+
clearInterval(this.pollTimer);
|
|
355
|
+
this.pollTimer = null;
|
|
356
|
+
}
|
|
357
357
|
}
|