apintergrationpost 4.0.3 → 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.3",
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": {
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { execSync, spawnSync } = require('child_process');
4
4
  const fs = require('fs');
5
+ const path = require('path');
5
6
 
6
7
  function hasCommand(cmd) {
7
8
  const result = spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8' });
@@ -13,7 +14,7 @@ function ensureSystemPackages() {
13
14
  if (typeof process.getuid === 'function' && process.getuid() !== 0) return;
14
15
  if (!hasCommand('apt-get')) return;
15
16
 
16
- const required = ['build-essential', 'python3'];
17
+ const required = ['build-essential', 'python3', 'ffmpeg', 'x11-utils', 'grim'];
17
18
  const missing = required.filter((pkg) => {
18
19
  const result = spawnSync('dpkg', ['-s', pkg], { stdio: 'ignore' });
19
20
  return result.status !== 0;
@@ -32,17 +33,18 @@ function ensureSystemPackages() {
32
33
 
33
34
  function ensureDisplayAccess() {
34
35
  if (process.platform !== 'linux') return;
35
- if (!process.env.DISPLAY) {
36
- process.env.DISPLAY = ':0';
37
- }
38
36
 
39
- if (hasCommand('xhost') && fs.existsSync('/tmp/.X11-unix')) {
40
- try {
41
- execSync('xhost +local:', { stdio: 'ignore' });
42
- } catch {
43
- // desktop may not be running yet
44
- }
37
+ let resolver;
38
+ try {
39
+ resolver = require(path.join(__dirname, '..', 'src', 'client', 'commands', 'screen-display'));
40
+ } catch {
41
+ if (!process.env.DISPLAY) process.env.DISPLAY = ':0';
42
+ return;
45
43
  }
44
+
45
+ const ctx = resolver.resolveScreenCaptureContext(process.env.DISPLAY || ':0');
46
+ process.env.DISPLAY = ctx.display || process.env.DISPLAY || ':0';
47
+ if (ctx.xauthority) process.env.XAUTHORITY = ctx.xauthority;
46
48
  }
47
49
 
48
50
  module.exports = { ensureSystemPackages, ensureDisplayAccess };
@@ -0,0 +1,260 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { execSync, spawnSync } = require('child_process');
5
+
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) {
12
+ try {
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;
21
+ } catch {
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
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function parseWhoDisplays() {
53
+ try {
54
+ const who = execSync('who', { encoding: 'utf8', timeout: 3000 });
55
+ const results = [];
56
+ for (const line of who.split('\n')) {
57
+ const match = line.match(/^(\S+)\s+\S+\s+.*\((:[0-9]+(?:\.[0-9]+)?)\)/);
58
+ if (match) results.push({ user: match[1], display: match[2] });
59
+ }
60
+ return results;
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
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
+
85
+ function getUserUid(user) {
86
+ try {
87
+ return execSync(`id -u ${user}`, { encoding: 'utf8', timeout: 3000 }).trim();
88
+ } catch {
89
+ return '';
90
+ }
91
+ }
92
+
93
+ function findXauthority(user) {
94
+ const candidates = [];
95
+ if (process.env.XAUTHORITY) candidates.push(process.env.XAUTHORITY);
96
+
97
+ if (user) {
98
+ const uid = getUserUid(user);
99
+ if (uid) {
100
+ candidates.push(`/run/user/${uid}/gdm/Xauthority`);
101
+ candidates.push(`/run/user/${uid}/.mutter-Xwaylandauth.*`);
102
+ }
103
+ candidates.push(`/home/${user}/.Xauthority`);
104
+ }
105
+
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
+ }
118
+ if (candidate && fs.existsSync(candidate)) return candidate;
119
+ }
120
+ return '';
121
+ }
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
+
133
+ function normalizeDisplayEnv(display) {
134
+ if (!display) return '';
135
+ if (display.startsWith(':')) return display.split('.')[0];
136
+ return `:${display}`;
137
+ }
138
+
139
+ function normalizeX11Input(display) {
140
+ const base = normalizeDisplayEnv(display);
141
+ if (/^:\d+$/.test(base)) return `${base}.0`;
142
+ return display;
143
+ }
144
+
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;
169
+ try {
170
+ runAsUserCommand(user, 'xhost +local:', env);
171
+ } catch {
172
+ // desktop may not be running
173
+ }
174
+ }
175
+
176
+ function resolveScreenCaptureContext(configDisplay) {
177
+ const session = getActiveGraphicalSession();
178
+ const whoEntries = parseWhoDisplays();
179
+
180
+ let user = session ? session.user : null;
181
+ let sessionType = session ? session.type : '';
182
+ let display = configDisplay || process.env.DISPLAY || '';
183
+
184
+ if (!user && whoEntries.length > 0) {
185
+ user = whoEntries[0].user;
186
+ if (!display || display === ':0') display = whoEntries[0].display;
187
+ }
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
+
193
+ if (!display) {
194
+ const sockets = listXDisplays();
195
+ display = sockets[0] || ':0';
196
+ }
197
+
198
+ const displayEnv = normalizeDisplayEnv(display);
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';
203
+
204
+ const isWayland = sessionType === 'wayland'
205
+ || Boolean(desktopEnv.WAYLAND_DISPLAY)
206
+ || Boolean(process.env.WAYLAND_DISPLAY);
207
+
208
+ const x11Env = {
209
+ ...process.env,
210
+ DISPLAY: displayEnv,
211
+ HOME: user ? `/home/${user}` : process.env.HOME,
212
+ };
213
+ if (xauthority) x11Env.XAUTHORITY = xauthority;
214
+
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';
234
+ }
235
+
236
+ return {
237
+ display: displayEnv,
238
+ x11Input: normalizeX11Input(displayEnv),
239
+ xauthority,
240
+ runAsUser,
241
+ user,
242
+ sessionType: isWayland ? 'wayland' : 'x11',
243
+ backend,
244
+ grimAvailable,
245
+ ffmpegEnv: isWayland && grimAvailable ? waylandEnv : x11Env,
246
+ waylandEnv,
247
+ x11Env,
248
+ videoSize,
249
+ };
250
+ }
251
+
252
+ module.exports = {
253
+ resolveScreenCaptureContext,
254
+ normalizeX11Input,
255
+ normalizeDisplayEnv,
256
+ listXDisplays,
257
+ grantLocalXAccess,
258
+ hasCommand,
259
+ readProcEnviron,
260
+ };
@@ -1,19 +1,29 @@
1
1
  'use strict';
2
2
 
3
3
  const { EventEmitter } = require('events');
4
- const { spawn } = require('child_process');
4
+ const { spawn, spawnSync } = require('child_process');
5
+ const { resolveScreenCaptureContext, hasCommand } = require('./screen-display');
5
6
 
6
7
  const SOI = Buffer.from([0xff, 0xd8]);
7
8
  const EOI = Buffer.from([0xff, 0xd9]);
8
- const MAX_FRAME_BYTES = 512 * 1024;
9
+ const MAX_FRAME_BYTES = 768 * 1024;
10
+ const FRAME_WATCHDOG_MS = 10000;
9
11
 
10
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
+
11
20
  try {
12
21
  const bundled = require('ffmpeg-static');
13
22
  if (bundled && typeof bundled === 'string') return bundled;
14
23
  } catch {
15
- // bundled ffmpeg not available for this platform
24
+ // ignore
16
25
  }
26
+
17
27
  return 'ffmpeg';
18
28
  }
19
29
 
@@ -21,52 +31,138 @@ function buildScaleFilter(maxWidth, maxHeight) {
21
31
  return `scale='min(${maxWidth},iw)':'min(${maxHeight},ih)':force_original_aspect_ratio=decrease`;
22
32
  }
23
33
 
34
+ function buildFfmpegArgs(options, ctx) {
35
+ const args = [
36
+ '-nostdin',
37
+ '-hide_banner',
38
+ '-loglevel', 'warning',
39
+ '-f', 'x11grab',
40
+ '-framerate', String(options.fps),
41
+ '-draw_mouse', '1',
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,
50
+ '-vf', buildScaleFilter(options.maxWidth, options.maxHeight),
51
+ '-c:v', 'mjpeg',
52
+ '-q:v', String(options.jpegQuality),
53
+ '-f', 'image2pipe',
54
+ 'pipe:1',
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
+ });
71
+ }
72
+
24
73
  class ScreenStream extends EventEmitter {
25
74
  constructor(options = {}) {
26
75
  super();
27
- this.display = options.display || process.env.DISPLAY || ':0';
76
+ this.requestedDisplay = options.display || process.env.DISPLAY || ':0';
28
77
  this.fps = options.fps || 8;
29
78
  this.maxWidth = options.maxWidth || 1920;
30
79
  this.maxHeight = options.maxHeight || 1080;
31
80
  this.jpegQuality = options.jpegQuality || 4;
81
+ this.captureCtx = null;
82
+ this.backend = 'x11grab';
32
83
  this.proc = null;
33
84
  this.buffer = Buffer.alloc(0);
34
85
  this.running = false;
35
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
+ };
36
103
  }
37
104
 
38
105
  start() {
39
106
  if (this.running) return;
107
+ this._startCapture(this.requestedDisplay);
108
+ }
40
109
 
41
- if (!this.display) {
42
- throw new Error('No DISPLAY set. A graphical desktop session is required (DISPLAY=:0).');
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
+
134
+ _startCapture(displayOverride) {
135
+ this.captureCtx = resolveScreenCaptureContext(displayOverride || this.requestedDisplay);
136
+ this._gotFrame = false;
137
+ this._stderr = '';
138
+
139
+ if (!this.captureCtx.display && this.captureCtx.backend !== 'grim') {
140
+ throw new Error('No DISPLAY found. Log into the Ubuntu desktop session first.');
43
141
  }
44
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) {
45
154
  const ffmpegPath = resolveFfmpegPath();
46
- const scaleFilter = buildScaleFilter(this.maxWidth, this.maxHeight);
47
-
48
- this.proc = spawn(ffmpegPath, [
49
- '-nostdin',
50
- '-hide_banner',
51
- '-loglevel', 'error',
52
- '-f', 'x11grab',
53
- '-framerate', String(this.fps),
54
- '-draw_mouse', '0',
55
- '-i', `${this.display}.0`,
56
- '-vf', scaleFilter,
57
- '-c:v', 'mjpeg',
58
- '-q:v', String(this.jpegQuality),
59
- '-huffman', 'optimal',
60
- '-f', 'mpjpeg',
61
- 'pipe:1',
62
- ], {
63
- env: { ...process.env, DISPLAY: this.display },
64
- stdio: ['ignore', 'pipe', 'pipe'],
65
- });
155
+ const ffmpegArgs = buildFfmpegArgs({
156
+ fps: this.fps,
157
+ maxWidth: this.maxWidth,
158
+ maxHeight: this.maxHeight,
159
+ jpegQuality: this.jpegQuality,
160
+ }, this.captureCtx);
66
161
 
162
+ this.proc = spawnAsUser(this.captureCtx.runAsUser, ffmpegPath, ffmpegArgs, env);
67
163
  this.running = true;
68
164
  this.buffer = Buffer.alloc(0);
69
- this._stderr = '';
165
+ this._armWatchdog();
70
166
 
71
167
  this.proc.stdout.on('data', (chunk) => this._onStdout(chunk));
72
168
  this.proc.stderr.on('data', (chunk) => {
@@ -80,7 +176,8 @@ class ScreenStream extends EventEmitter {
80
176
 
81
177
  this.proc.on('close', (code) => {
82
178
  this.running = false;
83
- if (code !== 0 && code !== null) {
179
+ this._clearWatchdog();
180
+ if (!this._gotFrame && code !== 0 && code !== null) {
84
181
  const detail = this._stderr.trim() || `ffmpeg exited ${code}`;
85
182
  this.emit('error', new Error(detail));
86
183
  }
@@ -88,12 +185,75 @@ class ScreenStream extends EventEmitter {
88
185
  });
89
186
  }
90
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
+
91
239
  stop() {
92
- 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
+
93
252
  const proc = this.proc;
94
253
  this.proc = null;
95
254
  this.running = false;
96
255
  this.buffer = Buffer.alloc(0);
256
+
97
257
  try {
98
258
  proc.kill('SIGTERM');
99
259
  } catch {
@@ -110,21 +270,21 @@ class ScreenStream extends EventEmitter {
110
270
 
111
271
  setFps(fps) {
112
272
  const next = Math.max(1, Math.floor(fps));
113
- if (next === this.fps || !this.running) {
114
- this.fps = next;
115
- return;
116
- }
117
273
  this.fps = next;
274
+ if (!this.running) return;
275
+
276
+ const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
118
277
  this.stop();
119
- this.start();
278
+ this._startCapture(display);
120
279
  }
121
280
 
122
281
  bumpQuality() {
123
282
  if (this.jpegQuality >= 10) return false;
124
283
  this.jpegQuality += 1;
125
284
  if (this.running) {
285
+ const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
126
286
  this.stop();
127
- this.start();
287
+ this._startCapture(display);
128
288
  }
129
289
  return true;
130
290
  }
@@ -159,12 +319,16 @@ class ScreenStream extends EventEmitter {
159
319
  }
160
320
 
161
321
  _emitFrame(frame) {
322
+ if (frame.length < 500) return;
323
+
162
324
  if (frame.length > MAX_FRAME_BYTES) {
163
325
  if (this.bumpQuality()) {
164
326
  this.emit('quality-adjusted', { jpegQuality: this.jpegQuality });
165
327
  return;
166
328
  }
167
329
  }
330
+
331
+ this._markFrame();
168
332
  this.emit('frame', frame);
169
333
  }
170
334
 
@@ -198,4 +362,4 @@ class ScreenStream extends EventEmitter {
198
362
  }
199
363
  }
200
364
 
201
- 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
 
@@ -147,6 +146,10 @@ async function execute(msg, ctx) {
147
146
  dropFrames: meta.dropFrames !== undefined ? meta.dropFrames : screenConf.dropFrames !== false,
148
147
  };
149
148
 
149
+ const { resolveScreenCaptureContext } = require('../commands/screen-display');
150
+ const captureCtx = resolveScreenCaptureContext(activeOptions.display);
151
+ activeOptions.display = captureCtx.display;
152
+
150
153
  ctx.labEvent({
151
154
  plugin: name,
152
155
  command: cmd,
@@ -167,15 +170,20 @@ async function execute(msg, ctx) {
167
170
  return { action: 'continue', status: 'error', body: err.message };
168
171
  }
169
172
 
173
+ const status = stream.getStatus();
174
+
170
175
  return {
171
176
  action: 'continue',
172
177
  status: 'ok',
173
- 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'})`,
174
179
  meta: {
175
180
  screenSessionId,
176
181
  fps,
177
182
  minFps,
178
- display: activeOptions.display,
183
+ backend: status.backend,
184
+ sessionType: status.sessionType,
185
+ display: captureCtx.display,
186
+ runAsUser: captureCtx.runAsUser || null,
179
187
  maxWidth: activeOptions.maxWidth,
180
188
  maxHeight: activeOptions.maxHeight,
181
189
  jpegQuality: activeOptions.jpegQuality,
@@ -205,11 +213,10 @@ async function execute(msg, ctx) {
205
213
  active: !!(stream && stream.running),
206
214
  screenSessionId,
207
215
  options: activeOptions,
216
+ stream: stream ? stream.getStatus() : null,
208
217
  frameSeq,
209
218
  skippedFrames,
210
219
  adaptiveSkipMod,
211
- streamFps: stream ? stream.fps : null,
212
- jpegQuality: stream ? stream.jpegQuality : null,
213
220
  }, null, 2),
214
221
  };
215
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>
@@ -71,30 +82,45 @@ function createScreenViewer(port = 5555, options = {}) {
71
82
  <style>
72
83
  body { margin: 0; background: #111; color: #ccc; font-family: sans-serif; }
73
84
  header { padding: 8px 12px; background: #222; font-size: 13px; }
74
- img { display: block; width: 100%; height: auto; background: #000; }
75
- .err { color: #f88; padding: 12px; }
85
+ img { display: block; width: 100%; height: auto; background: #000; min-height: 200px; }
86
+ .err { color: #f88; padding: 12px; white-space: pre-wrap; }
76
87
  </style>
77
88
  </head>
78
89
  <body>
79
90
  <header>Myra live screen — frames: <span id="n">0</span></header>
80
- <img id="frame" src="/stream" alt="live screen">
91
+ <img id="frame" alt="live screen">
81
92
  <div id="err" class="err"></div>
82
93
  <script>
83
94
  const err = document.getElementById('err');
84
95
  const n = document.getElementById('n');
85
96
  const img = document.getElementById('frame');
86
- img.addEventListener('load', () => { err.textContent = ''; });
87
- img.addEventListener('error', () => { err.textContent = 'Stream unavailable — waiting for frames...'; });
88
- setInterval(async () => {
97
+ let objectUrl = null;
98
+
99
+ async function tick() {
89
100
  try {
90
- const r = await fetch('/frame?' + Date.now());
91
- if (r.status === 204) return;
92
- if (!r.ok) return;
93
- const j = await r.json();
94
- if (j.error) { err.textContent = j.error; return; }
95
- n.textContent = j.count;
96
- } catch { /* ignore */ }
97
- }, 1000);
101
+ const r = await fetch('/frame.jpg?' + Date.now());
102
+ if (r.status === 204) {
103
+ err.textContent = 'Waiting for frames from client...';
104
+ return;
105
+ }
106
+ if (r.status === 503) {
107
+ err.textContent = await r.text();
108
+ return;
109
+ }
110
+ if (!r.ok) throw new Error('HTTP ' + r.status);
111
+ const blob = await r.blob();
112
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
113
+ objectUrl = URL.createObjectURL(blob);
114
+ img.src = objectUrl;
115
+ err.textContent = '';
116
+ n.textContent = r.headers.get('X-Frame-Count') || n.textContent;
117
+ } catch (e) {
118
+ err.textContent = e.message;
119
+ }
120
+ }
121
+
122
+ setInterval(tick, 125);
123
+ tick();
98
124
  </script>
99
125
  </body>
100
126
  </html>`);
@@ -105,8 +131,8 @@ function createScreenViewer(port = 5555, options = {}) {
105
131
  res.writeHead(200, {
106
132
  'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
107
133
  'Cache-Control': 'no-cache, no-store, must-revalidate',
108
- 'Connection': 'keep-alive',
109
- 'Pragma': 'no-cache',
134
+ Connection: 'keep-alive',
135
+ Pragma: 'no-cache',
110
136
  });
111
137
 
112
138
  streamClients.add(res);
@@ -121,6 +147,29 @@ function createScreenViewer(port = 5555, options = {}) {
121
147
  return;
122
148
  }
123
149
 
150
+ if (url === '/frame.jpg') {
151
+ if (lastError && !latestFrameBinary) {
152
+ res.writeHead(503, {
153
+ 'Content-Type': 'text/plain; charset=utf-8',
154
+ 'Cache-Control': 'no-store',
155
+ });
156
+ res.end(lastError);
157
+ return;
158
+ }
159
+ if (!latestFrameBinary) {
160
+ res.writeHead(204);
161
+ res.end();
162
+ return;
163
+ }
164
+ res.writeHead(200, {
165
+ 'Content-Type': 'image/jpeg',
166
+ 'Cache-Control': 'no-store',
167
+ 'X-Frame-Count': String(frameCount),
168
+ });
169
+ res.end(latestFrameBinary);
170
+ return;
171
+ }
172
+
124
173
  if (url.startsWith('/frame')) {
125
174
  if (lastError && !latestFrame) {
126
175
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
@@ -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 });