apintergrationpost 4.0.3 → 4.0.4
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
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' });
|
|
@@ -32,17 +33,25 @@ 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
|
-
|
|
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
|
+
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
|
+
}
|
|
44
48
|
}
|
|
49
|
+
return;
|
|
45
50
|
}
|
|
51
|
+
|
|
52
|
+
const ctx = resolver.resolveScreenCaptureContext(process.env.DISPLAY || ':0');
|
|
53
|
+
process.env.DISPLAY = ctx.display;
|
|
54
|
+
if (ctx.xauthority) process.env.XAUTHORITY = ctx.xauthority;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
module.exports = { ensureSystemPackages, ensureDisplayAccess };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
|
|
6
|
+
function listXDisplays() {
|
|
7
|
+
try {
|
|
8
|
+
return fs.readdirSync('/tmp/.X11-unix')
|
|
9
|
+
.filter((name) => /^X\d+$/.test(name))
|
|
10
|
+
.map((name) => `:${name.slice(1)}`);
|
|
11
|
+
} catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseWhoDisplays() {
|
|
17
|
+
try {
|
|
18
|
+
const who = execSync('who', { encoding: 'utf8', timeout: 3000 });
|
|
19
|
+
const results = [];
|
|
20
|
+
for (const line of who.split('\n')) {
|
|
21
|
+
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
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return results;
|
|
27
|
+
} catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getUserUid(user) {
|
|
33
|
+
try {
|
|
34
|
+
return execSync(`id -u ${user}`, { encoding: 'utf8', timeout: 3000 }).trim();
|
|
35
|
+
} catch {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findXauthority(user) {
|
|
41
|
+
const candidates = [];
|
|
42
|
+
if (process.env.XAUTHORITY) candidates.push(process.env.XAUTHORITY);
|
|
43
|
+
if (user) {
|
|
44
|
+
const uid = getUserUid(user);
|
|
45
|
+
if (uid) candidates.push(`/run/user/${uid}/gdm/Xauthority`);
|
|
46
|
+
candidates.push(`/home/${user}/.Xauthority`);
|
|
47
|
+
}
|
|
48
|
+
for (const candidate of candidates) {
|
|
49
|
+
if (candidate && fs.existsSync(candidate)) return candidate;
|
|
50
|
+
}
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeDisplayEnv(display) {
|
|
55
|
+
if (!display) return ':0';
|
|
56
|
+
if (display.startsWith(':')) return display.split('.')[0];
|
|
57
|
+
return `:${display}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeX11Input(display) {
|
|
61
|
+
const base = normalizeDisplayEnv(display);
|
|
62
|
+
if (/^:\d+$/.test(base)) return `${base}.0`;
|
|
63
|
+
return display;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function grantLocalXAccess(env) {
|
|
67
|
+
try {
|
|
68
|
+
execSync('xhost +local:', { env, stdio: 'ignore', timeout: 3000 });
|
|
69
|
+
} catch {
|
|
70
|
+
// desktop may not be running
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveScreenCaptureContext(configDisplay) {
|
|
75
|
+
const whoEntries = parseWhoDisplays();
|
|
76
|
+
let display = configDisplay || process.env.DISPLAY || '';
|
|
77
|
+
let runAsUser = null;
|
|
78
|
+
|
|
79
|
+
if (whoEntries.length > 0) {
|
|
80
|
+
const active = whoEntries.find((entry) => entry.display) || whoEntries[0];
|
|
81
|
+
if (!display || display === ':0') {
|
|
82
|
+
display = active.display;
|
|
83
|
+
}
|
|
84
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
85
|
+
runAsUser = active.user;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!display) {
|
|
90
|
+
const sockets = listXDisplays();
|
|
91
|
+
display = sockets[0] || ':0';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const displayEnv = normalizeDisplayEnv(display);
|
|
95
|
+
const xauthority = findXauthority(runAsUser) || process.env.XAUTHORITY || '';
|
|
96
|
+
|
|
97
|
+
const ffmpegEnv = {
|
|
98
|
+
...process.env,
|
|
99
|
+
DISPLAY: displayEnv,
|
|
100
|
+
};
|
|
101
|
+
if (xauthority) ffmpegEnv.XAUTHORITY = xauthority;
|
|
102
|
+
if (runAsUser) ffmpegEnv.HOME = `/home/${runAsUser}`;
|
|
103
|
+
|
|
104
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
105
|
+
grantLocalXAccess(ffmpegEnv);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
display: displayEnv,
|
|
110
|
+
x11Input: normalizeX11Input(displayEnv),
|
|
111
|
+
xauthority,
|
|
112
|
+
runAsUser,
|
|
113
|
+
ffmpegEnv,
|
|
114
|
+
wayland: process.env.XDG_SESSION_TYPE === 'wayland' || Boolean(process.env.WAYLAND_DISPLAY),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isFrameMostlyBlack(jpegBuffer) {
|
|
119
|
+
// Failed captures often produce tiny invalid payloads
|
|
120
|
+
return !jpegBuffer || jpegBuffer.length < 800;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
resolveScreenCaptureContext,
|
|
125
|
+
normalizeX11Input,
|
|
126
|
+
normalizeDisplayEnv,
|
|
127
|
+
isFrameMostlyBlack,
|
|
128
|
+
listXDisplays,
|
|
129
|
+
grantLocalXAccess,
|
|
130
|
+
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { EventEmitter } = require('events');
|
|
4
4
|
const { spawn } = require('child_process');
|
|
5
|
+
const { resolveScreenCaptureContext } = require('./screen-display');
|
|
5
6
|
|
|
6
7
|
const SOI = Buffer.from([0xff, 0xd8]);
|
|
7
8
|
const EOI = Buffer.from([0xff, 0xd9]);
|
|
@@ -21,14 +22,33 @@ function buildScaleFilter(maxWidth, maxHeight) {
|
|
|
21
22
|
return `scale='min(${maxWidth},iw)':'min(${maxHeight},ih)':force_original_aspect_ratio=decrease`;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function buildFfmpegArgs(options, x11Input) {
|
|
26
|
+
return [
|
|
27
|
+
'-nostdin',
|
|
28
|
+
'-hide_banner',
|
|
29
|
+
'-loglevel', 'error',
|
|
30
|
+
'-f', 'x11grab',
|
|
31
|
+
'-framerate', String(options.fps),
|
|
32
|
+
'-draw_mouse', '1',
|
|
33
|
+
'-i', x11Input,
|
|
34
|
+
'-vf', buildScaleFilter(options.maxWidth, options.maxHeight),
|
|
35
|
+
'-c:v', 'mjpeg',
|
|
36
|
+
'-q:v', String(options.jpegQuality),
|
|
37
|
+
'-huffman', 'optimal',
|
|
38
|
+
'-f', 'mpjpeg',
|
|
39
|
+
'pipe:1',
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
class ScreenStream extends EventEmitter {
|
|
25
44
|
constructor(options = {}) {
|
|
26
45
|
super();
|
|
27
|
-
this.
|
|
46
|
+
this.requestedDisplay = options.display || process.env.DISPLAY || ':0';
|
|
28
47
|
this.fps = options.fps || 8;
|
|
29
48
|
this.maxWidth = options.maxWidth || 1920;
|
|
30
49
|
this.maxHeight = options.maxHeight || 1080;
|
|
31
50
|
this.jpegQuality = options.jpegQuality || 4;
|
|
51
|
+
this.captureCtx = null;
|
|
32
52
|
this.proc = null;
|
|
33
53
|
this.buffer = Buffer.alloc(0);
|
|
34
54
|
this.running = false;
|
|
@@ -37,32 +57,37 @@ class ScreenStream extends EventEmitter {
|
|
|
37
57
|
|
|
38
58
|
start() {
|
|
39
59
|
if (this.running) return;
|
|
60
|
+
this._startCapture(this.requestedDisplay);
|
|
61
|
+
}
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
_startCapture(displayOverride) {
|
|
64
|
+
this.captureCtx = resolveScreenCaptureContext(displayOverride || this.requestedDisplay);
|
|
65
|
+
|
|
66
|
+
if (!this.captureCtx.display) {
|
|
67
|
+
throw new Error('No DISPLAY found. Log into the Ubuntu desktop session first.');
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
const ffmpegPath = resolveFfmpegPath();
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
'-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
71
|
+
const ffmpegArgs = buildFfmpegArgs({
|
|
72
|
+
fps: this.fps,
|
|
73
|
+
maxWidth: this.maxWidth,
|
|
74
|
+
maxHeight: this.maxHeight,
|
|
75
|
+
jpegQuality: this.jpegQuality,
|
|
76
|
+
}, this.captureCtx.x11Input);
|
|
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
|
+
}
|
|
66
91
|
|
|
67
92
|
this.running = true;
|
|
68
93
|
this.buffer = Buffer.alloc(0);
|
|
@@ -115,16 +140,18 @@ class ScreenStream extends EventEmitter {
|
|
|
115
140
|
return;
|
|
116
141
|
}
|
|
117
142
|
this.fps = next;
|
|
143
|
+
const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
|
|
118
144
|
this.stop();
|
|
119
|
-
this.
|
|
145
|
+
this._startCapture(display);
|
|
120
146
|
}
|
|
121
147
|
|
|
122
148
|
bumpQuality() {
|
|
123
149
|
if (this.jpegQuality >= 10) return false;
|
|
124
150
|
this.jpegQuality += 1;
|
|
125
151
|
if (this.running) {
|
|
152
|
+
const display = this.captureCtx ? this.captureCtx.display : this.requestedDisplay;
|
|
126
153
|
this.stop();
|
|
127
|
-
this.
|
|
154
|
+
this._startCapture(display);
|
|
128
155
|
}
|
|
129
156
|
return true;
|
|
130
157
|
}
|
|
@@ -165,6 +192,7 @@ class ScreenStream extends EventEmitter {
|
|
|
165
192
|
return;
|
|
166
193
|
}
|
|
167
194
|
}
|
|
195
|
+
|
|
168
196
|
this.emit('frame', frame);
|
|
169
197
|
}
|
|
170
198
|
|
|
@@ -147,6 +147,10 @@ async function execute(msg, ctx) {
|
|
|
147
147
|
dropFrames: meta.dropFrames !== undefined ? meta.dropFrames : screenConf.dropFrames !== false,
|
|
148
148
|
};
|
|
149
149
|
|
|
150
|
+
const { resolveScreenCaptureContext } = require('../commands/screen-display');
|
|
151
|
+
const captureCtx = resolveScreenCaptureContext(activeOptions.display);
|
|
152
|
+
activeOptions.display = captureCtx.display;
|
|
153
|
+
|
|
150
154
|
ctx.labEvent({
|
|
151
155
|
plugin: name,
|
|
152
156
|
command: cmd,
|
|
@@ -175,7 +179,8 @@ async function execute(msg, ctx) {
|
|
|
175
179
|
screenSessionId,
|
|
176
180
|
fps,
|
|
177
181
|
minFps,
|
|
178
|
-
display:
|
|
182
|
+
display: captureCtx.display,
|
|
183
|
+
runAsUser: captureCtx.runAsUser || null,
|
|
179
184
|
maxWidth: activeOptions.maxWidth,
|
|
180
185
|
maxHeight: activeOptions.maxHeight,
|
|
181
186
|
jpegQuality: activeOptions.jpegQuality,
|
|
@@ -71,30 +71,41 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
71
71
|
<style>
|
|
72
72
|
body { margin: 0; background: #111; color: #ccc; font-family: sans-serif; }
|
|
73
73
|
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; }
|
|
74
|
+
img { display: block; width: 100%; height: auto; background: #000; min-height: 200px; }
|
|
75
|
+
.err { color: #f88; padding: 12px; white-space: pre-wrap; }
|
|
76
76
|
</style>
|
|
77
77
|
</head>
|
|
78
78
|
<body>
|
|
79
79
|
<header>Myra live screen — frames: <span id="n">0</span></header>
|
|
80
|
-
<img id="frame"
|
|
80
|
+
<img id="frame" alt="live screen">
|
|
81
81
|
<div id="err" class="err"></div>
|
|
82
82
|
<script>
|
|
83
83
|
const err = document.getElementById('err');
|
|
84
84
|
const n = document.getElementById('n');
|
|
85
85
|
const img = document.getElementById('frame');
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
let objectUrl = null;
|
|
87
|
+
|
|
88
|
+
async function tick() {
|
|
89
89
|
try {
|
|
90
|
-
const r = await fetch('/frame?' + Date.now());
|
|
91
|
-
if (r.status === 204)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
const r = await fetch('/frame.jpg?' + Date.now());
|
|
91
|
+
if (r.status === 204) {
|
|
92
|
+
err.textContent = 'Waiting for frames from client...';
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
96
|
+
const blob = await r.blob();
|
|
97
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
98
|
+
objectUrl = URL.createObjectURL(blob);
|
|
99
|
+
img.src = objectUrl;
|
|
100
|
+
err.textContent = '';
|
|
101
|
+
n.textContent = r.headers.get('X-Frame-Count') || n.textContent;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
err.textContent = e.message;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setInterval(tick, 125);
|
|
108
|
+
tick();
|
|
98
109
|
</script>
|
|
99
110
|
</body>
|
|
100
111
|
</html>`);
|
|
@@ -105,8 +116,8 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
105
116
|
res.writeHead(200, {
|
|
106
117
|
'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
|
|
107
118
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
Connection: 'keep-alive',
|
|
120
|
+
Pragma: 'no-cache',
|
|
110
121
|
});
|
|
111
122
|
|
|
112
123
|
streamClients.add(res);
|
|
@@ -121,6 +132,29 @@ function createScreenViewer(port = 5555, options = {}) {
|
|
|
121
132
|
return;
|
|
122
133
|
}
|
|
123
134
|
|
|
135
|
+
if (url === '/frame.jpg') {
|
|
136
|
+
if (lastError && !latestFrameBinary) {
|
|
137
|
+
res.writeHead(503, {
|
|
138
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
139
|
+
'Cache-Control': 'no-store',
|
|
140
|
+
});
|
|
141
|
+
res.end(lastError);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!latestFrameBinary) {
|
|
145
|
+
res.writeHead(204);
|
|
146
|
+
res.end();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
res.writeHead(200, {
|
|
150
|
+
'Content-Type': 'image/jpeg',
|
|
151
|
+
'Cache-Control': 'no-store',
|
|
152
|
+
'X-Frame-Count': String(frameCount),
|
|
153
|
+
});
|
|
154
|
+
res.end(latestFrameBinary);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
124
158
|
if (url.startsWith('/frame')) {
|
|
125
159
|
if (lastError && !latestFrame) {
|
|
126
160
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|