lightman-agent 1.0.0

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 (54) hide show
  1. package/agent.config.template.json +30 -0
  2. package/bin/cms-agent.js +233 -0
  3. package/nssm/nssm.exe +0 -0
  4. package/package.json +52 -0
  5. package/public/assets/index-CcBNCz6h.css +1 -0
  6. package/public/assets/index-H-8HDl46.js +1 -0
  7. package/public/index.html +19 -0
  8. package/scripts/guardian.ps1 +75 -0
  9. package/scripts/install-linux.sh +134 -0
  10. package/scripts/install-rpi.sh +117 -0
  11. package/scripts/install-windows.ps1 +529 -0
  12. package/scripts/launch-kiosk.vbs +101 -0
  13. package/scripts/lightman-agent.logrotate +12 -0
  14. package/scripts/lightman-agent.service +38 -0
  15. package/scripts/lightman-shell.bat +128 -0
  16. package/scripts/reinstall-windows.ps1 +26 -0
  17. package/scripts/restore-desktop.ps1 +32 -0
  18. package/scripts/setup.ps1 +116 -0
  19. package/scripts/setup.sh +115 -0
  20. package/scripts/uninstall-linux.sh +50 -0
  21. package/scripts/uninstall-windows.ps1 +54 -0
  22. package/src/commands/display.ts +177 -0
  23. package/src/commands/kiosk.ts +113 -0
  24. package/src/commands/maintenance.ts +106 -0
  25. package/src/commands/network.ts +129 -0
  26. package/src/commands/power.ts +163 -0
  27. package/src/commands/rpi.ts +45 -0
  28. package/src/commands/screenshot.ts +166 -0
  29. package/src/commands/serial.ts +17 -0
  30. package/src/commands/update.ts +124 -0
  31. package/src/index.ts +652 -0
  32. package/src/lib/config.ts +69 -0
  33. package/src/lib/identity.ts +40 -0
  34. package/src/lib/logger.ts +137 -0
  35. package/src/lib/platform.ts +10 -0
  36. package/src/lib/rpi.ts +180 -0
  37. package/src/lib/screens.ts +128 -0
  38. package/src/lib/types.ts +176 -0
  39. package/src/services/commands.ts +107 -0
  40. package/src/services/health.ts +161 -0
  41. package/src/services/kiosk.ts +395 -0
  42. package/src/services/localEvents.ts +60 -0
  43. package/src/services/logForwarder.ts +72 -0
  44. package/src/services/multiScreenKiosk.ts +324 -0
  45. package/src/services/oscBridge.ts +186 -0
  46. package/src/services/powerScheduler.ts +260 -0
  47. package/src/services/provisioning.ts +120 -0
  48. package/src/services/serialBridge.ts +230 -0
  49. package/src/services/serviceLauncher.ts +183 -0
  50. package/src/services/staticServer.ts +226 -0
  51. package/src/services/updater.ts +249 -0
  52. package/src/services/watchdog.ts +310 -0
  53. package/src/services/websocket.ts +152 -0
  54. package/tsconfig.json +28 -0
