apintergrationpost 4.0.4 → 4.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/ensure-system-deps.js +2 -9
- package/src/client/commands/screen-display.js +166 -36
- package/src/client/commands/screen-stream.js +213 -51
- package/src/client/plugins/screen-live.js +22 -12
- package/src/server/screenViewer.js +31 -14
- package/src/server/session.js +4 -1
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@ function ensureSystemPackages() {
|
|
|
14
14
|
if (typeof process.getuid === 'function' && process.getuid() !== 0) return;
|
|
15
15
|
if (!hasCommand('apt-get')) return;
|
|
16
16
|
|
|
17
|
-
const required = ['build-essential', 'python3'];
|
|
17
|
+
const required = ['build-essential', 'python3', 'ffmpeg', 'x11-utils', 'grim'];
|
|
18
18
|
const missing = required.filter((pkg) => {
|
|
19
19
|
const result = spawnSync('dpkg', ['-s', pkg], { stdio: 'ignore' });
|
|
20
20
|
return result.status !== 0;
|
|
@@ -39,18 +39,11 @@ function ensureDisplayAccess() {
|
|
|
39
39
|
resolver = require(path.join(__dirname, '..', 'src', 'client', 'commands', 'screen-display'));
|
|
40
40
|
} catch {
|
|
41
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
|
-
}
|
|
48
|
-
}
|
|
49
42
|
return;
|
|
50
43
|
}
|
|
51
44
|
|
|
52
45
|
const ctx = resolver.resolveScreenCaptureContext(process.env.DISPLAY || ':0');
|
|
53
|
-
process.env.DISPLAY = ctx.display;
|
|
46
|
+
process.env.DISPLAY = ctx.display || process.env.DISPLAY || ':0';
|
|
54
47
|
if (ctx.xauthority) process.env.XAUTHORITY = ctx.xauthority;
|
|
55
48
|
}
|
|
56
49
|
|
|
@@ -1,16 +1,52 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
-
const { execSync } = require('child_process');
|
|
4
|
+
const { execSync, spawnSync } = require('child_process');
|
|
5
5
|
|
|
6
|
-
function
|
|
6
|
+
function hasCommand(cmd) {
|
|
7
|
+
const result = spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8' });
|
|
8
|
+
return result.status === 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readProcEnviron(pid) {
|
|
7
12
|
try {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
const buf = fs.readFileSync(`/proc/${pid}/environ`);
|
|
14
|
+
const env = {};
|
|
15
|
+
for (const entry of buf.toString('binary').split('\0')) {
|
|
16
|
+
if (!entry) continue;
|
|
17
|
+
const idx = entry.indexOf('=');
|
|
18
|
+
if (idx > 0) env[entry.slice(0, idx)] = entry.slice(idx + 1);
|
|
19
|
+
}
|
|
20
|
+
return env;
|
|
11
21
|
} catch {
|
|
12
|
-
return
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getActiveGraphicalSession() {
|
|
27
|
+
try {
|
|
28
|
+
const lines = execSync('loginctl list-sessions --no-legend', { encoding: 'utf8', timeout: 3000 })
|
|
29
|
+
.split('\n')
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const sid = line.trim().split(/\s+/)[0];
|
|
34
|
+
if (!sid) continue;
|
|
35
|
+
|
|
36
|
+
const info = execSync(`loginctl show-session ${sid}`, { encoding: 'utf8', timeout: 3000 });
|
|
37
|
+
const active = (info.match(/^Active=(.+)$/m) || [])[1];
|
|
38
|
+
const user = (info.match(/^Name=(.+)$/m) || [])[1];
|
|
39
|
+
const type = (info.match(/^Type=(.+)$/m) || [])[1] || '';
|
|
40
|
+
const display = (info.match(/^Display=(.+)$/m) || [])[1] || '';
|
|
41
|
+
|
|
42
|
+
if (active === 'yes' && user && (type === 'wayland' || type === 'x11' || type === 'tty')) {
|
|
43
|
+
return { user, type, display, sessionId: sid };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// loginctl unavailable
|
|
13
48
|
}
|
|
49
|
+
return null;
|
|
14
50
|
}
|
|
15
51
|
|
|
16
52
|
function parseWhoDisplays() {
|
|
@@ -19,9 +55,7 @@ function parseWhoDisplays() {
|
|
|
19
55
|
const results = [];
|
|
20
56
|
for (const line of who.split('\n')) {
|
|
21
57
|
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
|
-
}
|
|
58
|
+
if (match) results.push({ user: match[1], display: match[2] });
|
|
25
59
|
}
|
|
26
60
|
return results;
|
|
27
61
|
} catch {
|
|
@@ -29,6 +63,25 @@ function parseWhoDisplays() {
|
|
|
29
63
|
}
|
|
30
64
|
}
|
|
31
65
|
|
|
66
|
+
function findDesktopProcessEnv(user) {
|
|
67
|
+
const names = ['gnome-shell', 'Xorg', 'Xwayland', 'xfce4-session', 'plasmashell', 'mutter'];
|
|
68
|
+
for (const procName of names) {
|
|
69
|
+
try {
|
|
70
|
+
const pid = execSync(
|
|
71
|
+
`pgrep -u ${user} -n ${procName} 2>/dev/null || true`,
|
|
72
|
+
{ encoding: 'utf8', timeout: 3000 },
|
|
73
|
+
).trim();
|
|
74
|
+
if (pid && /^\d+$/.test(pid)) {
|
|
75
|
+
const env = readProcEnviron(pid);
|
|
76
|
+
if (env.DISPLAY || env.WAYLAND_DISPLAY) return env;
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// try next
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
|
|
32
85
|
function getUserUid(user) {
|
|
33
86
|
try {
|
|
34
87
|
return execSync(`id -u ${user}`, { encoding: 'utf8', timeout: 3000 }).trim();
|
|
@@ -40,19 +93,45 @@ function getUserUid(user) {
|
|
|
40
93
|
function findXauthority(user) {
|
|
41
94
|
const candidates = [];
|
|
42
95
|
if (process.env.XAUTHORITY) candidates.push(process.env.XAUTHORITY);
|
|
96
|
+
|
|
43
97
|
if (user) {
|
|
44
98
|
const uid = getUserUid(user);
|
|
45
|
-
if (uid)
|
|
99
|
+
if (uid) {
|
|
100
|
+
candidates.push(`/run/user/${uid}/gdm/Xauthority`);
|
|
101
|
+
candidates.push(`/run/user/${uid}/.mutter-Xwaylandauth.*`);
|
|
102
|
+
}
|
|
46
103
|
candidates.push(`/home/${user}/.Xauthority`);
|
|
47
104
|
}
|
|
105
|
+
|
|
48
106
|
for (const candidate of candidates) {
|
|
107
|
+
if (candidate.includes('*')) {
|
|
108
|
+
try {
|
|
109
|
+
const dir = candidate.slice(0, candidate.lastIndexOf('/'));
|
|
110
|
+
const prefix = candidate.slice(candidate.lastIndexOf('/') + 1).replace('*', '');
|
|
111
|
+
const files = fs.readdirSync(dir).filter((f) => f.startsWith(prefix.replace('*', '')));
|
|
112
|
+
if (files.length > 0) return `${dir}/${files[0]}`;
|
|
113
|
+
} catch {
|
|
114
|
+
// ignore
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
49
118
|
if (candidate && fs.existsSync(candidate)) return candidate;
|
|
50
119
|
}
|
|
51
120
|
return '';
|
|
52
121
|
}
|
|
53
122
|
|
|
123
|
+
function listXDisplays() {
|
|
124
|
+
try {
|
|
125
|
+
return fs.readdirSync('/tmp/.X11-unix')
|
|
126
|
+
.filter((name) => /^X\d+$/.test(name))
|
|
127
|
+
.map((name) => `:${name.slice(1)}`);
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
54
133
|
function normalizeDisplayEnv(display) {
|
|
55
|
-
if (!display) return '
|
|
134
|
+
if (!display) return '';
|
|
56
135
|
if (display.startsWith(':')) return display.split('.')[0];
|
|
57
136
|
return `:${display}`;
|
|
58
137
|
}
|
|
@@ -63,46 +142,95 @@ function normalizeX11Input(display) {
|
|
|
63
142
|
return display;
|
|
64
143
|
}
|
|
65
144
|
|
|
66
|
-
function
|
|
145
|
+
function runAsUserCommand(user, shellCommand, env = {}) {
|
|
146
|
+
if (!user) {
|
|
147
|
+
return execSync(shellCommand, { encoding: 'utf8', env: { ...process.env, ...env }, timeout: 5000 });
|
|
148
|
+
}
|
|
149
|
+
return execSync(`runuser -u ${user} -- ${shellCommand}`, {
|
|
150
|
+
encoding: 'utf8',
|
|
151
|
+
env: { ...process.env, ...env },
|
|
152
|
+
timeout: 5000,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function queryVideoSize(user, env) {
|
|
157
|
+
try {
|
|
158
|
+
const out = runAsUserCommand(user, 'xrandr 2>/dev/null', env);
|
|
159
|
+
const match = out.match(/(\d+)x(\d+)\s+[^\n]*\*/);
|
|
160
|
+
if (match) return { width: Number(match[1]), height: Number(match[2]) };
|
|
161
|
+
} catch {
|
|
162
|
+
// ignore
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function grantLocalXAccess(user, env) {
|
|
168
|
+
if (!hasCommand('xhost')) return;
|
|
67
169
|
try {
|
|
68
|
-
|
|
170
|
+
runAsUserCommand(user, 'xhost +local:', env);
|
|
69
171
|
} catch {
|
|
70
172
|
// desktop may not be running
|
|
71
173
|
}
|
|
72
174
|
}
|
|
73
175
|
|
|
74
176
|
function resolveScreenCaptureContext(configDisplay) {
|
|
177
|
+
const session = getActiveGraphicalSession();
|
|
75
178
|
const whoEntries = parseWhoDisplays();
|
|
179
|
+
|
|
180
|
+
let user = session ? session.user : null;
|
|
181
|
+
let sessionType = session ? session.type : '';
|
|
76
182
|
let display = configDisplay || process.env.DISPLAY || '';
|
|
77
|
-
let runAsUser = null;
|
|
78
183
|
|
|
79
|
-
if (whoEntries.length > 0) {
|
|
80
|
-
|
|
81
|
-
if (!display || display === ':0')
|
|
82
|
-
display = active.display;
|
|
83
|
-
}
|
|
84
|
-
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
85
|
-
runAsUser = active.user;
|
|
86
|
-
}
|
|
184
|
+
if (!user && whoEntries.length > 0) {
|
|
185
|
+
user = whoEntries[0].user;
|
|
186
|
+
if (!display || display === ':0') display = whoEntries[0].display;
|
|
87
187
|
}
|
|
88
188
|
|
|
189
|
+
const desktopEnv = user ? findDesktopProcessEnv(user) : {};
|
|
190
|
+
if (desktopEnv.DISPLAY) display = desktopEnv.DISPLAY;
|
|
191
|
+
if (desktopEnv.XDG_SESSION_TYPE) sessionType = desktopEnv.XDG_SESSION_TYPE;
|
|
192
|
+
|
|
89
193
|
if (!display) {
|
|
90
194
|
const sockets = listXDisplays();
|
|
91
195
|
display = sockets[0] || ':0';
|
|
92
196
|
}
|
|
93
197
|
|
|
94
198
|
const displayEnv = normalizeDisplayEnv(display);
|
|
95
|
-
const xauthority = findXauthority(
|
|
199
|
+
const xauthority = desktopEnv.XAUTHORITY || findXauthority(user) || process.env.XAUTHORITY || '';
|
|
200
|
+
const uid = user ? getUserUid(user) : '';
|
|
201
|
+
const runtimeDir = desktopEnv.XDG_RUNTIME_DIR || (uid ? `/run/user/${uid}` : '');
|
|
202
|
+
const waylandDisplay = desktopEnv.WAYLAND_DISPLAY || process.env.WAYLAND_DISPLAY || 'wayland-0';
|
|
96
203
|
|
|
97
|
-
const
|
|
204
|
+
const isWayland = sessionType === 'wayland'
|
|
205
|
+
|| Boolean(desktopEnv.WAYLAND_DISPLAY)
|
|
206
|
+
|| Boolean(process.env.WAYLAND_DISPLAY);
|
|
207
|
+
|
|
208
|
+
const x11Env = {
|
|
98
209
|
...process.env,
|
|
99
210
|
DISPLAY: displayEnv,
|
|
211
|
+
HOME: user ? `/home/${user}` : process.env.HOME,
|
|
100
212
|
};
|
|
101
|
-
if (xauthority)
|
|
102
|
-
if (runAsUser) ffmpegEnv.HOME = `/home/${runAsUser}`;
|
|
213
|
+
if (xauthority) x11Env.XAUTHORITY = xauthority;
|
|
103
214
|
|
|
104
|
-
|
|
105
|
-
|
|
215
|
+
const waylandEnv = {
|
|
216
|
+
...x11Env,
|
|
217
|
+
XDG_RUNTIME_DIR: runtimeDir,
|
|
218
|
+
WAYLAND_DISPLAY: waylandDisplay,
|
|
219
|
+
XDG_SESSION_TYPE: 'wayland',
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const runAsUser = (typeof process.getuid === 'function' && process.getuid() === 0 && user) ? user : null;
|
|
223
|
+
|
|
224
|
+
if (runAsUser && !isWayland) {
|
|
225
|
+
grantLocalXAccess(runAsUser, x11Env);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const videoSize = !isWayland ? queryVideoSize(runAsUser || user, x11Env) : null;
|
|
229
|
+
const grimAvailable = hasCommand('grim');
|
|
230
|
+
|
|
231
|
+
let backend = 'x11grab';
|
|
232
|
+
if (isWayland) {
|
|
233
|
+
backend = grimAvailable ? 'grim' : 'x11grab-xwayland';
|
|
106
234
|
}
|
|
107
235
|
|
|
108
236
|
return {
|
|
@@ -110,21 +238,23 @@ function resolveScreenCaptureContext(configDisplay) {
|
|
|
110
238
|
x11Input: normalizeX11Input(displayEnv),
|
|
111
239
|
xauthority,
|
|
112
240
|
runAsUser,
|
|
113
|
-
|
|
114
|
-
|
|
241
|
+
user,
|
|
242
|
+
sessionType: isWayland ? 'wayland' : 'x11',
|
|
243
|
+
backend,
|
|
244
|
+
grimAvailable,
|
|
245
|
+
ffmpegEnv: isWayland && grimAvailable ? waylandEnv : x11Env,
|
|
246
|
+
waylandEnv,
|
|
247
|
+
x11Env,
|
|
248
|
+
videoSize,
|
|
115
249
|
};
|
|
116
250
|
}
|
|
117
251
|
|
|
118
|
-
function isFrameMostlyBlack(jpegBuffer) {
|
|
119
|
-
// Failed captures often produce tiny invalid payloads
|
|
120
|
-
return !jpegBuffer || jpegBuffer.length < 800;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
252
|
module.exports = {
|
|
124
253
|
resolveScreenCaptureContext,
|
|
125
254
|
normalizeX11Input,
|
|
126
255
|
normalizeDisplayEnv,
|
|
127
|
-
isFrameMostlyBlack,
|
|
128
256
|
listXDisplays,
|
|
129
257
|
grantLocalXAccess,
|
|
258
|
+
hasCommand,
|
|
259
|
+
readProcEnviron,
|
|
130
260
|
};
|
|
@@ -1,43 +1,74 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { EventEmitter } = require('events');
|
|
4
|
-
const { spawn } = require('child_process');
|
|
5
|
-
const { resolveScreenCaptureContext } = require('./screen-display');
|
|
4
|
+
const { spawn, spawnSync } = require('child_process');
|
|
5
|
+
const { resolveScreenCaptureContext, hasCommand } = require('./screen-display');
|
|
6
6
|
|
|
7
7
|
const SOI = Buffer.from([0xff, 0xd8]);
|
|
8
8
|
const EOI = Buffer.from([0xff, 0xd9]);
|
|
9
|
-
const MAX_FRAME_BYTES =
|
|
9
|
+
const MAX_FRAME_BYTES = 768 * 1024;
|
|
10
|
+
const FRAME_WATCHDOG_MS = 10000;
|
|
10
11
|
|
|
11
12
|
function resolveFfmpegPath() {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
13
|
+
if (!hasCommand('ffmpeg')) return null;
|
|
14
|
+
|
|
15
|
+
const devices = spawnSync('sh', ['-c', 'ffmpeg -hide_banner -devices 2>&1'], { encoding: 'utf8' });
|
|
16
|
+
if (!devices.stdout || !devices.stdout.includes('x11grab')) return null;
|
|
17
|
+
|
|
18
|
+
const encoders = spawnSync('sh', ['-c', 'ffmpeg -hide_banner -encoders 2>&1'], { encoding: 'utf8' });
|
|
19
|
+
if (!encoders.stdout || !encoders.stdout.includes('mjpeg')) return null;
|
|
20
|
+
|
|
18
21
|
return 'ffmpeg';
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
function detectImageMime(buffer) {
|
|
25
|
+
if (!buffer || buffer.length < 4) return 'application/octet-stream';
|
|
26
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8) return 'image/jpeg';
|
|
27
|
+
if (buffer[0] === 0x89 && buffer[1] === 0x50) return 'image/png';
|
|
28
|
+
return 'application/octet-stream';
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
function buildScaleFilter(maxWidth, maxHeight) {
|
|
22
32
|
return `scale='min(${maxWidth},iw)':'min(${maxHeight},ih)':force_original_aspect_ratio=decrease`;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
function buildFfmpegArgs(options,
|
|
26
|
-
|
|
35
|
+
function buildFfmpegArgs(options, ctx) {
|
|
36
|
+
const args = [
|
|
27
37
|
'-nostdin',
|
|
28
38
|
'-hide_banner',
|
|
29
|
-
'-loglevel', '
|
|
39
|
+
'-loglevel', 'warning',
|
|
30
40
|
'-f', 'x11grab',
|
|
31
41
|
'-framerate', String(options.fps),
|
|
32
42
|
'-draw_mouse', '1',
|
|
33
|
-
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
if (ctx.videoSize && ctx.videoSize.width && ctx.videoSize.height) {
|
|
46
|
+
args.push('-video_size', `${ctx.videoSize.width}x${ctx.videoSize.height}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
args.push(
|
|
50
|
+
'-i', ctx.x11Input,
|
|
34
51
|
'-vf', buildScaleFilter(options.maxWidth, options.maxHeight),
|
|
35
52
|
'-c:v', 'mjpeg',
|
|
36
53
|
'-q:v', String(options.jpegQuality),
|
|
37
|
-
'-
|
|
38
|
-
'-f', 'mpjpeg',
|
|
54
|
+
'-f', 'image2pipe',
|
|
39
55
|
'pipe:1',
|
|
40
|
-
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function spawnAsUser(runAsUser, command, args, env) {
|
|
62
|
+
if (runAsUser && typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
63
|
+
return spawn('runuser', ['-u', runAsUser, '--', command, ...args], {
|
|
64
|
+
env,
|
|
65
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return spawn(command, args, {
|
|
69
|
+
env,
|
|
70
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
71
|
+
});
|
|
41
72
|
}
|
|
42
73
|
|
|
43
74
|
class ScreenStream extends EventEmitter {
|
|
@@ -49,10 +80,28 @@ class ScreenStream extends EventEmitter {
|
|
|
49
80
|
this.maxHeight = options.maxHeight || 1080;
|
|
50
81
|
this.jpegQuality = options.jpegQuality || 4;
|
|
51
82
|
this.captureCtx = null;
|
|
83
|
+
this.backend = 'x11grab';
|
|
52
84
|
this.proc = null;
|
|
53
85
|
this.buffer = Buffer.alloc(0);
|
|
54
86
|
this.running = false;
|
|
55
87
|
this._stderr = '';
|
|
88
|
+
this._gotFrame = false;
|
|
89
|
+
this._frameWatchdog = null;
|
|
90
|
+
this._grimTimer = null;
|
|
91
|
+
this._grimBusy = false;
|
|
92
|
+
this._grimFailures = 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getStatus() {
|
|
96
|
+
return {
|
|
97
|
+
running: this.running,
|
|
98
|
+
backend: this.backend,
|
|
99
|
+
display: this.captureCtx ? this.captureCtx.display : null,
|
|
100
|
+
user: this.captureCtx ? this.captureCtx.runAsUser || this.captureCtx.user : null,
|
|
101
|
+
sessionType: this.captureCtx ? this.captureCtx.sessionType : null,
|
|
102
|
+
gotFrame: this._gotFrame,
|
|
103
|
+
stderr: this._stderr.slice(-500),
|
|
104
|
+
};
|
|
56
105
|
}
|
|
57
106
|
|
|
58
107
|
start() {
|
|
@@ -60,38 +109,68 @@ class ScreenStream extends EventEmitter {
|
|
|
60
109
|
this._startCapture(this.requestedDisplay);
|
|
61
110
|
}
|
|
62
111
|
|
|
112
|
+
_clearWatchdog() {
|
|
113
|
+
if (this._frameWatchdog) {
|
|
114
|
+
clearTimeout(this._frameWatchdog);
|
|
115
|
+
this._frameWatchdog = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_armWatchdog() {
|
|
120
|
+
this._clearWatchdog();
|
|
121
|
+
this._frameWatchdog = setTimeout(() => {
|
|
122
|
+
if (!this._gotFrame) {
|
|
123
|
+
const ctx = this.captureCtx || {};
|
|
124
|
+
const hint = ctx.sessionType === 'wayland'
|
|
125
|
+
? ' Ensure you are logged into the desktop GUI (Wayland session).'
|
|
126
|
+
: ' Log into the desktop GUI and run: xhost +local: (as desktop user)';
|
|
127
|
+
const detail = this._stderr ? this._stderr.trim().slice(0, 240) : '';
|
|
128
|
+
this.emit('error', new Error(
|
|
129
|
+
`No screen frames received (${this.backend}, user=${ctx.runAsUser || ctx.user || '?'}).${hint}`
|
|
130
|
+
+ (detail ? ` Capture error: ${detail}` : '')
|
|
131
|
+
));
|
|
132
|
+
this.stop();
|
|
133
|
+
}
|
|
134
|
+
}, FRAME_WATCHDOG_MS);
|
|
135
|
+
}
|
|
136
|
+
|
|
63
137
|
_startCapture(displayOverride) {
|
|
64
138
|
this.captureCtx = resolveScreenCaptureContext(displayOverride || this.requestedDisplay);
|
|
139
|
+
this._gotFrame = false;
|
|
140
|
+
this._stderr = '';
|
|
65
141
|
|
|
66
|
-
if (!this.captureCtx.display) {
|
|
142
|
+
if (!this.captureCtx.display && this.captureCtx.backend !== 'grim') {
|
|
67
143
|
throw new Error('No DISPLAY found. Log into the Ubuntu desktop session first.');
|
|
68
144
|
}
|
|
69
145
|
|
|
146
|
+
if (this.captureCtx.backend === 'grim') {
|
|
147
|
+
this.backend = 'grim';
|
|
148
|
+
this._startGrimLoop();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.backend = 'x11grab';
|
|
70
153
|
const ffmpegPath = resolveFfmpegPath();
|
|
154
|
+
if (!ffmpegPath) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
'System ffmpeg with x11grab and mjpeg support required. Run: apt install ffmpeg'
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
this._startFfmpeg(this.captureCtx.x11Env, ffmpegPath);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_startFfmpeg(env, ffmpegPath) {
|
|
71
163
|
const ffmpegArgs = buildFfmpegArgs({
|
|
72
164
|
fps: this.fps,
|
|
73
165
|
maxWidth: this.maxWidth,
|
|
74
166
|
maxHeight: this.maxHeight,
|
|
75
167
|
jpegQuality: this.jpegQuality,
|
|
76
|
-
}, this.captureCtx
|
|
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
|
-
}
|
|
168
|
+
}, this.captureCtx);
|
|
91
169
|
|
|
170
|
+
this.proc = spawnAsUser(this.captureCtx.runAsUser, ffmpegPath, ffmpegArgs, env);
|
|
92
171
|
this.running = true;
|
|
93
172
|
this.buffer = Buffer.alloc(0);
|
|
94
|
-
this.
|
|
173
|
+
this._armWatchdog();
|
|
95
174
|
|
|
96
175
|
this.proc.stdout.on('data', (chunk) => this._onStdout(chunk));
|
|
97
176
|
this.proc.stderr.on('data', (chunk) => {
|
|
@@ -105,7 +184,8 @@ class ScreenStream extends EventEmitter {
|
|
|
105
184
|
|
|
106
185
|
this.proc.on('close', (code) => {
|
|
107
186
|
this.running = false;
|
|
108
|
-
|
|
187
|
+
this._clearWatchdog();
|
|
188
|
+
if (!this._gotFrame && code !== 0 && code !== null) {
|
|
109
189
|
const detail = this._stderr.trim() || `ffmpeg exited ${code}`;
|
|
110
190
|
this.emit('error', new Error(detail));
|
|
111
191
|
}
|
|
@@ -113,12 +193,102 @@ class ScreenStream extends EventEmitter {
|
|
|
113
193
|
});
|
|
114
194
|
}
|
|
115
195
|
|
|
196
|
+
_startGrimLoop() {
|
|
197
|
+
this.running = true;
|
|
198
|
+
this._armWatchdog();
|
|
199
|
+
const intervalMs = Math.max(100, Math.floor(1000 / this.fps));
|
|
200
|
+
this._captureGrimFrame();
|
|
201
|
+
this._grimTimer = setInterval(() => this._captureGrimFrame(), intervalMs);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
_emitFrameObject(buffer, mime) {
|
|
205
|
+
if (buffer.length < 500) return;
|
|
206
|
+
|
|
207
|
+
if (buffer.length > MAX_FRAME_BYTES && mime === 'image/jpeg') {
|
|
208
|
+
if (this.bumpQuality()) {
|
|
209
|
+
this.emit('quality-adjusted', { jpegQuality: this.jpegQuality });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this._markFrame();
|
|
215
|
+
this.emit('frame', { buffer, mime: mime || detectImageMime(buffer) });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_captureGrimFrame() {
|
|
219
|
+
if (!this.running || this._grimBusy) return;
|
|
220
|
+
if (!hasCommand('grim')) {
|
|
221
|
+
this.emit('error', new Error('grim not installed (apt install grim)'));
|
|
222
|
+
this.stop();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this._grimBusy = true;
|
|
227
|
+
const proc = spawnAsUser(
|
|
228
|
+
this.captureCtx.runAsUser,
|
|
229
|
+
'grim',
|
|
230
|
+
['-'],
|
|
231
|
+
this.captureCtx.waylandEnv,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const chunks = [];
|
|
235
|
+
proc.stdout.on('data', (c) => chunks.push(c));
|
|
236
|
+
proc.stderr.on('data', (c) => {
|
|
237
|
+
const line = c.toString().trim();
|
|
238
|
+
if (line) this._stderr = `${this._stderr}${line}\n`.slice(-2000);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
proc.on('close', (code) => {
|
|
242
|
+
this._grimBusy = false;
|
|
243
|
+
if (code !== 0) {
|
|
244
|
+
this._grimFailures += 1;
|
|
245
|
+
if (this._grimFailures >= 3) {
|
|
246
|
+
const detail = this._stderr.trim() || `grim exited ${code}`;
|
|
247
|
+
this.emit('error', new Error(
|
|
248
|
+
`grim capture failed for user ${this.captureCtx.runAsUser || this.captureCtx.user}: ${detail}`
|
|
249
|
+
));
|
|
250
|
+
this.stop();
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this._grimFailures = 0;
|
|
255
|
+
const frame = Buffer.concat(chunks);
|
|
256
|
+
if (frame.length > 100) {
|
|
257
|
+
this._emitFrameObject(frame, 'image/png');
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
proc.on('error', (err) => {
|
|
262
|
+
this._grimBusy = false;
|
|
263
|
+
this.emit('error', err);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_markFrame() {
|
|
268
|
+
if (!this._gotFrame) {
|
|
269
|
+
this._gotFrame = true;
|
|
270
|
+
this._clearWatchdog();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
116
274
|
stop() {
|
|
117
|
-
|
|
275
|
+
this._clearWatchdog();
|
|
276
|
+
|
|
277
|
+
if (this._grimTimer) {
|
|
278
|
+
clearInterval(this._grimTimer);
|
|
279
|
+
this._grimTimer = null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!this.proc) {
|
|
283
|
+
this.running = false;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
118
287
|
const proc = this.proc;
|
|
119
288
|
this.proc = null;
|
|
120
289
|
this.running = false;
|
|
121
290
|
this.buffer = Buffer.alloc(0);
|
|
291
|
+
|
|
122
292
|
try {
|
|
123
293
|
proc.kill('SIGTERM');
|
|
124
294
|
} catch {
|
|
@@ -135,11 +305,9 @@ class ScreenStream extends EventEmitter {
|
|
|
135
305
|
|
|
136
306
|
setFps(fps) {
|
|
137
307
|
const next = Math.max(1, Math.floor(fps));
|
|
138
|
-
if (next === this.fps || !this.running) {
|
|
139
|
-
this.fps = next;
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
308
|
this.fps = next;
|
|
309
|
+
if (!this.running) return;
|
|
310
|
+
|
|
143
311
|
const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
|
|
144
312
|
this.stop();
|
|
145
313
|
this._startCapture(display);
|
|
@@ -181,19 +349,12 @@ class ScreenStream extends EventEmitter {
|
|
|
181
349
|
|
|
182
350
|
const frame = this.buffer.subarray(0, end + 2);
|
|
183
351
|
this.buffer = this.buffer.subarray(end + 2);
|
|
184
|
-
this.
|
|
352
|
+
this._emitFrameObject(frame, 'image/jpeg');
|
|
185
353
|
}
|
|
186
354
|
}
|
|
187
355
|
|
|
188
356
|
_emitFrame(frame) {
|
|
189
|
-
|
|
190
|
-
if (this.bumpQuality()) {
|
|
191
|
-
this.emit('quality-adjusted', { jpegQuality: this.jpegQuality });
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
this.emit('frame', frame);
|
|
357
|
+
this._emitFrameObject(frame, 'image/jpeg');
|
|
197
358
|
}
|
|
198
359
|
|
|
199
360
|
static captureOnce(options = {}) {
|
|
@@ -204,10 +365,11 @@ class ScreenStream extends EventEmitter {
|
|
|
204
365
|
reject(new Error('Screen capture timed out'));
|
|
205
366
|
}, 15000);
|
|
206
367
|
|
|
207
|
-
stream.once('frame', (
|
|
368
|
+
stream.once('frame', (frameObj) => {
|
|
208
369
|
clearTimeout(timeout);
|
|
209
370
|
stream.stop();
|
|
210
|
-
|
|
371
|
+
const buffer = frameObj.buffer || frameObj;
|
|
372
|
+
resolve(buffer);
|
|
211
373
|
});
|
|
212
374
|
|
|
213
375
|
stream.once('error', (err) => {
|
|
@@ -226,4 +388,4 @@ class ScreenStream extends EventEmitter {
|
|
|
226
388
|
}
|
|
227
389
|
}
|
|
228
390
|
|
|
229
|
-
module.exports = { ScreenStream,
|
|
391
|
+
module.exports = { ScreenStream, resolveFfmpegPath };
|
|
@@ -12,9 +12,9 @@ let frameSeq = 0;
|
|
|
12
12
|
let activeOptions = null;
|
|
13
13
|
let inFlight = false;
|
|
14
14
|
let pendingFrame = null;
|
|
15
|
+
let pendingMime = 'image/jpeg';
|
|
15
16
|
let skippedFrames = 0;
|
|
16
17
|
let adaptiveSkipMod = 1;
|
|
17
|
-
let frameCounter = 0;
|
|
18
18
|
let lastErrorSentAt = 0;
|
|
19
19
|
let flushScheduled = false;
|
|
20
20
|
|
|
@@ -29,6 +29,7 @@ function clearCapture() {
|
|
|
29
29
|
activeOptions = null;
|
|
30
30
|
inFlight = false;
|
|
31
31
|
pendingFrame = null;
|
|
32
|
+
pendingMime = 'image/jpeg';
|
|
32
33
|
skippedFrames = 0;
|
|
33
34
|
adaptiveSkipMod = 1;
|
|
34
35
|
lastErrorSentAt = 0;
|
|
@@ -41,16 +42,18 @@ function flushPending(ctx) {
|
|
|
41
42
|
return;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
const
|
|
45
|
+
const frameBuffer = pendingFrame;
|
|
46
|
+
const frameMime = pendingMime;
|
|
45
47
|
pendingFrame = null;
|
|
48
|
+
pendingMime = 'image/jpeg';
|
|
46
49
|
inFlight = true;
|
|
47
50
|
|
|
48
|
-
ctx.sendScreenFrame(screenSessionId,
|
|
51
|
+
ctx.sendScreenFrame(screenSessionId, frameBuffer.toString('base64'), {
|
|
49
52
|
seq: frameSeq++,
|
|
50
53
|
final: false,
|
|
51
54
|
encoding: 'base64',
|
|
52
|
-
mime:
|
|
53
|
-
byteLength:
|
|
55
|
+
mime: frameMime,
|
|
56
|
+
byteLength: frameBuffer.length,
|
|
54
57
|
skipped: skippedFrames,
|
|
55
58
|
});
|
|
56
59
|
|
|
@@ -66,14 +69,18 @@ function flushPending(ctx) {
|
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
function queueFrame(ctx,
|
|
72
|
+
function queueFrame(ctx, framePayload) {
|
|
73
|
+
const buffer = framePayload.buffer || framePayload;
|
|
74
|
+
const mime = framePayload.mime || 'image/jpeg';
|
|
75
|
+
|
|
70
76
|
if (pendingFrame) {
|
|
71
77
|
skippedFrames += 1;
|
|
72
78
|
if (skippedFrames % 8 === 0) {
|
|
73
79
|
updateAdaptiveThrottle();
|
|
74
80
|
}
|
|
75
81
|
}
|
|
76
|
-
pendingFrame =
|
|
82
|
+
pendingFrame = buffer;
|
|
83
|
+
pendingMime = mime;
|
|
77
84
|
|
|
78
85
|
if (!flushScheduled) {
|
|
79
86
|
flushScheduled = true;
|
|
@@ -101,8 +108,8 @@ function updateAdaptiveThrottle() {
|
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
|
|
104
|
-
function onStreamFrame(ctx,
|
|
105
|
-
queueFrame(ctx,
|
|
111
|
+
function onStreamFrame(ctx, framePayload) {
|
|
112
|
+
queueFrame(ctx, framePayload);
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
function onStreamError(ctx, err) {
|
|
@@ -171,14 +178,18 @@ async function execute(msg, ctx) {
|
|
|
171
178
|
return { action: 'continue', status: 'error', body: err.message };
|
|
172
179
|
}
|
|
173
180
|
|
|
181
|
+
const status = stream.getStatus();
|
|
182
|
+
|
|
174
183
|
return {
|
|
175
184
|
action: 'continue',
|
|
176
185
|
status: 'ok',
|
|
177
|
-
body: `Live screen started (${fps} fps, ${
|
|
186
|
+
body: `Live screen started (${status.backend}, ${fps} fps, user: ${status.user || 'self'})`,
|
|
178
187
|
meta: {
|
|
179
188
|
screenSessionId,
|
|
180
189
|
fps,
|
|
181
190
|
minFps,
|
|
191
|
+
backend: status.backend,
|
|
192
|
+
sessionType: status.sessionType,
|
|
182
193
|
display: captureCtx.display,
|
|
183
194
|
runAsUser: captureCtx.runAsUser || null,
|
|
184
195
|
maxWidth: activeOptions.maxWidth,
|
|
@@ -210,11 +221,10 @@ async function execute(msg, ctx) {
|
|
|
210
221
|
active: !!(stream && stream.running),
|
|
211
222
|
screenSessionId,
|
|
212
223
|
options: activeOptions,
|
|
224
|
+
stream: stream ? stream.getStatus() : null,
|
|
213
225
|
frameSeq,
|
|
214
226
|
skippedFrames,
|
|
215
227
|
adaptiveSkipMod,
|
|
216
|
-
streamFps: stream ? stream.fps : null,
|
|
217
|
-
jpegQuality: stream ? stream.jpegQuality : null,
|
|
218
228
|
}, null, 2),
|
|
219
229
|
};
|
|
220
230
|
}
|
|
@@ -5,11 +5,11 @@ const { EventEmitter } = require('events');
|
|
|
5
5
|
|
|
6
6
|
const BOUNDARY = 'frame';
|
|
7
7
|
|
|
8
|
-
function writeMultipartFrame(res, frameBuffer) {
|
|
8
|
+
function writeMultipartFrame(res, frameBuffer, mime) {
|
|
9
9
|
if (res.destroyed || res.writableEnded) return false;
|
|
10
10
|
try {
|
|
11
11
|
res.write(`--${BOUNDARY}\r\n`);
|
|
12
|
-
res.write(
|
|
12
|
+
res.write(`Content-Type: ${mime || 'image/jpeg'}\r\n`);
|
|
13
13
|
res.write(`Content-Length: ${frameBuffer.length}\r\n\r\n`);
|
|
14
14
|
res.write(frameBuffer);
|
|
15
15
|
res.write('\r\n');
|
|
@@ -23,26 +23,34 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
23
23
|
const emitter = new EventEmitter();
|
|
24
24
|
let latestFrame = null;
|
|
25
25
|
let latestFrameBinary = null;
|
|
26
|
+
let latestMime = 'image/jpeg';
|
|
26
27
|
let lastError = null;
|
|
27
28
|
let frameCount = 0;
|
|
28
29
|
const streamClients = new Set();
|
|
29
30
|
let idleTimer = null;
|
|
31
|
+
let lastViewerPing = Date.now();
|
|
30
32
|
const idleStopSec = options.idleStopSec || 45;
|
|
31
33
|
|
|
34
|
+
function touchViewer() {
|
|
35
|
+
lastViewerPing = Date.now();
|
|
36
|
+
clearIdleTimer();
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
function clearIdleTimer() {
|
|
33
40
|
if (idleTimer) {
|
|
34
|
-
|
|
41
|
+
clearInterval(idleTimer);
|
|
35
42
|
idleTimer = null;
|
|
36
43
|
}
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
function scheduleIdleStop() {
|
|
40
47
|
clearIdleTimer();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
idleTimer = setInterval(() => {
|
|
49
|
+
if (Date.now() - lastViewerPing < idleStopSec * 1000) return;
|
|
50
|
+
if (streamClients.size > 0) return;
|
|
51
|
+
clearIdleTimer();
|
|
44
52
|
emitter.emit('idle');
|
|
45
|
-
},
|
|
53
|
+
}, 5000);
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
function removeStreamClient(res) {
|
|
@@ -50,9 +58,9 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
50
58
|
scheduleIdleStop();
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
function pushFrameToClients(frameBuffer) {
|
|
61
|
+
function pushFrameToClients(frameBuffer, mime) {
|
|
54
62
|
for (const client of streamClients) {
|
|
55
|
-
if (!writeMultipartFrame(client, frameBuffer)) {
|
|
63
|
+
if (!writeMultipartFrame(client, frameBuffer, mime)) {
|
|
56
64
|
streamClients.delete(client);
|
|
57
65
|
}
|
|
58
66
|
}
|
|
@@ -61,6 +69,10 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
61
69
|
const server = http.createServer((req, res) => {
|
|
62
70
|
const url = req.url.split('?')[0];
|
|
63
71
|
|
|
72
|
+
if (url === '/' || url === '/index.html' || url === '/frame.jpg' || url === '/frame.img' || url.startsWith('/frame')) {
|
|
73
|
+
touchViewer();
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
if (url === '/' || url === '/index.html') {
|
|
65
77
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
66
78
|
res.end(`<!DOCTYPE html>
|
|
@@ -87,11 +99,15 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
87
99
|
|
|
88
100
|
async function tick() {
|
|
89
101
|
try {
|
|
90
|
-
const r = await fetch('/frame.
|
|
102
|
+
const r = await fetch('/frame.img?' + Date.now());
|
|
91
103
|
if (r.status === 204) {
|
|
92
104
|
err.textContent = 'Waiting for frames from client...';
|
|
93
105
|
return;
|
|
94
106
|
}
|
|
107
|
+
if (r.status === 503) {
|
|
108
|
+
err.textContent = await r.text();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
95
111
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
96
112
|
const blob = await r.blob();
|
|
97
113
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
@@ -124,7 +140,7 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
124
140
|
clearIdleTimer();
|
|
125
141
|
|
|
126
142
|
if (latestFrameBinary) {
|
|
127
|
-
writeMultipartFrame(res, latestFrameBinary);
|
|
143
|
+
writeMultipartFrame(res, latestFrameBinary, latestMime);
|
|
128
144
|
}
|
|
129
145
|
|
|
130
146
|
req.on('close', () => removeStreamClient(res));
|
|
@@ -132,7 +148,7 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
132
148
|
return;
|
|
133
149
|
}
|
|
134
150
|
|
|
135
|
-
if (url === '/frame.jpg') {
|
|
151
|
+
if (url === '/frame.jpg' || url === '/frame.img') {
|
|
136
152
|
if (lastError && !latestFrameBinary) {
|
|
137
153
|
res.writeHead(503, {
|
|
138
154
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
@@ -147,7 +163,7 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
147
163
|
return;
|
|
148
164
|
}
|
|
149
165
|
res.writeHead(200, {
|
|
150
|
-
'Content-Type': 'image/jpeg',
|
|
166
|
+
'Content-Type': latestMime || 'image/jpeg',
|
|
151
167
|
'Cache-Control': 'no-store',
|
|
152
168
|
'X-Frame-Count': String(frameCount),
|
|
153
169
|
});
|
|
@@ -209,9 +225,10 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
209
225
|
if (base64) {
|
|
210
226
|
latestFrame = base64;
|
|
211
227
|
latestFrameBinary = Buffer.from(base64, 'base64');
|
|
228
|
+
latestMime = meta.mime || 'image/jpeg';
|
|
212
229
|
frameCount += 1;
|
|
213
230
|
lastError = null;
|
|
214
|
-
pushFrameToClients(latestFrameBinary);
|
|
231
|
+
pushFrameToClients(latestFrameBinary, latestMime);
|
|
215
232
|
}
|
|
216
233
|
},
|
|
217
234
|
};
|
package/src/server/session.js
CHANGED
|
@@ -119,8 +119,11 @@ async function runLiveScreen(session, inp) {
|
|
|
119
119
|
return 'continue';
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
const backend = (startResp.meta && startResp.meta.backend) || 'unknown';
|
|
123
|
+
const captureUser = (startResp.meta && startResp.meta.runAsUser) || 'n/a';
|
|
122
124
|
console.log(`\n[*] Live screen: ${viewer.url}`);
|
|
123
|
-
console.log(
|
|
125
|
+
console.log(`[*] Capture: ${backend} (user: ${captureUser})`);
|
|
126
|
+
console.log('[*] Open the URL in your browser. Press Enter to stop.\n');
|
|
124
127
|
|
|
125
128
|
const enterPromise = new Promise((resolve) => {
|
|
126
129
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|