lightman-agent 1.0.5 → 1.0.7

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,174 +1,174 @@
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
-
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
172
  private async shellLaunch(targetUrl: string): Promise<KioskStatus> {
173
173
  this.currentUrl = targetUrl;
174
174
  // Keep shell sidecar updated so shell BAT can launch with auth query params.
@@ -182,216 +182,216 @@ export class KioskManager {
182
182
  } else {
183
183
  this.logger.info('Shell mode: Chrome not running. Shell BAT will launch it.');
184
184
  }
185
-
186
- this.startedAt = this.startedAt || Date.now();
187
- return this.getStatus();
188
- }
189
-
190
- private getShellModeStatus(): KioskStatus {
191
- const running = this.isChromeRunning();
192
- return {
193
- running,
194
- pid: running ? this.getChromePid() : null,
195
- url: this.currentUrl || this.readUrlSidecar(),
196
- crashCount: 0, // Shell handles crash recovery, not us
197
- crashLoopDetected: false,
198
- uptimeMs: running && this.startedAt ? Date.now() - this.startedAt : null,
199
- };
200
- }
201
-
202
- /** Write the target URL to the sidecar file that the shell BAT reads */
203
- private writeUrlSidecar(url: string): void {
204
- try {
205
- writeFileSync(URL_SIDECAR_FILE, url, 'utf-8');
206
- } catch (err) {
207
- this.logger.error('Failed to write URL sidecar:', err instanceof Error ? err.message : String(err));
208
- }
209
- }
210
-
211
- /** Read the current URL from sidecar file */
212
- private readUrlSidecar(): string | null {
213
- try {
214
- if (existsSync(URL_SIDECAR_FILE)) {
215
- return readFileSync(URL_SIDECAR_FILE, 'utf-8').trim();
216
- }
217
- } catch {
218
- // Best effort
219
- }
220
- return null;
221
- }
222
-
223
- /** Check if any chrome.exe process is running */
224
- private isChromeRunning(): boolean {
225
- try {
226
- if (process.platform === 'win32') {
227
- const result = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /NH', {
228
- encoding: 'utf-8',
229
- timeout: 5_000,
230
- stdio: ['pipe', 'pipe', 'ignore'],
231
- });
232
- return result.toLowerCase().includes('chrome.exe');
233
- } else {
234
- execSync('pgrep -x chrome || pgrep -x chromium-browser', {
235
- stdio: 'ignore',
236
- timeout: 5_000,
237
- });
238
- return true;
239
- }
240
- } catch {
241
- return false;
242
- }
243
- }
244
-
245
- /** Get PID of main Chrome process */
246
- private getChromePid(): number | null {
247
- try {
248
- if (process.platform === 'win32') {
249
- const result = execSync(
250
- 'wmic process where "name=\'chrome.exe\' and CommandLine like \'%--kiosk%\'" get ProcessId /format:value',
251
- { encoding: 'utf-8', timeout: 5_000, stdio: ['pipe', 'pipe', 'ignore'] }
252
- );
253
- const match = result.match(/ProcessId=(\d+)/);
254
- return match ? parseInt(match[1], 10) : null;
255
- }
256
- } catch {
257
- // Best effort
258
- }
259
- return null;
260
- }
261
-
262
- // =====================================================================
263
- // Standard Mode Methods (original behavior)
264
- // =====================================================================
265
-
266
- private async standardLaunch(targetUrl: string): Promise<KioskStatus> {
267
- // Kill existing process if running
268
- if (this.process) {
269
- await this.kill();
270
- }
271
-
272
- // Kill any leftover Chrome kiosk instances
273
- this.killAllChrome();
274
-
275
- // Delay to let Chrome fully release profile lock
276
- await new Promise((r) => setTimeout(r, 2_000));
277
-
278
- const args = [
279
- '--kiosk',
280
- '--noerrdialogs',
281
- '--disable-infobars',
282
- '--disable-session-crashed-bubble',
283
- '--no-first-run',
284
- '--no-default-browser-check',
285
- ...this.config.extraArgs,
286
- targetUrl,
287
- ];
288
-
289
- this.logger.info(`Launching kiosk: ${this.config.browserPath} → ${targetUrl}`);
290
-
291
- this.process = spawn(this.config.browserPath, args, {
292
- stdio: 'ignore',
293
- detached: true,
294
- });
295
-
296
- // Unref so the agent process isn't held open by Chrome
297
- this.process.unref();
298
-
299
- this.currentUrl = targetUrl;
300
- this.startedAt = Date.now();
301
- this.crashLoopDetected = false;
302
-
303
- this.process.on('exit', (code) => {
304
- this.handleCrash(code);
305
- });
306
-
307
- this.process.on('error', (err) => {
308
- this.logger.error('Kiosk process error:', err.message);
309
- this.process = null;
310
- this.handleCrash(1);
311
- });
312
-
313
- this.startPoll();
314
-
315
- return this.getStatus();
316
- }
317
-
318
- private killAllChrome(): void {
319
- try {
320
- if (process.platform === 'win32') {
321
- try {
322
- execSync('taskkill /IM chrome.exe /F', { stdio: 'ignore', timeout: 5_000 });
323
- this.logger.info('Killed Chrome instances');
324
- } catch {
325
- // No Chrome running, that's fine
326
- }
327
- } else {
328
- try {
329
- execSync('pkill -f chromium-browser || pkill -f chrome', { stdio: 'ignore', timeout: 5_000 });
330
- } catch {
331
- // No browser running
332
- }
333
- }
334
- } catch {
335
- // Best effort
336
- }
337
- }
338
-
339
- private handleCrash(code: number | null): void {
340
- // If process was intentionally killed (process set to null), skip
341
- if (this.process === null) {
342
- return;
343
- }
344
-
345
- this.process = null;
346
- this.stopPoll();
347
-
348
- // If restart() is in progress, skip auto-restart — restart() handles it
349
- if (this.restarting) {
350
- this.restarting = false;
351
- this.logger.info(`Kiosk exited with code ${code} during restart, deferring to restart()`);
352
- return;
353
- }
354
-
355
- const now = Date.now();
356
- const windowStart = now - this.config.crashWindowMs;
357
- this.crashTimestamps = [...this.crashTimestamps, now].filter((t) => t >= windowStart);
358
-
359
- this.logger.warn(
360
- `Kiosk exited with code ${code}. Crashes in window: ${this.crashTimestamps.length}/${this.config.maxCrashesInWindow}`
361
- );
362
-
363
- if (this.crashTimestamps.length >= this.config.maxCrashesInWindow) {
364
- this.crashLoopDetected = true;
365
- this.logger.error(
366
- `Crash loop detected (${this.crashTimestamps.length} crashes in ${this.config.crashWindowMs}ms). NOT restarting.`
367
- );
368
- return;
369
- }
370
-
371
- // Auto-restart after delay
372
- this.logger.info('Auto-restarting kiosk in 2s...');
373
- setTimeout(() => {
374
- this.launch(this.currentUrl || undefined).catch((err) => {
375
- this.logger.error('Failed to auto-restart kiosk:', err);
376
- });
377
- }, 2_000);
378
- }
379
-
380
- private startPoll(): void {
381
- this.stopPoll();
382
- this.pollTimer = setInterval(() => {
383
- if (this.process && this.process.exitCode !== null) {
384
- // Process died but exit event didn't fire
385
- this.logger.warn('Poll detected kiosk process died');
386
- this.handleCrash(null);
387
- }
388
- }, this.config.pollIntervalMs);
389
- }
390
-
391
- private stopPoll(): void {
392
- if (this.pollTimer) {
393
- clearInterval(this.pollTimer);
394
- this.pollTimer = null;
395
- }
396
- }
397
- }
185
+
186
+ this.startedAt = this.startedAt || Date.now();
187
+ return this.getStatus();
188
+ }
189
+
190
+ private getShellModeStatus(): KioskStatus {
191
+ const running = this.isChromeRunning();
192
+ return {
193
+ running,
194
+ pid: running ? this.getChromePid() : null,
195
+ url: this.currentUrl || this.readUrlSidecar(),
196
+ crashCount: 0, // Shell handles crash recovery, not us
197
+ crashLoopDetected: false,
198
+ uptimeMs: running && this.startedAt ? Date.now() - this.startedAt : null,
199
+ };
200
+ }
201
+
202
+ /** Write the target URL to the sidecar file that the shell BAT reads */
203
+ private writeUrlSidecar(url: string): void {
204
+ try {
205
+ writeFileSync(URL_SIDECAR_FILE, url, 'utf-8');
206
+ } catch (err) {
207
+ this.logger.error('Failed to write URL sidecar:', err instanceof Error ? err.message : String(err));
208
+ }
209
+ }
210
+
211
+ /** Read the current URL from sidecar file */
212
+ private readUrlSidecar(): string | null {
213
+ try {
214
+ if (existsSync(URL_SIDECAR_FILE)) {
215
+ return readFileSync(URL_SIDECAR_FILE, 'utf-8').trim();
216
+ }
217
+ } catch {
218
+ // Best effort
219
+ }
220
+ return null;
221
+ }
222
+
223
+ /** Check if any chrome.exe process is running */
224
+ private isChromeRunning(): boolean {
225
+ try {
226
+ if (process.platform === 'win32') {
227
+ const result = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /NH', {
228
+ encoding: 'utf-8',
229
+ timeout: 5_000,
230
+ stdio: ['pipe', 'pipe', 'ignore'],
231
+ });
232
+ return result.toLowerCase().includes('chrome.exe');
233
+ } else {
234
+ execSync('pgrep -x chrome || pgrep -x chromium-browser', {
235
+ stdio: 'ignore',
236
+ timeout: 5_000,
237
+ });
238
+ return true;
239
+ }
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+
245
+ /** Get PID of main Chrome process */
246
+ private getChromePid(): number | null {
247
+ try {
248
+ if (process.platform === 'win32') {
249
+ const result = execSync(
250
+ 'wmic process where "name=\'chrome.exe\' and CommandLine like \'%--kiosk%\'" get ProcessId /format:value',
251
+ { encoding: 'utf-8', timeout: 5_000, stdio: ['pipe', 'pipe', 'ignore'] }
252
+ );
253
+ const match = result.match(/ProcessId=(\d+)/);
254
+ return match ? parseInt(match[1], 10) : null;
255
+ }
256
+ } catch {
257
+ // Best effort
258
+ }
259
+ return null;
260
+ }
261
+
262
+ // =====================================================================
263
+ // Standard Mode Methods (original behavior)
264
+ // =====================================================================
265
+
266
+ private async standardLaunch(targetUrl: string): Promise<KioskStatus> {
267
+ // Kill existing process if running
268
+ if (this.process) {
269
+ await this.kill();
270
+ }
271
+
272
+ // Kill any leftover Chrome kiosk instances
273
+ this.killAllChrome();
274
+
275
+ // Delay to let Chrome fully release profile lock
276
+ await new Promise((r) => setTimeout(r, 2_000));
277
+
278
+ const args = [
279
+ '--kiosk',
280
+ '--noerrdialogs',
281
+ '--disable-infobars',
282
+ '--disable-session-crashed-bubble',
283
+ '--no-first-run',
284
+ '--no-default-browser-check',
285
+ ...this.config.extraArgs,
286
+ targetUrl,
287
+ ];
288
+
289
+ this.logger.info(`Launching kiosk: ${this.config.browserPath} → ${targetUrl}`);
290
+
291
+ this.process = spawn(this.config.browserPath, args, {
292
+ stdio: 'ignore',
293
+ detached: true,
294
+ });
295
+
296
+ // Unref so the agent process isn't held open by Chrome
297
+ this.process.unref();
298
+
299
+ this.currentUrl = targetUrl;
300
+ this.startedAt = Date.now();
301
+ this.crashLoopDetected = false;
302
+
303
+ this.process.on('exit', (code) => {
304
+ this.handleCrash(code);
305
+ });
306
+
307
+ this.process.on('error', (err) => {
308
+ this.logger.error('Kiosk process error:', err.message);
309
+ this.process = null;
310
+ this.handleCrash(1);
311
+ });
312
+
313
+ this.startPoll();
314
+
315
+ return this.getStatus();
316
+ }
317
+
318
+ private killAllChrome(): void {
319
+ try {
320
+ if (process.platform === 'win32') {
321
+ try {
322
+ execSync('taskkill /IM chrome.exe /F', { stdio: 'ignore', timeout: 5_000 });
323
+ this.logger.info('Killed Chrome instances');
324
+ } catch {
325
+ // No Chrome running, that's fine
326
+ }
327
+ } else {
328
+ try {
329
+ execSync('pkill -f chromium-browser || pkill -f chrome', { stdio: 'ignore', timeout: 5_000 });
330
+ } catch {
331
+ // No browser running
332
+ }
333
+ }
334
+ } catch {
335
+ // Best effort
336
+ }
337
+ }
338
+
339
+ private handleCrash(code: number | null): void {
340
+ // If process was intentionally killed (process set to null), skip
341
+ if (this.process === null) {
342
+ return;
343
+ }
344
+
345
+ this.process = null;
346
+ this.stopPoll();
347
+
348
+ // If restart() is in progress, skip auto-restart — restart() handles it
349
+ if (this.restarting) {
350
+ this.restarting = false;
351
+ this.logger.info(`Kiosk exited with code ${code} during restart, deferring to restart()`);
352
+ return;
353
+ }
354
+
355
+ const now = Date.now();
356
+ const windowStart = now - this.config.crashWindowMs;
357
+ this.crashTimestamps = [...this.crashTimestamps, now].filter((t) => t >= windowStart);
358
+
359
+ this.logger.warn(
360
+ `Kiosk exited with code ${code}. Crashes in window: ${this.crashTimestamps.length}/${this.config.maxCrashesInWindow}`
361
+ );
362
+
363
+ if (this.crashTimestamps.length >= this.config.maxCrashesInWindow) {
364
+ this.crashLoopDetected = true;
365
+ this.logger.error(
366
+ `Crash loop detected (${this.crashTimestamps.length} crashes in ${this.config.crashWindowMs}ms). NOT restarting.`
367
+ );
368
+ return;
369
+ }
370
+
371
+ // Auto-restart after delay
372
+ this.logger.info('Auto-restarting kiosk in 2s...');
373
+ setTimeout(() => {
374
+ this.launch(this.currentUrl || undefined).catch((err) => {
375
+ this.logger.error('Failed to auto-restart kiosk:', err);
376
+ });
377
+ }, 2_000);
378
+ }
379
+
380
+ private startPoll(): void {
381
+ this.stopPoll();
382
+ this.pollTimer = setInterval(() => {
383
+ if (this.process && this.process.exitCode !== null) {
384
+ // Process died but exit event didn't fire
385
+ this.logger.warn('Poll detected kiosk process died');
386
+ this.handleCrash(null);
387
+ }
388
+ }, this.config.pollIntervalMs);
389
+ }
390
+
391
+ private stopPoll(): void {
392
+ if (this.pollTimer) {
393
+ clearInterval(this.pollTimer);
394
+ this.pollTimer = null;
395
+ }
396
+ }
397
+ }