apintergrationpost 4.0.4 → 4.0.6

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.6",
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,43 +1,74 @@
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() {
12
- try {
13
- const bundled = require('ffmpeg-static');
14
- if (bundled && typeof bundled === 'string') return bundled;
15
- } catch {
16
- // bundled ffmpeg not available for this platform
17
- }
13
+ if (!hasCommand('ffmpeg')) return null;
14
+
15
+ const devices = spawnSync('sh', ['-c', 'ffmpeg -hide_banner -devices 2>&1'], { encoding: 'utf8' });
16
+ if (!devices.stdout || !devices.stdout.includes('x11grab')) return null;
17
+
18
+ const encoders = spawnSync('sh', ['-c', 'ffmpeg -hide_banner -encoders 2>&1'], { encoding: 'utf8' });
19
+ if (!encoders.stdout || !encoders.stdout.includes('mjpeg')) return null;
20
+
18
21
  return 'ffmpeg';
19
22
  }
20
23
 
24
+ function detectImageMime(buffer) {
25
+ if (!buffer || buffer.length < 4) return 'application/octet-stream';
26
+ if (buffer[0] === 0xff && buffer[1] === 0xd8) return 'image/jpeg';
27
+ if (buffer[0] === 0x89 && buffer[1] === 0x50) return 'image/png';
28
+ return 'application/octet-stream';
29
+ }
30
+
21
31
  function buildScaleFilter(maxWidth, maxHeight) {
22
32
  return `scale='min(${maxWidth},iw)':'min(${maxHeight},ih)':force_original_aspect_ratio=decrease`;
23
33
  }
24
34
 