@@ -0,0 +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
+ }
@@ -0,0 +1,186 @@
1
+ import { createSocket, type Socket } from 'dgram';
2
+ import type { WsClient } from './websocket.js';
3
+ import type { Logger } from '../lib/logger.js';
4
+
5
+ /**
6
+ * OscBridge — listens for OSC messages over UDP and converts them to
7
+ * hardware events, mirroring the SerialBridge pattern.
8
+ *
9
+ * When the configured OSC address is received with arg === 1, it emits
10
+ * an `osc:trigger` event via the local event broadcaster and the server WS.
11
+ *
12
+ * Uses Node.js built-in `dgram` — no external dependencies.
13
+ *
14
+ * OSC wire format (minimal parser):
15
+ * - Address: null-terminated string padded to 4-byte boundary
16
+ * - Type tag: "," + type chars, null-terminated padded to 4-byte boundary
17
+ * - Arguments: int32 (big-endian) for 'i', float32 for 'f'
18
+ */
19
+ export class OscBridge {
20
+ private wsClient: WsClient;
21
+ private logger: Logger;
22
+ private port: number;
23
+ private host: string;
24
+ private address: string;
25
+ private running = false;
26
+ private socket: Socket | null = null;
27
+ private onEvent?: (event: Record<string, unknown>) => void;
28
+
29
+ constructor(opts: {
30
+ wsClient: WsClient;
31
+ logger: Logger;
32
+ port: number;
33
+ host?: string;
34
+ address: string;
35
+ onEvent?: (event: Record<string, unknown>) => void;
36
+ }) {
37
+ this.wsClient = opts.wsClient;
38
+ this.logger = opts.logger;
39
+ this.port = opts.port;
40
+ this.host = opts.host || '0.0.0.0';
41
+ this.address = opts.address;
42
+ this.onEvent = opts.onEvent;
43
+ }
44
+
45
+ start(): void {
46
+ if (this.running) return;
47
+ this.running = true;
48
+
49
+ this.logger.info(`[OSC] Starting bridge — listening on UDP ${this.host}:${this.port} for address "${this.address}"`);
50
+
51
+ this.socket = createSocket('udp4');
52
+
53
+ this.socket.on('message', (msg: Buffer) => {
54
+ try {
55
+ const parsed = parseOscMessage(msg);
56
+ if (!parsed) return;
57
+
58
+ this.logger.debug(`[OSC] Received: ${parsed.address} args=${JSON.stringify(parsed.args)}`);
59
+
60
+ // Match address
61
+ if (parsed.address === this.address) {
62
+ const firstArg = parsed.args[0];
63
+ // Trigger on arg === 1 (int or float)
64
+ if (firstArg === 1 || firstArg === 1.0) {
65
+ this.emitTrigger();
66
+ }
67
+ }
68
+ } catch (err) {
69
+ this.logger.debug('[OSC] Failed to parse message:', err);
70
+ }
71
+ });
72
+
73
+ this.socket.on('error', (err) => {
74
+ this.logger.error('[OSC] Socket error:', err);
75
+ if (this.running) {
76
+ this.socket?.close();
77
+ this.socket = null;
78
+ this.logger.info('[OSC] Restarting in 3s...');
79
+ setTimeout(() => {
80
+ if (this.running) this.start();
81
+ }, 3000);
82
+ }
83
+ });
84
+
85
+ this.socket.bind(this.port, this.host, () => {
86
+ this.logger.info(`[OSC] Bridge listening on UDP ${this.host}:${this.port}`);
87
+ });
88
+ }
89
+
90
+ stop(): void {
91
+ this.running = false;
92
+ if (this.socket) {
93
+ try { this.socket.close(); } catch { /* ignore */ }
94
+ this.socket = null;
95
+ }
96
+ this.logger.info('[OSC] Bridge stopped');
97
+ }
98
+
99
+ isRunning(): boolean {
100
+ return this.running;
101
+ }
102
+
103
+ private emitTrigger(): void {
104
+ const event: Record<string, unknown> = {
105
+ type: 'osc:trigger',
106
+ address: this.address,
107
+ timestamp: Date.now(),
108
+ };
109
+
110
+ this.logger.info(`[OSC] Trigger event: ${this.address}`);
111
+
112
+ // Send to server
113
+ this.wsClient.send({
114
+ type: 'osc-bridge:event',
115
+ payload: event,
116
+ timestamp: Date.now(),
117
+ });
118
+
119
+ // Broadcast locally to Chrome display
120
+ this.onEvent?.(event);
121
+ }
122
+ }
123
+
124
+ // ==========================================
125
+ // Minimal OSC message parser
126
+ // ==========================================
127
+
128
+ interface OscMessage {
129
+ address: string;
130
+ args: (number | string)[];
131
+ }
132
+
133
+ function parseOscMessage(buf: Buffer): OscMessage | null {
134
+ let offset = 0;
135
+
136
+ // Read address string
137
+ const address = readOscString(buf, offset);
138
+ if (!address.value || address.value[0] !== '/') return null;
139
+ offset = address.next;
140
+
141
+ // Read type tag string
142
+ const typeTags = readOscString(buf, offset);
143
+ offset = typeTags.next;
144
+
145
+ const tags = typeTags.value || '';
146
+ // Type tag starts with ','
147
+ const types = tags.startsWith(',') ? tags.slice(1) : tags;
148
+
149
+ // Read arguments
150
+ const args: (number | string)[] = [];
151
+ for (const t of types) {
152
+ if (offset >= buf.length) break;
153
+
154
+ switch (t) {
155
+ case 'i': // int32
156
+ args.push(buf.readInt32BE(offset));
157
+ offset += 4;
158
+ break;
159
+ case 'f': // float32
160
+ args.push(buf.readFloatBE(offset));
161
+ offset += 4;
162
+ break;
163
+ case 's': { // string
164
+ const s = readOscString(buf, offset);
165
+ args.push(s.value);
166
+ offset = s.next;
167
+ break;
168
+ }
169
+ default:
170
+ // Unknown type, stop parsing
171
+ return { address: address.value, args };
172
+ }
173
+ }
174
+
175
+ return { address: address.value, args };
176
+ }
177
+
178
+ function readOscString(buf: Buffer, offset: number): { value: string; next: number } {
179
+ let end = offset;
180
+ while (end < buf.length && buf[end] !== 0) end++;
181
+ const value = buf.toString('utf-8', offset, end);
182
+ // OSC strings are padded to 4-byte boundary (including null terminator)
183
+ const padded = end + 1;
184
+ const next = padded + ((4 - (padded % 4)) % 4);
185
+ return { value, next };
186
+ }