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,395 @@
1
+ import { spawn, execSync } from 'child_process';
2
+ import { writeFileSync, readFileSync, existsSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import type { ChildProcess } from 'child_process';
5
+ import type { KioskConfig, KioskStatus } from '../lib/types.js';
6
+ import type { Logger } from '../lib/logger.js';
7
+
8
+ /**
9
+ * URL sidecar file — used in shell mode so the shell BAT reads the current
10
+ * target URL before launching Chrome. Agent writes, shell reads.
11
+ */
12
+ const URL_SIDECAR_FILE = process.platform === 'win32'
13
+ ? 'C:\\ProgramData\\Lightman\\kiosk-url.txt'
14
+ : '/tmp/lightman-kiosk-url.txt';
15
+
16
+ export class KioskManager {
17
+ private config: KioskConfig;
18
+ private logger: Logger;
19
+ private shellMode: boolean;
20
+ private process: ChildProcess | null = null;
21
+ private currentUrl: string | null = null;
22
+ private startedAt: number | null = null;
23
+ private crashTimestamps: number[] = [];
24
+ private crashLoopDetected = false;
25
+ private restarting = false;
26
+ private pollTimer: NodeJS.Timeout | null = null;
27
+
28
+ constructor(config: KioskConfig, logger: Logger) {
29
+ this.config = config;
30
+ this.logger = logger;
31
+ this.shellMode = config.shellMode ?? false;
32
+
33
+ if (this.shellMode) {
34
+ this.logger.info('KioskManager: shell mode enabled - Chrome lifecycle managed by Windows shell');
35
+ // Shell BAT reads slug directly from agent.config.json - no sidecar needed
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Launch the kiosk browser.
41
+ *
42
+ * Standard mode: spawns Chrome as a child process.
43
+ * Shell mode: writes URL to sidecar file. If Chrome isn't running, kills
44
+ * any stale instance (the shell's infinite loop will relaunch it).
45
+ * If Chrome IS running, kills it so the shell relaunches with new URL.
46
+ */
47
+ async launch(url?: string): Promise<KioskStatus> {
48
+ const targetUrl = url || this.config.defaultUrl;
49
+ this.currentUrl = targetUrl;
50
+
51
+ if (this.shellMode) {
52
+ return this.shellLaunch(targetUrl);
53
+ }
54
+ return this.standardLaunch(targetUrl);
55
+ }
56
+
57
+ async kill(): Promise<void> {
58
+ if (this.shellMode) {
59
+ // In shell mode, we just kill Chrome — the shell BAT will relaunch it
60
+ this.killAllChrome();
61
+ return;
62
+ }
63
+
64
+ this.stopPoll();
65
+ if (!this.process) {
66
+ return;
67
+ }
68
+
69
+ const proc = this.process;
70
+ this.process = null;
71
+
72
+ return new Promise<void>((resolve) => {
73
+ let killed = false;
74
+
75
+ const onExit = () => {
76
+ if (killed) return;
77
+ killed = true;
78
+ clearTimeout(forceKillTimer);
79
+ resolve();
80
+ };
81
+
82
+ const forceKillTimer = setTimeout(() => {
83
+ if (!killed) {
84
+ this.logger.warn('Kiosk did not exit after SIGTERM, sending SIGKILL');
85
+ try {
86
+ proc.kill('SIGKILL');
87
+ } catch {
88
+ // Process may already be dead
89
+ }
90
+ }
91
+ }, 5_000);
92
+
93
+ // Remove the crash handler so kill doesn't trigger auto-restart
94
+ proc.removeAllListeners('exit');
95
+ proc.once('exit', onExit);
96
+
97
+ try {
98
+ proc.kill('SIGTERM');
99
+ } catch {
100
+ // Process already dead
101
+ onExit();
102
+ }
103
+ });
104
+ }
105
+
106
+ async navigate(url: string): Promise<void> {
107
+ this.logger.info(`Navigating kiosk to: ${url}`);
108
+
109
+ if (this.shellMode) {
110
+ // Write new URL → kill Chrome → shell relaunches with new URL
111
+ this.writeUrlSidecar(url);
112
+ this.currentUrl = url;
113
+ this.killAllChrome();
114
+ return;
115
+ }
116
+
117
+ await this.kill();
118
+ await this.launch(url);
119
+ }
120
+
121
+ async restart(): Promise<KioskStatus> {
122
+ this.logger.info('Restarting kiosk');
123
+
124
+ if (this.shellMode) {
125
+ // Just kill Chrome — shell relaunches it with same URL from sidecar
126
+ this.killAllChrome();
127
+ // Give shell time to relaunch
128
+ await new Promise((r) => setTimeout(r, 5_000));
129
+ return this.getStatus();
130
+ }
131
+
132
+ this.restarting = true;
133
+ const url = this.currentUrl;
134
+ await this.kill();
135
+ return this.launch(url || undefined);
136
+ }
137
+
138
+ getStatus(): KioskStatus {
139
+ if (this.shellMode) {
140
+ return this.getShellModeStatus();
141
+ }
142
+
143
+ const running = this.process !== null && this.process.exitCode === null;
144
+ return {
145
+ running,
146
+ pid: running && this.process ? this.process.pid ?? null : null,
147
+ url: this.currentUrl,
148
+ crashCount: this.crashTimestamps.length,
149
+ crashLoopDetected: this.crashLoopDetected,
150
+ uptimeMs: running && this.startedAt ? Date.now() - this.startedAt : null,
151
+ };
152
+ }
153
+
154
+ destroy(): void {
155
+ this.stopPoll();
156
+ if (this.process) {
157
+ try {
158
+ this.process.removeAllListeners();
159
+ this.process.kill('SIGKILL');
160
+ } catch {
161
+ // Process may already be dead
162
+ }
163
+ this.process = null;
164
+ }
165
+ // In shell mode, do NOT kill Chrome on agent shutdown — the shell keeps it alive
166
+ }
167
+
168
+ // =====================================================================
169
+ // Shell Mode Methods
170
+ // =====================================================================
171
+
172
+ private async shellLaunch(targetUrl: string): Promise<KioskStatus> {
173
+ this.currentUrl = targetUrl;
174
+
175
+ // Shell mode: Chrome is managed by lightman-shell.bat.
176
+ // Shell reads slug from agent.config.json directly.
177
+ // Agent NEVER kills Chrome on startup - only on explicit navigate().
178
+ if (this.isChromeRunning()) {
179
+ this.logger.info('Shell mode: Chrome already running. Not touching it.');
180
+ } else {
181
+ this.logger.info('Shell mode: Chrome not running. Shell BAT will launch it.');
182
+ }
183
+
184
+ this.startedAt = this.startedAt || Date.now();
185
+ return this.getStatus();
186
+ }
187
+
188
+ private getShellModeStatus(): KioskStatus {
189
+ const running = this.isChromeRunning();
190
+ return {
191
+ running,
192
+ pid: running ? this.getChromePid() : null,
193
+ url: this.currentUrl || this.readUrlSidecar(),
194
+ crashCount: 0, // Shell handles crash recovery, not us
195
+ crashLoopDetected: false,
196
+ uptimeMs: running && this.startedAt ? Date.now() - this.startedAt : null,
197
+ };
198
+ }
199
+
200
+ /** Write the target URL to the sidecar file that the shell BAT reads */
201
+ private writeUrlSidecar(url: string): void {
202
+ try {
203
+ writeFileSync(URL_SIDECAR_FILE, url, 'utf-8');
204
+ } catch (err) {
205
+ this.logger.error('Failed to write URL sidecar:', err instanceof Error ? err.message : String(err));
206
+ }
207
+ }
208
+
209
+ /** Read the current URL from sidecar file */
210
+ private readUrlSidecar(): string | null {
211
+ try {
212
+ if (existsSync(URL_SIDECAR_FILE)) {
213
+ return readFileSync(URL_SIDECAR_FILE, 'utf-8').trim();
214
+ }
215
+ } catch {
216
+ // Best effort
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /** Check if any chrome.exe process is running */
222
+ private isChromeRunning(): boolean {
223
+ try {
224
+ if (process.platform === 'win32') {
225
+ const result = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /NH', {
226
+ encoding: 'utf-8',
227
+ timeout: 5_000,
228
+ stdio: ['pipe', 'pipe', 'ignore'],
229
+ });
230
+ return result.toLowerCase().includes('chrome.exe');
231
+ } else {
232
+ execSync('pgrep -x chrome || pgrep -x chromium-browser', {
233
+ stdio: 'ignore',
234
+ timeout: 5_000,
235
+ });
236
+ return true;
237
+ }
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ /** Get PID of main Chrome process */
244
+ private getChromePid(): number | null {
245
+ try {
246
+ if (process.platform === 'win32') {
247
+ const result = execSync(
248
+ 'wmic process where "name=\'chrome.exe\' and CommandLine like \'%--kiosk%\'" get ProcessId /format:value',
249
+ { encoding: 'utf-8', timeout: 5_000, stdio: ['pipe', 'pipe', 'ignore'] }
250
+ );
251
+ const match = result.match(/ProcessId=(\d+)/);
252
+ return match ? parseInt(match[1], 10) : null;
253
+ }
254
+ } catch {
255
+ // Best effort
256
+ }
257
+ return null;
258
+ }
259
+
260
+ // =====================================================================
261
+ // Standard Mode Methods (original behavior)
262
+ // =====================================================================
263
+
264
+ private async standardLaunch(targetUrl: string): Promise<KioskStatus> {
265
+ // Kill existing process if running
266
+ if (this.process) {
267
+ await this.kill();
268
+ }
269
+
270
+ // Kill any leftover Chrome kiosk instances
271
+ this.killAllChrome();
272
+
273
+ // Delay to let Chrome fully release profile lock
274
+ await new Promise((r) => setTimeout(r, 2_000));
275
+
276
+ const args = [
277
+ '--kiosk',
278
+ '--noerrdialogs',
279
+ '--disable-infobars',
280
+ '--disable-session-crashed-bubble',
281
+ '--no-first-run',
282
+ '--no-default-browser-check',
283
+ ...this.config.extraArgs,
284
+ targetUrl,
285
+ ];
286
+
287
+ this.logger.info(`Launching kiosk: ${this.config.browserPath} → ${targetUrl}`);
288
+
289
+ this.process = spawn(this.config.browserPath, args, {
290
+ stdio: 'ignore',
291
+ detached: true,
292
+ });
293
+
294
+ // Unref so the agent process isn't held open by Chrome
295
+ this.process.unref();
296
+
297
+ this.currentUrl = targetUrl;
298
+ this.startedAt = Date.now();
299
+ this.crashLoopDetected = false;
300
+
301
+ this.process.on('exit', (code) => {
302
+ this.handleCrash(code);
303
+ });
304
+
305
+ this.process.on('error', (err) => {
306
+ this.logger.error('Kiosk process error:', err.message);
307
+ this.process = null;
308
+ this.handleCrash(1);
309
+ });
310
+
311
+ this.startPoll();
312
+
313
+ return this.getStatus();
314
+ }
315
+
316
+ private killAllChrome(): void {
317
+ try {
318
+ if (process.platform === 'win32') {
319
+ try {
320
+ execSync('taskkill /IM chrome.exe /F', { stdio: 'ignore', timeout: 5_000 });
321
+ this.logger.info('Killed Chrome instances');
322
+ } catch {
323
+ // No Chrome running, that's fine
324
+ }
325
+ } else {
326
+ try {
327
+ execSync('pkill -f chromium-browser || pkill -f chrome', { stdio: 'ignore', timeout: 5_000 });
328
+ } catch {
329
+ // No browser running
330
+ }
331
+ }
332
+ } catch {
333
+ // Best effort
334
+ }
335
+ }
336
+
337
+ private handleCrash(code: number | null): void {
338
+ // If process was intentionally killed (process set to null), skip
339
+ if (this.process === null) {
340
+ return;
341
+ }
342
+
343
+ this.process = null;
344
+ this.stopPoll();
345
+
346
+ // If restart() is in progress, skip auto-restart — restart() handles it
347
+ if (this.restarting) {
348
+ this.restarting = false;
349
+ this.logger.info(`Kiosk exited with code ${code} during restart, deferring to restart()`);
350
+ return;
351
+ }
352
+
353
+ const now = Date.now();
354
+ const windowStart = now - this.config.crashWindowMs;
355
+ this.crashTimestamps = [...this.crashTimestamps, now].filter((t) => t >= windowStart);
356
+
357
+ this.logger.warn(
358
+ `Kiosk exited with code ${code}. Crashes in window: ${this.crashTimestamps.length}/${this.config.maxCrashesInWindow}`
359
+ );
360
+
361
+ if (this.crashTimestamps.length >= this.config.maxCrashesInWindow) {
362
+ this.crashLoopDetected = true;
363
+ this.logger.error(
364
+ `Crash loop detected (${this.crashTimestamps.length} crashes in ${this.config.crashWindowMs}ms). NOT restarting.`
365
+ );
366
+ return;
367
+ }
368
+
369
+ // Auto-restart after delay
370
+ this.logger.info('Auto-restarting kiosk in 2s...');
371
+ setTimeout(() => {
372
+ this.launch(this.currentUrl || undefined).catch((err) => {
373
+ this.logger.error('Failed to auto-restart kiosk:', err);
374
+ });
375
+ }, 2_000);
376
+ }
377
+
378
+ private startPoll(): void {
379
+ this.stopPoll();
380
+ this.pollTimer = setInterval(() => {
381
+ if (this.process && this.process.exitCode !== null) {
382
+ // Process died but exit event didn't fire
383
+ this.logger.warn('Poll detected kiosk process died');
384
+ this.handleCrash(null);
385
+ }
386
+ }, this.config.pollIntervalMs);
387
+ }
388
+
389
+ private stopPoll(): void {
390
+ if (this.pollTimer) {
391
+ clearInterval(this.pollTimer);
392
+ this.pollTimer = null;
393
+ }
394
+ }
395
+ }
@@ -0,0 +1,60 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ import type { Logger } from '../lib/logger.js';
3
+
4
+ /**
5
+ * Local hardware event broadcaster.
6
+ * Runs a WebSocket server on localhost only so the local Chrome display
7
+ * can receive hardware events directly from the agent — no server round-trip.
8
+ */
9
+ export class LocalEventServer {
10
+ private wss: WebSocketServer | null = null;
11
+ private clients: Set<WebSocket> = new Set();
12
+ private port: number;
13
+ private logger: Logger;
14
+
15
+ constructor(port: number, logger: Logger) {
16
+ this.port = port;
17
+ this.logger = logger;
18
+ }
19
+
20
+ start(): void {
21
+ this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port });
22
+
23
+ this.wss.on('connection', (ws) => {
24
+ this.clients.add(ws);
25
+ this.logger.debug(`[LocalEvents] Display connected (total: ${this.clients.size})`);
26
+
27
+ ws.on('close', () => {
28
+ this.clients.delete(ws);
29
+ this.logger.debug(`[LocalEvents] Display disconnected (total: ${this.clients.size})`);
30
+ });
31
+
32
+ ws.on('error', () => {
33
+ this.clients.delete(ws);
34
+ });
35
+ });
36
+
37
+ this.wss.on('error', (err) => {
38
+ this.logger.error('[LocalEvents] Server error:', err);
39
+ });
40
+
41
+ this.logger.info(`[LocalEvents] Hardware event server listening on ws://127.0.0.1:${this.port}`);
42
+ }
43
+
44
+ broadcast(event: Record<string, unknown>): void {
45
+ if (this.clients.size === 0) return;
46
+ const msg = JSON.stringify(event);
47
+ for (const ws of this.clients) {
48
+ if (ws.readyState === WebSocket.OPEN) {
49
+ ws.send(msg);
50
+ }
51
+ }
52
+ this.logger.debug(`[LocalEvents] Broadcast to ${this.clients.size} client(s): ${event.type}`);
53
+ }
54
+
55
+ stop(): void {
56
+ this.wss?.close();
57
+ this.wss = null;
58
+ this.clients.clear();
59
+ }
60
+ }
@@ -0,0 +1,72 @@
1
+ import type { WsClient } from './websocket.js';
2
+ import type { Logger } from '../lib/logger.js';
3
+ import type { LogEntry, WsMessage } from '../lib/types.js';
4
+
5
+ interface LogForwarderConfig {
6
+ batchIntervalMs: number;
7
+ maxBatchSize: number;
8
+ }
9
+
10
+ const DEFAULT_CONFIG: LogForwarderConfig = {
11
+ batchIntervalMs: 30_000,
12
+ maxBatchSize: 100,
13
+ };
14
+
15
+ export class LogForwarder {
16
+ private wsClient: WsClient;
17
+ private logger: Logger;
18
+ private config: LogForwarderConfig;
19
+ private buffer: LogEntry[] = [];
20
+ private timer: NodeJS.Timeout | null = null;
21
+
22
+ constructor(wsClient: WsClient, logger: Logger, config?: Partial<LogForwarderConfig>) {
23
+ this.wsClient = wsClient;
24
+ this.logger = logger;
25
+ this.config = { ...DEFAULT_CONFIG, ...config };
26
+ }
27
+
28
+ onLog(entry: LogEntry): void {
29
+ this.buffer = [...this.buffer, entry];
30
+ // Flush if buffer exceeds max batch size
31
+ if (this.buffer.length >= this.config.maxBatchSize) {
32
+ this.flush();
33
+ }
34
+ }
35
+
36
+ start(): void {
37
+ if (this.timer !== null) {
38
+ this.logger.warn('Log forwarder already running, ignoring duplicate start()');
39
+ return;
40
+ }
41
+ this.timer = setInterval(() => {
42
+ this.flush();
43
+ }, this.config.batchIntervalMs);
44
+ this.logger.info(`Log forwarder started (interval: ${this.config.batchIntervalMs}ms, maxBatch: ${this.config.maxBatchSize})`);
45
+ }
46
+
47
+ stop(): void {
48
+ if (this.timer) {
49
+ clearInterval(this.timer);
50
+ this.timer = null;
51
+ }
52
+ // Final flush
53
+ this.flush();
54
+ this.logger.info('Log forwarder stopped');
55
+ }
56
+
57
+ flush(): void {
58
+ if (this.buffer.length === 0) {
59
+ return;
60
+ }
61
+
62
+ const entries = this.buffer;
63
+ this.buffer = [];
64
+
65
+ const msg: WsMessage = {
66
+ type: 'agent:logs',
67
+ payload: { entries: entries as unknown as Record<string, unknown>[] } as unknown as Record<string, unknown>,
68
+ timestamp: Date.now(),
69
+ };
70
+ this.wsClient.send(msg);
71
+ }
72
+ }