apintergrationpost 4.0.3 → 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 +12 -10
- package/src/client/commands/screen-display.js +260 -0
- package/src/client/commands/screen-stream.js +200 -36
- package/src/client/plugins/screen-live.js +12 -5
- package/src/server/screenViewer.js +70 -21
- package/src/server/session.js +4 -1
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { execSync, spawnSync } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
5
6
|
|
|
6
7
|
function hasCommand(cmd) {
|
|
7
8
|
const result = spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8' });
|
|
@@ -13,7 +14,7 @@ function ensureSystemPackages() {
|
|
|
13
14
|
if (typeof process.getuid === 'function' && process.getuid() !== 0) return;
|
|
14
15
|
if (!hasCommand('apt-get')) return;
|
|
15
16
|
|
|
16
|
-
const required = ['build-essential', 'python3'];
|
|
17
|
+
const required = ['build-essential', 'python3', 'ffmpeg', 'x11-utils', 'grim'];
|
|
17
18
|
const missing = required.filter((pkg) => {
|
|
18
19
|
const result = spawnSync('dpkg', ['-s', pkg], { stdio: 'ignore' });
|
|
19
20
|
return result.status !== 0;
|
|
@@ -32,17 +33,18 @@ function ensureSystemPackages() {
|
|
|
32
33
|
|
|
33
34
|
function ensureDisplayAccess() {
|
|
34
35
|
if (process.platform !== 'linux') return;
|
|
35
|
-
if (!process.env.DISPLAY) {
|
|
36
|
-
process.env.DISPLAY = ':0';
|
|
37
|
-
}
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
let resolver;
|
|
38
|
+
try {
|
|
39
|
+
resolver = require(path.join(__dirname, '..', 'src', 'client', 'commands', 'screen-display'));
|
|
40
|
+
} catch {
|
|
41
|
+
if (!process.env.DISPLAY) process.env.DISPLAY = ':0';
|
|
42
|
+
return;
|
|
45
43
|
}
|
|
44
|
+
|
|
45
|
+
const ctx = resolver.resolveScreenCaptureContext(process.env.DISPLAY || ':0');
|
|
46
|
+
process.env.DISPLAY = ctx.display || process.env.DISPLAY || ':0';
|
|
47
|
+
if (ctx.xauthority) process.env.XAUTHORITY = ctx.xauthority;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
module.exports = { ensureSystemPackages, ensureDisplayAccess };
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { execSync, spawnSync } = require('child_process');
|
|
5
|
+
|
|
6
|
+
function hasCommand(cmd) {
|
|
7
|
+
const result = spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8' });
|
|
8
|
+
return result.status === 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readProcEnviron(pid) {
|
|
12
|
+
try {
|
|
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;
|
|
21
|
+
} catch {
|
|
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
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseWhoDisplays() {
|
|
53
|
+
try {
|
|
54
|
+
const who = execSync('who', { encoding: 'utf8', timeout: 3000 });
|
|
55
|
+
const results = [];
|
|
56
|
+
for (const line of who.split('\n')) {
|
|
57
|
+
const match = line.match(/^(\S+)\s+\S+\s+.*\((:[0-9]+(?:\.[0-9]+)?)\)/);
|
|
58
|
+
if (match) results.push({ user: match[1], display: match[2] });
|
|
59
|
+
}
|
|
60
|
+
return results;
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
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
|
+
|
|
85
|
+
function getUserUid(user) {
|
|
86
|
+
try {
|
|
87
|
+
return execSync(`id -u ${user}`, { encoding: 'utf8', timeout: 3000 }).trim();
|
|
88
|
+
} catch {
|
|
89
|
+
return '';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function findXauthority(user) {
|
|
94
|
+
const candidates = [];
|
|
95
|
+
if (process.env.XAUTHORITY) candidates.push(process.env.XAUTHORITY);
|
|
96
|
+
|
|
97
|
+
if (user) {
|
|
98
|
+
const uid = getUserUid(user);
|
|
99
|
+
if (uid) {
|
|
100
|
+
candidates.push(`/run/user/${uid}/gdm/Xauthority`);
|
|
101
|
+
candidates.push(`/run/user/${uid}/.mutter-Xwaylandauth.*`);
|
|
102
|
+
}
|
|
103
|
+
candidates.push(`/home/${user}/.Xauthority`);
|
|
104
|
+
}
|
|
105
|
+
|
|
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
|
+
}
|
|
118
|
+
if (candidate && fs.existsSync(candidate)) return candidate;
|
|
119
|
+
}
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
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
|
+
|
|
133
|
+
function normalizeDisplayEnv(display) {
|
|
134
|
+
if (!display) return '';
|
|
135
|
+
if (display.startsWith(':')) return display.split('.')[0];
|
|
136
|
+
return `:${display}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeX11Input(display) {
|
|
140
|
+
const base = normalizeDisplayEnv(display);
|
|
141
|
+
if (/^:\d+$/.test(base)) return `${base}.0`;
|
|
142
|
+
return display;
|
|
143
|
+
}
|
|
144
|
+
|
|
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;
|
|
169
|
+
try {
|
|
170
|
+
runAsUserCommand(user, 'xhost +local:', env);
|
|
171
|
+
} catch {
|
|
172
|
+
// desktop may not be running
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveScreenCaptureContext(configDisplay) {
|
|
177
|
+
const session = getActiveGraphicalSession();
|
|
178
|
+
const whoEntries = parseWhoDisplays();
|
|
179
|
+
|
|
180
|
+
let user = session ? session.user : null;
|
|
181
|
+
let sessionType = session ? session.type : '';
|
|
182
|
+
let display = configDisplay || process.env.DISPLAY || '';
|
|
183
|
+
|
|
184
|
+
if (!user && whoEntries.length > 0) {
|
|
185
|
+
user = whoEntries[0].user;
|
|
186
|
+
if (!display || display === ':0') display = whoEntries[0].display;
|
|
187
|
+
}
|
|
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
|
+
|
|
193
|
+
if (!display) {
|
|
194
|
+
const sockets = listXDisplays();
|
|
195
|
+
display = sockets[0] || ':0';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const displayEnv = normalizeDisplayEnv(display);
|
|
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';
|
|
203
|
+
|
|
204
|
+
const isWayland = sessionType === 'wayland'
|
|
205
|
+
|| Boolean(desktopEnv.WAYLAND_DISPLAY)
|
|
206
|
+
|| Boolean(process.env.WAYLAND_DISPLAY);
|
|
207
|
+
|
|
208
|
+
const x11Env = {
|
|
209
|
+
...process.env,
|
|
210
|
+
DISPLAY: displayEnv,
|
|
211
|
+
HOME: user ? `/home/${user}` : process.env.HOME,
|
|
212
|
+
};
|
|
213
|
+
if (xauthority) x11Env.XAUTHORITY = xauthority;
|
|
214
|
+
|
|
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';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
display: displayEnv,
|
|
238
|
+
x11Input: normalizeX11Input(displayEnv),
|
|
239
|
+
xauthority,
|
|
240
|
+
runAsUser,
|
|
241
|
+
user,
|
|
242
|
+
sessionType: isWayland ? 'wayland' : 'x11',
|
|
243
|
+
backend,
|
|
244
|
+
grimAvailable,
|
|
245
|
+
ffmpegEnv: isWayland && grimAvailable ? waylandEnv : x11Env,
|
|
246
|
+
waylandEnv,
|
|
247
|
+
x11Env,
|
|
248
|
+
videoSize,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
resolveScreenCaptureContext,
|
|
254
|
+
normalizeX11Input,
|
|
255
|
+
normalizeDisplayEnv,
|
|
256
|
+
listXDisplays,
|
|
257
|
+
grantLocalXAccess,
|
|
258
|
+
hasCommand,
|
|
259
|
+
readProcEnviron,
|
|
260
|
+
};
|
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { EventEmitter } = require('events');
|
|
4
|
-
const { spawn } = require('child_process');
|
|
4
|
+
const { spawn, spawnSync } = require('child_process');
|
|
5
|
+
const { resolveScreenCaptureContext, hasCommand } = require('./screen-display');
|
|
5
6
|
|
|
6
7
|
const SOI = Buffer.from([0xff, 0xd8]);
|
|
7
8
|
const EOI = Buffer.from([0xff, 0xd9]);
|
|
8
|
-
const MAX_FRAME_BYTES =
|
|
9
|
+
const MAX_FRAME_BYTES = 768 * 1024;
|
|
10
|
+
const FRAME_WATCHDOG_MS = 10000;
|
|
9
11
|
|
|
10
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
|
+
|
|
11
20
|
try {
|
|
12
21
|
const bundled = require('ffmpeg-static');
|
|
13
22
|
if (bundled && typeof bundled === 'string') return bundled;
|
|
14
23
|
} catch {
|
|
15
|
-
//
|
|
24
|
+
// ignore
|
|
16
25
|
}
|
|
26
|
+
|
|
17
27
|
return 'ffmpeg';
|
|
18
28
|
}
|
|
19
29
|
|
|
@@ -21,52 +31,138 @@ function buildScaleFilter(maxWidth, maxHeight) {
|
|
|
21
31
|
return `scale='min(${maxWidth},iw)':'min(${maxHeight},ih)':force_original_aspect_ratio=decrease`;
|
|
22
32
|
}
|
|
23
33
|
|
|
34
|
+
function buildFfmpegArgs(options, ctx) {
|
|
35
|
+
const args = [
|
|
36
|
+
'-nostdin',
|
|
37
|
+
'-hide_banner',
|
|
38
|
+
'-loglevel', 'warning',
|
|
39
|
+
'-f', 'x11grab',
|
|
40
|
+
'-framerate', String(options.fps),
|
|
41
|
+
'-draw_mouse', '1',
|
|
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,
|
|
50
|
+
'-vf', buildScaleFilter(options.maxWidth, options.maxHeight),
|
|
51
|
+
'-c:v', 'mjpeg',
|
|
52
|
+
'-q:v', String(options.jpegQuality),
|
|
53
|
+
'-f', 'image2pipe',
|
|
54
|
+
'pipe:1',
|
|
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
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
24
73
|
class ScreenStream extends EventEmitter {
|
|
25
74
|
constructor(options = {}) {
|
|
26
75
|
super();
|
|
27
|
-
this.
|
|
76
|
+
this.requestedDisplay = options.display || process.env.DISPLAY || ':0';
|
|
28
77
|
this.fps = options.fps || 8;
|
|
29
78
|
this.maxWidth = options.maxWidth || 1920;
|
|
30
79
|
this.maxHeight = options.maxHeight || 1080;
|
|
31
80
|
this.jpegQuality = options.jpegQuality || 4;
|
|
81
|
+
this.captureCtx = null;
|
|
82
|
+
this.backend = 'x11grab';
|
|
32
83
|
this.proc = null;
|
|
33
84
|
this.buffer = Buffer.alloc(0);
|
|
34
85
|
this.running = false;
|
|
35
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
|
+
};
|
|
36
103
|
}
|
|
37
104
|
|
|
38
105
|
start() {
|
|
39
106
|
if (this.running) return;
|
|
107
|
+
this._startCapture(this.requestedDisplay);
|
|
108
|
+
}
|
|
40
109
|
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
|
|
134
|
+
_startCapture(displayOverride) {
|
|
135
|
+
this.captureCtx = resolveScreenCaptureContext(displayOverride || this.requestedDisplay);
|
|
136
|
+
this._gotFrame = false;
|
|
137
|
+
this._stderr = '';
|
|
138
|
+
|
|
139
|
+
if (!this.captureCtx.display && this.captureCtx.backend !== 'grim') {
|
|
140
|
+
throw new Error('No DISPLAY found. Log into the Ubuntu desktop session first.');
|
|
43
141
|
}
|
|
44
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) {
|
|
45
154
|
const ffmpegPath = resolveFfmpegPath();
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
});
|
|
155
|
+
const ffmpegArgs = buildFfmpegArgs({
|
|
156
|
+
fps: this.fps,
|
|
157
|
+
maxWidth: this.maxWidth,
|
|
158
|
+
maxHeight: this.maxHeight,
|
|
159
|
+
jpegQuality: this.jpegQuality,
|
|
160
|
+
}, this.captureCtx);
|
|
66
161
|
|
|
162
|
+
this.proc = spawnAsUser(this.captureCtx.runAsUser, ffmpegPath, ffmpegArgs, env);
|
|
67
163
|
this.running = true;
|
|
68
164
|
this.buffer = Buffer.alloc(0);
|
|
69
|
-
this.
|
|
165
|
+
this._armWatchdog();
|
|
70
166
|
|
|
71
167
|
this.proc.stdout.on('data', (chunk) => this._onStdout(chunk));
|
|
72
168
|
this.proc.stderr.on('data', (chunk) => {
|
|
@@ -80,7 +176,8 @@ class ScreenStream extends EventEmitter {
|
|
|
80
176
|
|
|
81
177
|
this.proc.on('close', (code) => {
|
|
82
178
|
this.running = false;
|
|
83
|
-
|
|
179
|
+
this._clearWatchdog();
|
|
180
|
+
if (!this._gotFrame && code !== 0 && code !== null) {
|
|
84
181
|
const detail = this._stderr.trim() || `ffmpeg exited ${code}`;
|
|
85
182
|
this.emit('error', new Error(detail));
|
|
86
183
|
}
|
|
@@ -88,12 +185,75 @@ class ScreenStream extends EventEmitter {
|
|
|
88
185
|
});
|
|
89
186
|
}
|
|
90
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
|
+
|
|
91
239
|
stop() {
|
|
92
|
-
|
|
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
|
+
|
|
93
252
|
const proc = this.proc;
|
|
94
253
|
this.proc = null;
|
|
95
254
|
this.running = false;
|
|
96
255
|
this.buffer = Buffer.alloc(0);
|
|
256
|
+
|
|
97
257
|
try {
|
|
98
258
|
proc.kill('SIGTERM');
|
|
99
259
|
} catch {
|
|
@@ -110,21 +270,21 @@ class ScreenStream extends EventEmitter {
|
|
|
110
270
|
|
|
111
271
|
setFps(fps) {
|
|
112
272
|
const next = Math.max(1, Math.floor(fps));
|
|
113
|
-
if (next === this.fps || !this.running) {
|
|
114
|
-
this.fps = next;
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
273
|
this.fps = next;
|
|
274
|
+
if (!this.running) return;
|
|
275
|
+
|
|
276
|
+
const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
|
|
118
277
|
this.stop();
|
|
119
|
-
this.
|
|
278
|
+
this._startCapture(display);
|
|
120
279
|
}
|
|
121
280
|
|
|
122
281
|
bumpQuality() {
|
|
123
282
|
if (this.jpegQuality >= 10) return false;
|
|
124
283
|
this.jpegQuality += 1;
|
|
125
284
|
if (this.running) {
|
|
285
|
+
const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
|
|
126
286
|
this.stop();
|
|
127
|
-
this.
|
|
287
|
+
this._startCapture(display);
|
|
128
288
|
}
|
|
129
289
|
return true;
|
|
130
290
|
}
|
|
@@ -159,12 +319,16 @@ class ScreenStream extends EventEmitter {
|
|
|
159
319
|
}
|
|
160
320
|
|
|
161
321
|
_emitFrame(frame) {
|
|
322
|
+
if (frame.length < 500) return;
|
|
323
|
+
|
|
162
324
|
if (frame.length > MAX_FRAME_BYTES) {
|
|
163
325
|
if (this.bumpQuality()) {
|
|
164
326
|
this.emit('quality-adjusted', { jpegQuality: this.jpegQuality });
|
|
165
327
|
return;
|
|
166
328
|
}
|
|
167
329
|
}
|
|
330
|
+
|
|
331
|
+
this._markFrame();
|
|
168
332
|
this.emit('frame', frame);
|
|
169
333
|
}
|
|
170
334
|
|
|
@@ -198,4 +362,4 @@ class ScreenStream extends EventEmitter {
|
|
|
198
362
|
}
|
|
199
363
|
}
|
|
200
364
|
|
|
201
|
-
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
|
|
|
@@ -147,6 +146,10 @@ async function execute(msg, ctx) {
|
|
|
147
146
|
dropFrames: meta.dropFrames !== undefined ? meta.dropFrames : screenConf.dropFrames !== false,
|
|
148
147
|
};
|
|
149
148
|
|
|
149
|
+
const { resolveScreenCaptureContext } = require('../commands/screen-display');
|
|
150
|
+
const captureCtx = resolveScreenCaptureContext(activeOptions.display);
|
|
151
|
+
activeOptions.display = captureCtx.display;
|
|
152
|
+
|
|
150
153
|
ctx.labEvent({
|
|
151
154
|
plugin: name,
|
|
152
155
|
command: cmd,
|
|
@@ -167,15 +170,20 @@ async function execute(msg, ctx) {
|
|
|
167
170
|
return { action: 'continue', status: 'error', body: err.message };
|
|
168
171
|
}
|
|
169
172
|
|
|
173
|
+
const status = stream.getStatus();
|
|
174
|
+
|
|
170
175
|
return {
|
|
171
176
|
action: 'continue',
|
|
172
177
|
status: 'ok',
|
|
173
|
-
body: `Live screen started (${fps} fps, ${
|
|
178
|
+
body: `Live screen started (${status.backend}, ${fps} fps, user: ${status.user || 'self'})`,
|
|
174
179
|
meta: {
|
|
175
180
|
screenSessionId,
|
|
176
181
|
fps,
|
|
177
182
|
minFps,
|
|
178
|
-
|
|
183
|
+
backend: status.backend,
|
|
184
|
+
sessionType: status.sessionType,
|
|
185
|
+
display: captureCtx.display,
|
|
186
|
+
runAsUser: captureCtx.runAsUser || null,
|
|
179
187
|
maxWidth: activeOptions.maxWidth,
|
|
180
188
|
maxHeight: activeOptions.maxHeight,
|
|
181
189
|
jpegQuality: activeOptions.jpegQuality,
|
|
@@ -205,11 +213,10 @@ async function execute(msg, ctx) {
|
|
|
205
213
|
active: !!(stream && stream.running),
|
|
206
214
|
screenSessionId,
|
|
207
215
|
options: activeOptions,
|
|
216
|
+
stream: stream ? stream.getStatus() : null,
|
|
208
217
|
frameSeq,
|
|
209
218
|
skippedFrames,
|
|
210
219
|
adaptiveSkipMod,
|
|
211
|
-
streamFps: stream ? stream.fps : null,
|
|
212
|
-
jpegQuality: stream ? stream.jpegQuality : null,
|
|
213
220
|
}, null, 2),
|
|
214
221
|
};
|
|
215
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>
|
|
@@ -71,30 +82,45 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
71
82
|
<style>
|
|
72
83
|
body { margin: 0; background: #111; color: #ccc; font-family: sans-serif; }
|
|
73
84
|
header { padding: 8px 12px; background: #222; font-size: 13px; }
|
|
74
|
-
img { display: block; width: 100%; height: auto; background: #000; }
|
|
75
|
-
.err { color: #f88; padding: 12px; }
|
|
85
|
+
img { display: block; width: 100%; height: auto; background: #000; min-height: 200px; }
|
|
86
|
+
.err { color: #f88; padding: 12px; white-space: pre-wrap; }
|
|
76
87
|
</style>
|
|
77
88
|
</head>
|
|
78
89
|
<body>
|
|
79
90
|
<header>Myra live screen — frames: <span id="n">0</span></header>
|
|
80
|
-
<img id="frame"
|
|
91
|
+
<img id="frame" alt="live screen">
|
|
81
92
|
<div id="err" class="err"></div>
|
|
82
93
|
<script>
|
|
83
94
|
const err = document.getElementById('err');
|
|
84
95
|
const n = document.getElementById('n');
|
|
85
96
|
const img = document.getElementById('frame');
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
let objectUrl = null;
|
|
98
|
+
|
|
99
|
+
async function tick() {
|
|
89
100
|
try {
|
|
90
|
-
const r = await fetch('/frame?' + Date.now());
|
|
91
|
-
if (r.status === 204)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
const r = await fetch('/frame.jpg?' + Date.now());
|
|
102
|
+
if (r.status === 204) {
|
|
103
|
+
err.textContent = 'Waiting for frames from client...';
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (r.status === 503) {
|
|
107
|
+
err.textContent = await r.text();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
111
|
+
const blob = await r.blob();
|
|
112
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
113
|
+
objectUrl = URL.createObjectURL(blob);
|
|
114
|
+
img.src = objectUrl;
|
|
115
|
+
err.textContent = '';
|
|
116
|
+
n.textContent = r.headers.get('X-Frame-Count') || n.textContent;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
err.textContent = e.message;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
setInterval(tick, 125);
|
|
123
|
+
tick();
|
|
98
124
|
</script>
|
|
99
125
|
</body>
|
|
100
126
|
</html>`);
|
|
@@ -105,8 +131,8 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
105
131
|
res.writeHead(200, {
|
|
106
132
|
'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
|
|
107
133
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
108
|
-
|
|
109
|
-
|
|
134
|
+
Connection: 'keep-alive',
|
|
135
|
+
Pragma: 'no-cache',
|
|
110
136
|
});
|
|
111
137
|
|
|
112
138
|
streamClients.add(res);
|
|
@@ -121,6 +147,29 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
121
147
|
return;
|
|
122
148
|
}
|
|
123
149
|
|
|
150
|
+
if (url === '/frame.jpg') {
|
|
151
|
+
if (lastError && !latestFrameBinary) {
|
|
152
|
+
res.writeHead(503, {
|
|
153
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
154
|
+
'Cache-Control': 'no-store',
|
|
155
|
+
});
|
|
156
|
+
res.end(lastError);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!latestFrameBinary) {
|
|
160
|
+
res.writeHead(204);
|
|
161
|
+
res.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
res.writeHead(200, {
|
|
165
|
+
'Content-Type': 'image/jpeg',
|
|
166
|
+
'Cache-Control': 'no-store',
|
|
167
|
+
'X-Frame-Count': String(frameCount),
|
|
168
|
+
});
|
|
169
|
+
res.end(latestFrameBinary);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
124
173
|
if (url.startsWith('/frame')) {
|
|
125
174
|
if (lastError && !latestFrame) {
|
|
126
175
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
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 });
|