apintergrationpost 4.0.4 → 4.0.5
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 +171 -35
- package/src/client/plugins/screen-live.js +6 -4
- package/src/server/screenViewer.js +20 -5
- 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,20 +1,29 @@
|
|
|
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() {
|
|
13
|
+
const probe = spawnSync('sh', ['-c', 'ffmpeg -hide_banner -devices 2>&1 | grep x11grab || true'], {
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
});
|
|
16
|
+
if (probe.stdout && probe.stdout.includes('x11grab')) {
|
|
17
|
+
return 'ffmpeg';
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
try {
|
|
13
21
|
const bundled = require('ffmpeg-static');
|
|
14
22
|
if (bundled && typeof bundled === 'string') return bundled;
|
|
15
23
|
} catch {
|
|
16
|
-
//
|
|
24
|
+
// ignore
|
|
17
25
|
}
|
|
26
|
+
|
|
18
27
|
return 'ffmpeg';
|
|
19
28
|
}
|
|
20
29
|
|
|
@@ -22,22 +31,43 @@ function buildScaleFilter(maxWidth, maxHeight) {
|
|
|
22
31
|
return `scale='min(${maxWidth},iw)':'min(${maxHeight},ih)':force_original_aspect_ratio=decrease`;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
function buildFfmpegArgs(options,
|
|
26
|
-
|
|
34
|
+
function buildFfmpegArgs(options, ctx) {
|
|
35
|
+
const args = [
|
|
27
36
|
'-nostdin',
|
|
28
37
|
'-hide_banner',
|
|
29
|
-
'-loglevel', '
|
|
38
|
+
'-loglevel', 'warning',
|
|
30
39
|
'-f', 'x11grab',
|
|
31
40
|
'-framerate', String(options.fps),
|
|
32
41
|
'-draw_mouse', '1',
|
|
33
|
-
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
if (ctx.videoSize && ctx.videoSize.width && ctx.videoSize.height) {
|
|
45
|
+
args.push('-video_size', `${ctx.videoSize.width}x${ctx.videoSize.height}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
args.push(
|
|
49
|
+
'-i', ctx.x11Input,
|
|
34
50
|
'-vf', buildScaleFilter(options.maxWidth, options.maxHeight),
|
|
35
51
|
'-c:v', 'mjpeg',
|
|
36
52
|
'-q:v', String(options.jpegQuality),
|
|
37
|
-
'-
|
|
38
|
-
'-f', 'mpjpeg',
|
|
53
|
+
'-f', 'image2pipe',
|
|
39
54
|
'pipe:1',
|
|
40
|
-
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return args;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function spawnAsUser(runAsUser, command, args, env) {
|
|
61
|
+
if (runAsUser && typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
62
|
+
return spawn('runuser', ['-u', runAsUser, '--', command, ...args], {
|
|
63
|
+
env,
|
|
64
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return spawn(command, args, {
|
|
68
|
+
env,
|
|
69
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
70
|
+
});
|
|
41
71
|
}
|
|
42
72
|
|
|
43
73
|
class ScreenStream extends EventEmitter {
|
|
@@ -49,10 +79,27 @@ class ScreenStream extends EventEmitter {
|
|
|
49
79
|
this.maxHeight = options.maxHeight || 1080;
|
|
50
80
|
this.jpegQuality = options.jpegQuality || 4;
|
|
51
81
|
this.captureCtx = null;
|
|
82
|
+
this.backend = 'x11grab';
|
|
52
83
|
this.proc = null;
|
|
53
84
|
this.buffer = Buffer.alloc(0);
|
|
54
85
|
this.running = false;
|
|
55
86
|
this._stderr = '';
|
|
87
|
+
this._gotFrame = false;
|
|
88
|
+
this._frameWatchdog = null;
|
|
89
|
+
this._grimTimer = null;
|
|
90
|
+
this._grimBusy = false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getStatus() {
|
|
94
|
+
return {
|
|
95
|
+
running: this.running,
|
|
96
|
+
backend: this.backend,
|
|
97
|
+
display: this.captureCtx ? this.captureCtx.display : null,
|
|
98
|
+
user: this.captureCtx ? this.captureCtx.runAsUser || this.captureCtx.user : null,
|
|
99
|
+
sessionType: this.captureCtx ? this.captureCtx.sessionType : null,
|
|
100
|
+
gotFrame: this._gotFrame,
|
|
101
|
+
stderr: this._stderr.slice(-500),
|
|
102
|
+
};
|
|
56
103
|
}
|
|
57
104
|
|
|
58
105
|
start() {
|
|
@@ -60,38 +107,62 @@ class ScreenStream extends EventEmitter {
|
|
|
60
107
|
this._startCapture(this.requestedDisplay);
|
|
61
108
|
}
|
|
62
109
|
|
|
110
|
+
_clearWatchdog() {
|
|
111
|
+
if (this._frameWatchdog) {
|
|
112
|
+
clearTimeout(this._frameWatchdog);
|
|
113
|
+
this._frameWatchdog = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_armWatchdog() {
|
|
118
|
+
this._clearWatchdog();
|
|
119
|
+
this._frameWatchdog = setTimeout(() => {
|
|
120
|
+
if (!this._gotFrame) {
|
|
121
|
+
const ctx = this.captureCtx || {};
|
|
122
|
+
const hint = ctx.sessionType === 'wayland' && !ctx.grimAvailable
|
|
123
|
+
? ' Ubuntu Wayland detected — install grim: apt install grim'
|
|
124
|
+
: ' Log into the desktop GUI and run: xhost +local: (as desktop user)';
|
|
125
|
+
this.emit('error', new Error(
|
|
126
|
+
`No screen frames received (${this.backend}, DISPLAY=${ctx.display || '?'}, user=${ctx.runAsUser || ctx.user || '?'}).${hint}`
|
|
127
|
+
+ (this._stderr ? ` ffmpeg: ${this._stderr.trim().slice(0, 180)}` : '')
|
|
128
|
+
));
|
|
129
|
+
this.stop();
|
|
130
|
+
}
|
|
131
|
+
}, FRAME_WATCHDOG_MS);
|
|
132
|
+
}
|
|
133
|
+
|
|
63
134
|
_startCapture(displayOverride) {
|
|
64
135
|
this.captureCtx = resolveScreenCaptureContext(displayOverride || this.requestedDisplay);
|
|
136
|
+
this._gotFrame = false;
|
|
137
|
+
this._stderr = '';
|
|
65
138
|
|
|
66
|
-
if (!this.captureCtx.display) {
|
|
139
|
+
if (!this.captureCtx.display && this.captureCtx.backend !== 'grim') {
|
|
67
140
|
throw new Error('No DISPLAY found. Log into the Ubuntu desktop session first.');
|
|
68
141
|
}
|
|
69
142
|
|
|
143
|
+
if (this.captureCtx.backend === 'grim') {
|
|
144
|
+
this.backend = 'grim';
|
|
145
|
+
this._startGrimLoop();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.backend = 'x11grab';
|
|
150
|
+
this._startFfmpeg(this.captureCtx.x11Env);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_startFfmpeg(env) {
|
|
70
154
|
const ffmpegPath = resolveFfmpegPath();
|
|
71
155
|
const ffmpegArgs = buildFfmpegArgs({
|
|
72
156
|
fps: this.fps,
|
|
73
157
|
maxWidth: this.maxWidth,
|
|
74
158
|
maxHeight: this.maxHeight,
|
|
75
159
|
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
|
-
}
|
|
160
|
+
}, this.captureCtx);
|
|
91
161
|
|
|
162
|
+
this.proc = spawnAsUser(this.captureCtx.runAsUser, ffmpegPath, ffmpegArgs, env);
|
|
92
163
|
this.running = true;
|
|
93
164
|
this.buffer = Buffer.alloc(0);
|
|
94
|
-
this.
|
|
165
|
+
this._armWatchdog();
|
|
95
166
|
|
|
96
167
|
this.proc.stdout.on('data', (chunk) => this._onStdout(chunk));
|
|
97
168
|
this.proc.stderr.on('data', (chunk) => {
|
|
@@ -105,7 +176,8 @@ class ScreenStream extends EventEmitter {
|
|
|
105
176
|
|
|
106
177
|
this.proc.on('close', (code) => {
|
|
107
178
|
this.running = false;
|
|
108
|
-
|
|
179
|
+
this._clearWatchdog();
|
|
180
|
+
if (!this._gotFrame && code !== 0 && code !== null) {
|
|
109
181
|
const detail = this._stderr.trim() || `ffmpeg exited ${code}`;
|
|
110
182
|
this.emit('error', new Error(detail));
|
|
111
183
|
}
|
|
@@ -113,12 +185,75 @@ class ScreenStream extends EventEmitter {
|
|
|
113
185
|
});
|
|
114
186
|
}
|
|
115
187
|
|
|
188
|
+
_startGrimLoop() {
|
|
189
|
+
this.running = true;
|
|
190
|
+
this._armWatchdog();
|
|
191
|
+
const intervalMs = Math.max(100, Math.floor(1000 / this.fps));
|
|
192
|
+
this._captureGrimFrame();
|
|
193
|
+
this._grimTimer = setInterval(() => this._captureGrimFrame(), intervalMs);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_captureGrimFrame() {
|
|
197
|
+
if (!this.running || this._grimBusy) return;
|
|
198
|
+
if (!hasCommand('grim')) {
|
|
199
|
+
this.emit('error', new Error('grim not installed (apt install grim)'));
|
|
200
|
+
this.stop();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this._grimBusy = true;
|
|
205
|
+
const proc = spawnAsUser(
|
|
206
|
+
this.captureCtx.runAsUser,
|
|
207
|
+
'grim',
|
|
208
|
+
['-t', 'jpeg', '-'],
|
|
209
|
+
this.captureCtx.waylandEnv,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const chunks = [];
|
|
213
|
+
proc.stdout.on('data', (c) => chunks.push(c));
|
|
214
|
+
proc.stderr.on('data', (c) => { this._stderr += c.toString(); });
|
|
215
|
+
|
|
216
|
+
proc.on('close', (code) => {
|
|
217
|
+
this._grimBusy = false;
|
|
218
|
+
if (code !== 0) return;
|
|
219
|
+
const frame = Buffer.concat(chunks);
|
|
220
|
+
if (frame.length > 100) {
|
|
221
|
+
this._markFrame();
|
|
222
|
+
this.emit('frame', frame);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
proc.on('error', (err) => {
|
|
227
|
+
this._grimBusy = false;
|
|
228
|
+
this.emit('error', err);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_markFrame() {
|
|
233
|
+
if (!this._gotFrame) {
|
|
234
|
+
this._gotFrame = true;
|
|
235
|
+
this._clearWatchdog();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
116
239
|
stop() {
|
|
117
|
-
|
|
240
|
+
this._clearWatchdog();
|
|
241
|
+
|
|
242
|
+
if (this._grimTimer) {
|
|
243
|
+
clearInterval(this._grimTimer);
|
|
244
|
+
this._grimTimer = null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!this.proc) {
|
|
248
|
+
this.running = false;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
118
252
|
const proc = this.proc;
|
|
119
253
|
this.proc = null;
|
|
120
254
|
this.running = false;
|
|
121
255
|
this.buffer = Buffer.alloc(0);
|
|
256
|
+
|
|
122
257
|
try {
|
|
123
258
|
proc.kill('SIGTERM');
|
|
124
259
|
} catch {
|
|
@@ -135,11 +270,9 @@ class ScreenStream extends EventEmitter {
|
|
|
135
270
|
|
|
136
271
|
setFps(fps) {
|
|
137
272
|
const next = Math.max(1, Math.floor(fps));
|
|
138
|
-
if (next === this.fps || !this.running) {
|
|
139
|
-
this.fps = next;
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
273
|
this.fps = next;
|
|
274
|
+
if (!this.running) return;
|
|
275
|
+
|
|
143
276
|
const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
|
|
144
277
|
this.stop();
|
|
145
278
|
this._startCapture(display);
|
|
@@ -186,6 +319,8 @@ class ScreenStream extends EventEmitter {
|
|
|
186
319
|
}
|
|
187
320
|
|
|
188
321
|
_emitFrame(frame) {
|
|
322
|
+
if (frame.length < 500) return;
|
|
323
|
+
|
|
189
324
|
if (frame.length > MAX_FRAME_BYTES) {
|
|
190
325
|
if (this.bumpQuality()) {
|
|
191
326
|
this.emit('quality-adjusted', { jpegQuality: this.jpegQuality });
|
|
@@ -193,6 +328,7 @@ class ScreenStream extends EventEmitter {
|
|
|
193
328
|
}
|
|
194
329
|
}
|
|
195
330
|
|
|
331
|
+
this._markFrame();
|
|
196
332
|
this.emit('frame', frame);
|
|
197
333
|
}
|
|
198
334
|
|
|
@@ -226,4 +362,4 @@ class ScreenStream extends EventEmitter {
|
|
|
226
362
|
}
|
|
227
363
|
}
|
|
228
364
|
|
|
229
|
-
module.exports = { ScreenStream,
|
|
365
|
+
module.exports = { ScreenStream, resolveFfmpegPath };
|
|
@@ -14,7 +14,6 @@ let inFlight = false;
|
|
|
14
14
|
let pendingFrame = null;
|
|
15
15
|
let skippedFrames = 0;
|
|
16
16
|
let adaptiveSkipMod = 1;
|
|
17
|
-
let frameCounter = 0;
|
|
18
17
|
let lastErrorSentAt = 0;
|
|
19
18
|
let flushScheduled = false;
|
|
20
19
|
|
|
@@ -171,14 +170,18 @@ async function execute(msg, ctx) {
|
|
|
171
170
|
return { action: 'continue', status: 'error', body: err.message };
|
|
172
171
|
}
|
|
173
172
|
|
|
173
|
+
const status = stream.getStatus();
|
|
174
|
+
|
|
174
175
|
return {
|
|
175
176
|
action: 'continue',
|
|
176
177
|
status: 'ok',
|
|
177
|
-
body: `Live screen started (${fps} fps, ${
|
|
178
|
+
body: `Live screen started (${status.backend}, ${fps} fps, user: ${status.user || 'self'})`,
|
|
178
179
|
meta: {
|
|
179
180
|
screenSessionId,
|
|
180
181
|
fps,
|
|
181
182
|
minFps,
|
|
183
|
+
backend: status.backend,
|
|
184
|
+
sessionType: status.sessionType,
|
|
182
185
|
display: captureCtx.display,
|
|
183
186
|
runAsUser: captureCtx.runAsUser || null,
|
|
184
187
|
maxWidth: activeOptions.maxWidth,
|
|
@@ -210,11 +213,10 @@ async function execute(msg, ctx) {
|
|
|
210
213
|
active: !!(stream && stream.running),
|
|
211
214
|
screenSessionId,
|
|
212
215
|
options: activeOptions,
|
|
216
|
+
stream: stream ? stream.getStatus() : null,
|
|
213
217
|
frameSeq,
|
|
214
218
|
skippedFrames,
|
|
215
219
|
adaptiveSkipMod,
|
|
216
|
-
streamFps: stream ? stream.fps : null,
|
|
217
|
-
jpegQuality: stream ? stream.jpegQuality : null,
|
|
218
220
|
}, null, 2),
|
|
219
221
|
};
|
|
220
222
|
}
|
|
@@ -27,22 +27,29 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
27
27
|
let frameCount = 0;
|
|
28
28
|
const streamClients = new Set();
|
|
29
29
|
let idleTimer = null;
|
|
30
|
+
let lastViewerPing = Date.now();
|
|
30
31
|
const idleStopSec = options.idleStopSec || 45;
|
|
31
32
|
|
|
33
|
+
function touchViewer() {
|
|
34
|
+
lastViewerPing = Date.now();
|
|
35
|
+
clearIdleTimer();
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
function clearIdleTimer() {
|
|
33
39
|
if (idleTimer) {
|
|
34
|
-
|
|
40
|
+
clearInterval(idleTimer);
|
|
35
41
|
idleTimer = null;
|
|
36
42
|
}
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
function scheduleIdleStop() {
|
|
40
46
|
clearIdleTimer();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
idleTimer = setInterval(() => {
|
|
48
|
+
if (Date.now() - lastViewerPing < idleStopSec * 1000) return;
|
|
49
|
+
if (streamClients.size > 0) return;
|
|
50
|
+
clearIdleTimer();
|
|
44
51
|
emitter.emit('idle');
|
|
45
|
-
},
|
|
52
|
+
}, 5000);
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
function removeStreamClient(res) {
|
|
@@ -61,6 +68,10 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
61
68
|
const server = http.createServer((req, res) => {
|
|
62
69
|
const url = req.url.split('?')[0];
|
|
63
70
|
|
|
71
|
+
if (url === '/' || url === '/index.html' || url === '/frame.jpg' || url.startsWith('/frame')) {
|
|
72
|
+
touchViewer();
|
|
73
|
+
}
|
|
74
|
+
|
|
64
75
|
if (url === '/' || url === '/index.html') {
|
|
65
76
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
66
77
|
res.end(`<!DOCTYPE html>
|
|
@@ -92,6 +103,10 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
92
103
|
err.textContent = 'Waiting for frames from client...';
|
|
93
104
|
return;
|
|
94
105
|
}
|
|
106
|
+
if (r.status === 503) {
|
|
107
|
+
err.textContent = await r.text();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
95
110
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
96
111
|
const blob = await r.blob();
|
|
97
112
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
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 });
|