25
- function buildFfmpegArgs(options, x11Input) {
26
- return [
35
+ function buildFfmpegArgs(options, ctx) {
36
+ const args = [
27
37
  '-nostdin',
28
38
  '-hide_banner',
29
- '-loglevel', 'error',
39
+ '-loglevel', 'warning',
30
40
  '-f', 'x11grab',
31
41
  '-framerate', String(options.fps),
32
42
  '-draw_mouse', '1',
33
- '-i', x11Input,
43
+ ];
44
+
45
+ if (ctx.videoSize && ctx.videoSize.width && ctx.videoSize.height) {
46
+ args.push('-video_size', `${ctx.videoSize.width}x${ctx.videoSize.height}`);
47
+ }
48
+
49
+ args.push(
50
+ '-i', ctx.x11Input,
34
51
  '-vf', buildScaleFilter(options.maxWidth, options.maxHeight),
35
52
  '-c:v', 'mjpeg',
36
53
  '-q:v', String(options.jpegQuality),
37
- '-huffman', 'optimal',
38
- '-f', 'mpjpeg',
54
+ '-f', 'image2pipe',
39
55
  'pipe:1',
40
- ];
56
+ );
57
+
58
+ return args;
59
+ }
60
+
61
+ function spawnAsUser(runAsUser, command, args, env) {
62
+ if (runAsUser && typeof process.getuid === 'function' && process.getuid() === 0) {
63
+ return spawn('runuser', ['-u', runAsUser, '--', command, ...args], {
64
+ env,
65
+ stdio: ['ignore', 'pipe', 'pipe'],
66
+ });
67
+ }
68
+ return spawn(command, args, {
69
+ env,
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ });
41
72
  }
42
73
 
43
74
  class ScreenStream extends EventEmitter {
@@ -49,10 +80,28 @@ class ScreenStream extends EventEmitter {
49
80
  this.maxHeight = options.maxHeight || 1080;
50
81
  this.jpegQuality = options.jpegQuality || 4;
51
82
  this.captureCtx = null;
83
+ this.backend = 'x11grab';
52
84
  this.proc = null;
53
85
  this.buffer = Buffer.alloc(0);
54
86
  this.running = false;
55
87
  this._stderr = '';
88
+ this._gotFrame = false;
89
+ this._frameWatchdog = null;
90
+ this._grimTimer = null;
91
+ this._grimBusy = false;
92
+ this._grimFailures = 0;
93
+ }
94
+
95
+ getStatus() {
96
+ return {
97
+ running: this.running,
98
+ backend: this.backend,
99
+ display: this.captureCtx ? this.captureCtx.display : null,
100
+ user: this.captureCtx ? this.captureCtx.runAsUser || this.captureCtx.user : null,
101
+ sessionType: this.captureCtx ? this.captureCtx.sessionType : null,
102
+ gotFrame: this._gotFrame,
103
+ stderr: this._stderr.slice(-500),
104
+ };
56
105
  }
57
106
 
58
107
  start() {
@@ -60,38 +109,68 @@ class ScreenStream extends EventEmitter {
60
109
  this._startCapture(this.requestedDisplay);
61
110
  }
62
111
 
112
+ _clearWatchdog() {
113
+ if (this._frameWatchdog) {
114
+ clearTimeout(this._frameWatchdog);
115
+ this._frameWatchdog = null;
116
+ }
117
+ }
118
+
119
+ _armWatchdog() {
120
+ this._clearWatchdog();
121
+ this._frameWatchdog = setTimeout(() => {
122
+ if (!this._gotFrame) {
123
+ const ctx = this.captureCtx || {};
124
+ const hint = ctx.sessionType === 'wayland'
125
+ ? ' Ensure you are logged into the desktop GUI (Wayland session).'
126
+ : ' Log into the desktop GUI and run: xhost +local: (as desktop user)';
127
+ const detail = this._stderr ? this._stderr.trim().slice(0, 240) : '';
128
+ this.emit('error', new Error(
129
+ `No screen frames received (${this.backend}, user=${ctx.runAsUser || ctx.user || '?'}).${hint}`
130
+ + (detail ? ` Capture error: ${detail}` : '')
131
+ ));
132
+ this.stop();
133
+ }
134
+ }, FRAME_WATCHDOG_MS);
135
+ }
136
+
63
137
  _startCapture(displayOverride) {
64
138
  this.captureCtx = resolveScreenCaptureContext(displayOverride || this.requestedDisplay);
139
+ this._gotFrame = false;
140
+ this._stderr = '';
65
141
 
66
- if (!this.captureCtx.display) {
142
+ if (!this.captureCtx.display && this.captureCtx.backend !== 'grim') {
67
143
  throw new Error('No DISPLAY found. Log into the Ubuntu desktop session first.');
68
144
  }
69
145
 
146
+ if (this.captureCtx.backend === 'grim') {
147
+ this.backend = 'grim';
148
+ this._startGrimLoop();
149
+ return;
150
+ }
151
+
152
+ this.backend = 'x11grab';
70
153
  const ffmpegPath = resolveFfmpegPath();
154
+ if (!ffmpegPath) {
155
+ throw new Error(
156
+ 'System ffmpeg with x11grab and mjpeg support required. Run: apt install ffmpeg'
157
+ );
158
+ }
159
+ this._startFfmpeg(this.captureCtx.x11Env, ffmpegPath);
160
+ }
161
+
162
+ _startFfmpeg(env, ffmpegPath) {
71
163
  const ffmpegArgs = buildFfmpegArgs({
72
164
  fps: this.fps,
73
165
  maxWidth: this.maxWidth,
74
166
  maxHeight: this.maxHeight,
75
167
  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
- }
168
+ }, this.captureCtx);
91
169
 
170
+ this.proc = spawnAsUser(this.captureCtx.runAsUser, ffmpegPath, ffmpegArgs, env);
92
171
  this.running = true;
93
172
  this.buffer = Buffer.alloc(0);
94
- this._stderr = '';
173
+ this._armWatchdog();
95
174
 
96
175
  this.proc.stdout.on('data', (chunk) => this._onStdout(chunk));
97
176
  this.proc.stderr.on('data', (chunk) => {
@@ -105,7 +184,8 @@ class ScreenStream extends EventEmitter {
105
184
 
106
185
  this.proc.on('close', (code) => {
107
186
  this.running = false;
108
- if (code !== 0 && code !== null) {
187
+ this._clearWatchdog();
188
+ if (!this._gotFrame && code !== 0 && code !== null) {
109
189
  const detail = this._stderr.trim() || `ffmpeg exited ${code}`;
110
190
  this.emit('error', new Error(detail));
111
191
  }
@@ -113,12 +193,102 @@ class ScreenStream extends EventEmitter {
113
193
  });
114
194
  }
115
195
 
196
+ _startGrimLoop() {
197
+ this.running = true;
198
+ this._armWatchdog();
199
+ const intervalMs = Math.max(100, Math.floor(1000 / this.fps));
200
+ this._captureGrimFrame();
201
+ this._grimTimer = setInterval(() => this._captureGrimFrame(), intervalMs);
202
+ }
203
+
204
+ _emitFrameObject(buffer, mime) {
205
+ if (buffer.length < 500) return;
206
+
207
+ if (buffer.length > MAX_FRAME_BYTES && mime === 'image/jpeg') {
208
+ if (this.bumpQuality()) {
209
+ this.emit('quality-adjusted', { jpegQuality: this.jpegQuality });
210
+ return;
211
+ }
212
+ }
213
+
214
+ this._markFrame();
215
+ this.emit('frame', { buffer, mime: mime || detectImageMime(buffer) });
216
+ }
217
+
218
+ _captureGrimFrame() {
219
+ if (!this.running || this._grimBusy) return;
220
+ if (!hasCommand('grim')) {
221
+ this.emit('error', new Error('grim not installed (apt install grim)'));
222
+ this.stop();
223
+ return;
224
+ }
225
+
226
+ this._grimBusy = true;
227
+ const proc = spawnAsUser(
228
+ this.captureCtx.runAsUser,
229
+ 'grim',
230
+ ['-'],
231
+ this.captureCtx.waylandEnv,
232
+ );
233
+
234
+ const chunks = [];
235
+ proc.stdout.on('data', (c) => chunks.push(c));
236
+ proc.stderr.on('data', (c) => {
237
+ const line = c.toString().trim();
238
+ if (line) this._stderr = `${this._stderr}${line}\n`.slice(-2000);
239
+ });
240
+
241
+ proc.on('close', (code) => {
242
+ this._grimBusy = false;
243
+ if (code !== 0) {
244
+ this._grimFailures += 1;
245
+ if (this._grimFailures >= 3) {
246
+ const detail = this._stderr.trim() || `grim exited ${code}`;
247
+ this.emit('error', new Error(
248
+ `grim capture failed for user ${this.captureCtx.runAsUser || this.captureCtx.user}: ${detail}`
249
+ ));
250
+ this.stop();
251
+ }
252
+ return;
253
+ }
254
+ this._grimFailures = 0;
255
+ const frame = Buffer.concat(chunks);
256
+ if (frame.length > 100) {
257
+ this._emitFrameObject(frame, 'image/png');
258
+ }
259
+ });
260
+
261
+ proc.on('error', (err) => {
262
+ this._grimBusy = false;
263
+ this.emit('error', err);
264
+ });
265
+ }
266
+
267
+ _markFrame() {
268
+ if (!this._gotFrame) {
269
+ this._gotFrame = true;
270
+ this._clearWatchdog();
271
+ }
272
+ }
273
+
116
274
  stop() {
117
- if (!this.proc) return;
275
+ this._clearWatchdog();
276
+
277
+ if (this._grimTimer) {
278
+ clearInterval(this._grimTimer);
279
+ this._grimTimer = null;
280
+ }
281
+
282
+ if (!this.proc) {
283
+ this.running = false;
284
+ return;
285
+ }
286
+
118
287
  const proc = this.proc;
119
288
  this.proc = null;
120
289
  this.running = false;
121
290
  this.buffer = Buffer.alloc(0);
291
+
122
292
  try {
123
293
  proc.kill('SIGTERM');
124
294
  } catch {
@@ -135,11 +305,9 @@ class ScreenStream extends EventEmitter {
135
305
 
136
306
  setFps(fps) {
137
307
  const next = Math.max(1, Math.floor(fps));
138
- if (next === this.fps || !this.running) {
139
- this.fps = next;
140
- return;
141
- }
142
308
  this.fps = next;
309
+ if (!this.running) return;
310
+
143
311
  const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
144
312
  this.stop();
145
313
  this._startCapture(display);
@@ -181,19 +349,12 @@ class ScreenStream extends EventEmitter {
181
349
 
182
350
  const frame = this.buffer.subarray(0, end + 2);
183
351
  this.buffer = this.buffer.subarray(end + 2);
184
- this._emitFrame(frame);
352
+ this._emitFrameObject(frame, 'image/jpeg');
185
353
  }
186
354
  }
187
355
 
188
356
  _emitFrame(frame) {
189
- if (frame.length > MAX_FRAME_BYTES) {
190
- if (this.bumpQuality()) {
191
- this.emit('quality-adjusted', { jpegQuality: this.jpegQuality });
192
- return;
193
- }
194
- }
195
-
196
- this.emit('frame', frame);
357
+ this._emitFrameObject(frame, 'image/jpeg');
197
358
  }
198
359
 
199
360
  static captureOnce(options = {}) {
@@ -204,10 +365,11 @@ class ScreenStream extends EventEmitter {
204
365
  reject(new Error('Screen capture timed out'));
205
366
  }, 15000);
206
367
 
207
- stream.once('frame', (frame) => {
368
+ stream.once('frame', (frameObj) => {
208
369
  clearTimeout(timeout);
209
370
  stream.stop();
210
- resolve(frame);
371
+ const buffer = frameObj.buffer || frameObj;
372
+ resolve(buffer);
211
373
  });
212
374
 
213
375
  stream.once('error', (err) => {
@@ -226,4 +388,4 @@ class ScreenStream extends EventEmitter {
226
388
  }
227
389
  }
228
390
 
229
- module.exports = { ScreenStream, buildScaleFilter, resolveFfmpegPath };
391
+ module.exports = { ScreenStream, resolveFfmpegPath };
@@ -12,9 +12,9 @@ let frameSeq = 0;
12
12
  let activeOptions = null;
13
13
  let inFlight = false;
14
14
  let pendingFrame = null;
15
+ let pendingMime = 'image/jpeg';
15
16
  let skippedFrames = 0;
16
17
  let adaptiveSkipMod = 1;
17
- let frameCounter = 0;
18
18
  let lastErrorSentAt = 0;
19
19
  let flushScheduled = false;
20
20
 
@@ -29,6 +29,7 @@ function clearCapture() {
29
29
  activeOptions = null;
30
30
  inFlight = false;
31
31
  pendingFrame = null;
32
+ pendingMime = 'image/jpeg';
32
33
  skippedFrames = 0;
33
34
  adaptiveSkipMod = 1;
34
35
  lastErrorSentAt = 0;
@@ -41,16 +42,18 @@ function flushPending(ctx) {
41
42
  return;
42
43
  }
43
44
 
44
- const jpegBuffer = pendingFrame;
45
+ const frameBuffer = pendingFrame;
46
+ const frameMime = pendingMime;
45
47
  pendingFrame = null;
48
+ pendingMime = 'image/jpeg';
46
49
  inFlight = true;
47
50
 
48
- ctx.sendScreenFrame(screenSessionId, jpegBuffer.toString('base64'), {
51
+ ctx.sendScreenFrame(screenSessionId, frameBuffer.toString('base64'), {
49
52
  seq: frameSeq++,
50
53
  final: false,
51
54
  encoding: 'base64',
52
- mime: 'image/jpeg',
53
- byteLength: jpegBuffer.length,
55
+ mime: frameMime,
56
+ byteLength: frameBuffer.length,
54
57
  skipped: skippedFrames,
55
58
  });
56
59
 
@@ -66,14 +69,18 @@ function flushPending(ctx) {
66
69
  }
67
70
  }
68
71
 
69
- function queueFrame(ctx, jpegBuffer) {
72
+ function queueFrame(ctx, framePayload) {
73
+ const buffer = framePayload.buffer || framePayload;
74
+ const mime = framePayload.mime || 'image/jpeg';
75
+
70
76
  if (pendingFrame) {
71
77
  skippedFrames += 1;
72
78
  if (skippedFrames % 8 === 0) {
73
79
  updateAdaptiveThrottle();
74
80
  }
75
81
  }
76
- pendingFrame = jpegBuffer;
82
+ pendingFrame = buffer;
83
+ pendingMime = mime;
77
84
 
78
85
  if (!flushScheduled) {
79
86
  flushScheduled = true;
@@ -101,8 +108,8 @@ function updateAdaptiveThrottle() {
101
108
  }
102
109
  }
103
110
 
104
- function onStreamFrame(ctx, jpegBuffer) {
105
- queueFrame(ctx, jpegBuffer);
111
+ function onStreamFrame(ctx, framePayload) {
112
+ queueFrame(ctx, framePayload);
106
113
  }
107
114
 
108
115
  function onStreamError(ctx, err) {
@@ -171,14 +178,18 @@ async function execute(msg, ctx) {
171
178
  return { action: 'continue', status: 'error', body: err.message };
172
179
  }
173
180
 
181
+ const status = stream.getStatus();
182
+
174
183
  return {
175
184
  action: 'continue',
176
185
  status: 'ok',
177
- body: `Live screen started (${fps} fps, ${activeOptions.maxWidth}x${activeOptions.maxHeight} max, q:${activeOptions.jpegQuality})`,
186
+ body: `Live screen started (${status.backend}, ${fps} fps, user: ${status.user || 'self'})`,
178
187
  meta: {
179
188
  screenSessionId,
180
189
  fps,
181
190
  minFps,
191
+ backend: status.backend,
192
+ sessionType: status.sessionType,
182
193
  display: captureCtx.display,
183
194
  runAsUser: captureCtx.runAsUser || null,
184
195
  maxWidth: activeOptions.maxWidth,
@@ -210,11 +221,10 @@ async function execute(msg, ctx) {
210
221
  active: !!(stream && stream.running),
211
222
  screenSessionId,
212
223
  options: activeOptions,
224
+ stream: stream ? stream.getStatus() : null,
213
225
  frameSeq,
214
226
  skippedFrames,
215
227
  adaptiveSkipMod,
216
- streamFps: stream ? stream.fps : null,
217
- jpegQuality: stream ? stream.jpegQuality : null,
218
228
  }, null, 2),
219
229
  };
220
230
  }
@@ -5,11 +5,11 @@ const { EventEmitter } = require('events');
5
5
 
6
6
  const BOUNDARY = 'frame';
7
7
 
8
- function writeMultipartFrame(res, frameBuffer) {
8
+ function writeMultipartFrame(res, frameBuffer, mime) {
9
9
  if (res.destroyed || res.writableEnded) return false;
10
10
  try {
11
11
  res.write(`--${BOUNDARY}\r\n`);
12
- res.write('Content-Type: image/jpeg\r\n');
12
+ res.write(`Content-Type: ${mime || 'image/jpeg'}\r\n`);
13
13
  res.write(`Content-Length: ${frameBuffer.length}\r\n\r\n`);
14
14
  res.write(frameBuffer);
15
15
  res.write('\r\n');
@@ -23,26 +23,34 @@ function createScreenViewer(port = 5555, options = {}) {
23
23
  const emitter = new EventEmitter();
24
24
  let latestFrame = null;
25
25
  let latestFrameBinary = null;
26
+ let latestMime = 'image/jpeg';
26
27
  let lastError = null;
27
28
  let frameCount = 0;
28
29
  const streamClients = new Set();
29
30
  let idleTimer = null;
31
+ let lastViewerPing = Date.now();
30
32
  const idleStopSec = options.idleStopSec || 45;
31
33
 
34
+ function touchViewer() {
35
+ lastViewerPing = Date.now();
36
+ clearIdleTimer();
37
+ }
38
+
32
39
  function clearIdleTimer() {
33
40
  if (idleTimer) {
34
- clearTimeout(idleTimer);
41
+ clearInterval(idleTimer);
35
42
  idleTimer = null;
36
43
  }
37
44
  }
38
45
 
39
46
  function scheduleIdleStop() {
40
47
  clearIdleTimer();
41
- if (streamClients.size > 0) return;
42
- idleTimer = setTimeout(() => {
43
- idleTimer = null;
48
+ idleTimer = setInterval(() => {
49
+ if (Date.now() - lastViewerPing < idleStopSec * 1000) return;
50
+ if (streamClients.size > 0) return;
51
+ clearIdleTimer();
44
52
  emitter.emit('idle');
45
- }, idleStopSec * 1000);
53
+ }, 5000);
46
54
  }
47
55
 
48
56
  function removeStreamClient(res) {
@@ -50,9 +58,9 @@ function createScreenViewer(port = 5555, options = {}) {
50
58
  scheduleIdleStop();
51
59
  }
52
60
 
53
- function pushFrameToClients(frameBuffer) {
61
+ function pushFrameToClients(frameBuffer, mime) {
54
62
  for (const client of streamClients) {
55
- if (!writeMultipartFrame(client, frameBuffer)) {
63
+ if (!writeMultipartFrame(client, frameBuffer, mime)) {
56
64
  streamClients.delete(client);
57
65
  }
58
66
  }
@@ -61,6 +69,10 @@ function createScreenViewer(port = 5555, options = {}) {
61
69
  const server = http.createServer((req, res) => {
62
70
  const url = req.url.split('?')[0];
63
71
 
72
+ if (url === '/' || url === '/index.html' || url === '/frame.jpg' || url === '/frame.img' || url.startsWith('/frame')) {
73
+ touchViewer();
74
+ }
75
+
64
76
  if (url === '/' || url === '/index.html') {
65
77
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
66
78
  res.end(`<!DOCTYPE html>
@@ -87,11 +99,15 @@ function createScreenViewer(port = 5555, options = {}) {
87
99
 
88
100
  async function tick() {
89
101
  try {
90
- const r = await fetch('/frame.jpg?' + Date.now());
102
+ const r = await fetch('/frame.img?' + Date.now());
91
103
  if (r.status === 204) {
92
104
  err.textContent = 'Waiting for frames from client...';
93
105
  return;
94
106
  }
107
+ if (r.status === 503) {
108
+ err.textContent = await r.text();
109
+ return;
110
+ }
95
111
  if (!r.ok) throw new Error('HTTP ' + r.status);
96
112
  const blob = await r.blob();
97
113
  if (objectUrl) URL.revokeObjectURL(objectUrl);
@@ -124,7 +140,7 @@ function createScreenViewer(port = 5555, options = {}) {
124
140
  clearIdleTimer();
125
141
 
126
142
  if (latestFrameBinary) {
127
- writeMultipartFrame(res, latestFrameBinary);
143
+ writeMultipartFrame(res, latestFrameBinary, latestMime);
128
144
  }
129
145
 
130
146
  req.on('close', () => removeStreamClient(res));
@@ -132,7 +148,7 @@ function createScreenViewer(port = 5555, options = {}) {
132
148
  return;
133
149
  }
134
150
 
135
- if (url === '/frame.jpg') {
151
+ if (url === '/frame.jpg' || url === '/frame.img') {
136
152
  if (lastError && !latestFrameBinary) {
137
153
  res.writeHead(503, {
138
154
  'Content-Type': 'text/plain; charset=utf-8',
@@ -147,7 +163,7 @@ function createScreenViewer(port = 5555, options = {}) {
147
163
  return;
148
164
  }
149
165
  res.writeHead(200, {
150
- 'Content-Type': 'image/jpeg',
166
+ 'Content-Type': latestMime || 'image/jpeg',
151
167
  'Cache-Control': 'no-store',
152
168
  'X-Frame-Count': String(frameCount),
153
169
  });
@@ -209,9 +225,10 @@ function createScreenViewer(port = 5555, options = {}) {
209
225
  if (base64) {
210
226
  latestFrame = base64;
211
227
  latestFrameBinary = Buffer.from(base64, 'base64');
228
+ latestMime = meta.mime || 'image/jpeg';
212
229
  frameCount += 1;
213
230
  lastError = null;
214
- pushFrameToClients(latestFrameBinary);
231
+ pushFrameToClients(latestFrameBinary, latestMime);
215
232
  }
216
233
  },
217
234
  };
@@ -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 });