neoagent 2.4.1-beta.6 → 2.4.1-beta.8
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/flutter_app/lib/src/backend_client.dart +44 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +32 -3
- package/flutter_app/lib/src/desktop_companion_io.dart +83 -0
- package/flutter_app/lib/src/stream_renderer.dart +155 -0
- package/package.json +1 -1
- package/server/guest_agent.js +7 -0
- package/server/http/routes.js +1 -0
- package/server/http/socket.js +1 -1
- package/server/index.js +4 -1
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/routes/stream.js +178 -0
- package/server/services/android/controller.js +6 -2
- package/server/services/browser/controller.js +16 -0
- package/server/services/desktop/gateway.js +28 -3
- package/server/services/desktop/protocol.js +32 -0
- package/server/services/desktop/provider.js +10 -0
- package/server/services/desktop/registry.js +41 -0
- package/server/services/manager.js +11 -0
- package/server/services/runtime/backends/local-vm.js +6 -0
- package/server/services/runtime/manager.js +4 -0
- package/server/services/streaming/android-stream.js +72 -0
- package/server/services/streaming/browser-stream.js +87 -0
- package/server/services/streaming/stream-hub.js +231 -0
- package/server/services/websocket.js +73 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const { requireAuth } = require('../middleware/auth');
|
|
5
|
+
const { sanitizeError } = require('../utils/security');
|
|
6
|
+
const { AndroidStream } = require('../services/streaming/android-stream');
|
|
7
|
+
const { BrowserStream } = require('../services/streaming/browser-stream');
|
|
8
|
+
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
router.use(requireAuth);
|
|
11
|
+
|
|
12
|
+
function boundedInt(value, fallback, min, max) {
|
|
13
|
+
const parsed = Number(value);
|
|
14
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
15
|
+
return Math.min(max, Math.max(min, Math.floor(parsed)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function streamHub(req) {
|
|
19
|
+
const hub = req.app?.locals?.streamHub;
|
|
20
|
+
if (!hub) throw new Error('Stream hub is unavailable.');
|
|
21
|
+
return hub;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function desktopProvider(req) {
|
|
25
|
+
const factory = req.app?.locals?.getDesktopProviderForUser;
|
|
26
|
+
if (typeof factory !== 'function') {
|
|
27
|
+
throw new Error('Desktop provider is unavailable.');
|
|
28
|
+
}
|
|
29
|
+
return factory(req.session?.userId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function androidController(req) {
|
|
33
|
+
const runtimeManager = req.app?.locals?.runtimeManager;
|
|
34
|
+
if (runtimeManager && typeof runtimeManager.getAndroidProviderForUser === 'function') {
|
|
35
|
+
return runtimeManager.getAndroidProviderForUser(req.session?.userId);
|
|
36
|
+
}
|
|
37
|
+
throw new Error('Android controller is unavailable.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function browserController(req) {
|
|
41
|
+
const runtimeManager = req.app?.locals?.runtimeManager;
|
|
42
|
+
if (runtimeManager && typeof runtimeManager.getBrowserProviderForUser === 'function') {
|
|
43
|
+
return runtimeManager.getBrowserProviderForUser(req.session?.userId);
|
|
44
|
+
}
|
|
45
|
+
throw new Error('Browser controller is unavailable.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizePlatform(value) {
|
|
49
|
+
const platform = String(value || '').trim().toLowerCase();
|
|
50
|
+
if (platform === 'desktop' || platform === 'android' || platform === 'browser') return platform;
|
|
51
|
+
const error = new Error('platform must be desktop, android, or browser.');
|
|
52
|
+
error.status = 400;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeDeviceId(platform, value) {
|
|
57
|
+
const deviceId = String(value || '').trim();
|
|
58
|
+
if (deviceId) return deviceId;
|
|
59
|
+
if (platform === 'browser') return 'browser';
|
|
60
|
+
const error = new Error('deviceId is required.');
|
|
61
|
+
error.status = 400;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function resolveStartDeviceId(platform, req) {
|
|
66
|
+
const rawDeviceId = String(req.body?.deviceId || '').trim();
|
|
67
|
+
if (platform === 'browser') return { deviceId: 'browser', controller: null };
|
|
68
|
+
if (platform === 'android') {
|
|
69
|
+
const controller = await androidController(req);
|
|
70
|
+
const status = typeof controller.getStatus === 'function'
|
|
71
|
+
? await controller.getStatus().catch(() => ({}))
|
|
72
|
+
: {};
|
|
73
|
+
const adbSerial = String(status?.adbSerial || controller.adbSerial || '').trim();
|
|
74
|
+
if (rawDeviceId && adbSerial && rawDeviceId !== adbSerial) {
|
|
75
|
+
const error = new Error('Android deviceId does not match the active emulator serial.');
|
|
76
|
+
error.status = 400;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (rawDeviceId) return { deviceId: rawDeviceId, controller };
|
|
80
|
+
if (!adbSerial) throw new Error('Android deviceId is required when no emulator serial is available.');
|
|
81
|
+
return { deviceId: adbSerial, controller };
|
|
82
|
+
}
|
|
83
|
+
if (rawDeviceId) return { deviceId: rawDeviceId, controller: null };
|
|
84
|
+
return { deviceId: normalizeDeviceId(platform, rawDeviceId), controller: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function resolveStopDeviceId(platform, req) {
|
|
88
|
+
const rawDeviceId = String(req.body?.deviceId || '').trim();
|
|
89
|
+
if (rawDeviceId) return rawDeviceId;
|
|
90
|
+
if (platform === 'browser') return 'browser';
|
|
91
|
+
if (platform === 'android') {
|
|
92
|
+
const controller = await androidController(req);
|
|
93
|
+
const status = typeof controller.getStatus === 'function'
|
|
94
|
+
? await controller.getStatus().catch(() => ({}))
|
|
95
|
+
: {};
|
|
96
|
+
const adbSerial = String(status?.adbSerial || controller.adbSerial || '').trim();
|
|
97
|
+
if (adbSerial) return adbSerial;
|
|
98
|
+
}
|
|
99
|
+
const error = new Error('deviceId is required.');
|
|
100
|
+
error.status = 400;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sendError(res, err) {
|
|
105
|
+
res.status(Number(err?.status || 500)).json({ error: sanitizeError(err) });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
router.post('/start', async (req, res) => {
|
|
109
|
+
try {
|
|
110
|
+
const userId = req.session?.userId;
|
|
111
|
+
const platform = normalizePlatform(req.body?.platform);
|
|
112
|
+
const resolved = await resolveStartDeviceId(platform, req);
|
|
113
|
+
const deviceId = resolved.deviceId;
|
|
114
|
+
const fps = boundedInt(req.body?.fps, platform === 'android' ? 10 : 15, 1, platform === 'android' ? 15 : 20);
|
|
115
|
+
const quality = boundedInt(req.body?.quality, platform === 'android' ? 75 : 80, 30, 95);
|
|
116
|
+
const hub = streamHub(req);
|
|
117
|
+
await hub.stopStream(userId, platform, deviceId, 'restart');
|
|
118
|
+
|
|
119
|
+
if (platform === 'desktop') {
|
|
120
|
+
const provider = await desktopProvider(req);
|
|
121
|
+
const result = await provider.startStream({
|
|
122
|
+
deviceId,
|
|
123
|
+
fps,
|
|
124
|
+
quality,
|
|
125
|
+
displayId: req.body?.displayId || null,
|
|
126
|
+
});
|
|
127
|
+
const resolvedDeviceId = result?.deviceId || result?.device?.deviceId || deviceId;
|
|
128
|
+
hub.markStarted(userId, resolvedDeviceId, platform, { fps, quality }, () =>
|
|
129
|
+
provider.stopStream({ deviceId: resolvedDeviceId }));
|
|
130
|
+
return res.json({ ok: true, platform, deviceId: resolvedDeviceId, fps, quality });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (platform === 'android') {
|
|
134
|
+
const controller = resolved.controller || await androidController(req);
|
|
135
|
+
const stream = new AndroidStream({ userId, deviceId, controller, streamHub: hub, fps, quality });
|
|
136
|
+
stream.start();
|
|
137
|
+
hub.markStarted(userId, deviceId, platform, { fps, quality }, () => stream.stop());
|
|
138
|
+
return res.json({ ok: true, platform, deviceId, fps, quality });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const controller = await browserController(req);
|
|
142
|
+
const stream = new BrowserStream({ userId, deviceId, controller, streamHub: hub, fps, quality });
|
|
143
|
+
stream.start();
|
|
144
|
+
hub.markStarted(userId, deviceId, platform, { fps, quality }, () => stream.stop());
|
|
145
|
+
return res.json({ ok: true, platform, deviceId, fps, quality });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
sendError(res, err);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
router.post('/stop', async (req, res) => {
|
|
152
|
+
try {
|
|
153
|
+
const platform = normalizePlatform(req.body?.platform);
|
|
154
|
+
const deviceId = await resolveStopDeviceId(platform, req);
|
|
155
|
+
const stopped = await streamHub(req).stopStream(req.session?.userId, platform, deviceId, 'api_stop');
|
|
156
|
+
res.json({ ok: true, stopped, platform, deviceId });
|
|
157
|
+
} catch (err) {
|
|
158
|
+
sendError(res, err);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
router.get('/status', async (req, res) => {
|
|
163
|
+
try {
|
|
164
|
+
const platform = req.query?.platform ? normalizePlatform(req.query.platform) : null;
|
|
165
|
+
const deviceId = req.query?.deviceId
|
|
166
|
+
? normalizeDeviceId(platform || 'desktop', req.query.deviceId)
|
|
167
|
+
: null;
|
|
168
|
+
const hub = streamHub(req);
|
|
169
|
+
if (deviceId) {
|
|
170
|
+
return res.json(hub.status(req.session?.userId, platform || 'desktop', deviceId));
|
|
171
|
+
}
|
|
172
|
+
return res.json({ streams: hub.listStatus(req.session?.userId), platform });
|
|
173
|
+
} catch (err) {
|
|
174
|
+
sendError(res, err);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
module.exports = router;
|
|
@@ -347,12 +347,16 @@ class AndroidController {
|
|
|
347
347
|
// ── Actions ───────────────────────────────────────────────────────────────
|
|
348
348
|
|
|
349
349
|
async screenshot(_opts = {}) {
|
|
350
|
-
const
|
|
351
|
-
const r = this.#adbCapture(serial, ['exec-out', 'screencap', '-p']);
|
|
350
|
+
const r = await this.capturePng();
|
|
352
351
|
if (!r?.length) throw new Error('screencap returned no data');
|
|
353
352
|
return { screenshotPath: this.#saveArtifact(r) };
|
|
354
353
|
}
|
|
355
354
|
|
|
355
|
+
async capturePng(_opts = {}) {
|
|
356
|
+
const serial = this.#requireSerial();
|
|
357
|
+
return this.#adbCapture(serial, ['exec-out', 'screencap', '-p']);
|
|
358
|
+
}
|
|
359
|
+
|
|
356
360
|
async observe(_opts = {}) { return this.screenshot(); }
|
|
357
361
|
|
|
358
362
|
async tap({ x, y } = {}) {
|
|
@@ -453,6 +453,22 @@ class BrowserController {
|
|
|
453
453
|
};
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
async screenshotJpeg(quality = 80, options = {}) {
|
|
457
|
+
const page = await this.ensurePage();
|
|
458
|
+
const screenshotOptions = {
|
|
459
|
+
type: 'jpeg',
|
|
460
|
+
quality: Math.min(95, Math.max(30, Math.floor(Number(quality) || 80))),
|
|
461
|
+
fullPage: options.fullPage === true,
|
|
462
|
+
};
|
|
463
|
+
if (options.selector) {
|
|
464
|
+
const element = await page.$(options.selector);
|
|
465
|
+
if (element) {
|
|
466
|
+
return element.screenshot(screenshotOptions);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return page.screenshot(screenshotOptions);
|
|
470
|
+
}
|
|
471
|
+
|
|
456
472
|
async navigate(url, options = {}) {
|
|
457
473
|
const page = await this.ensurePage();
|
|
458
474
|
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
const { WebSocketServer } = require('ws');
|
|
2
|
-
const {
|
|
2
|
+
const {
|
|
3
|
+
DESKTOP_COMPANION_WS_PATH,
|
|
4
|
+
FRAME_TYPE_VIDEO,
|
|
5
|
+
MAX_DESKTOP_STREAM_FRAME_BYTES,
|
|
6
|
+
parseBinaryFrame,
|
|
7
|
+
parseDesktopMessage,
|
|
8
|
+
} = require('./protocol');
|
|
3
9
|
const {
|
|
4
10
|
assertDesktopHelloAuth,
|
|
5
11
|
isDesktopCompanionHello,
|
|
@@ -72,8 +78,11 @@ function createUpgradeThrottleObserver() {
|
|
|
72
78
|
return { record, snapshot };
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware) {
|
|
76
|
-
const wss = new WebSocketServer({
|
|
81
|
+
function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware, streamHub = null) {
|
|
82
|
+
const wss = new WebSocketServer({
|
|
83
|
+
noServer: true,
|
|
84
|
+
maxPayload: MAX_DESKTOP_STREAM_FRAME_BYTES,
|
|
85
|
+
});
|
|
77
86
|
const upgradeAttempts = new Map();
|
|
78
87
|
const upgradeThrottleObserver = createUpgradeThrottleObserver();
|
|
79
88
|
|
|
@@ -173,6 +182,22 @@ function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware) {
|
|
|
173
182
|
device,
|
|
174
183
|
}));
|
|
175
184
|
ws.on('message', (nextData) => {
|
|
185
|
+
const activeStreamHub = streamHub || app?.locals?.streamHub || null;
|
|
186
|
+
if (
|
|
187
|
+
activeStreamHub
|
|
188
|
+
&& Buffer.isBuffer(nextData)
|
|
189
|
+
&& nextData.length > 10
|
|
190
|
+
&& nextData[0] === FRAME_TYPE_VIDEO
|
|
191
|
+
) {
|
|
192
|
+
const frame = parseBinaryFrame(nextData);
|
|
193
|
+
if (frame) {
|
|
194
|
+
activeStreamHub.handleFrame(req.session.userId, device.deviceId, {
|
|
195
|
+
...frame,
|
|
196
|
+
platform: 'desktop',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
176
201
|
let parsed;
|
|
177
202
|
try {
|
|
178
203
|
parsed = parseDesktopMessage(nextData);
|
|
@@ -3,6 +3,8 @@ const DESKTOP_COMPANION_WS_PATH = '/api/desktop/ws';
|
|
|
3
3
|
const DESKTOP_COMMANDS = Object.freeze({
|
|
4
4
|
GET_STATUS: 'getStatus',
|
|
5
5
|
CAPTURE_FRAME: 'captureFrame',
|
|
6
|
+
STREAM_START: 'startStream',
|
|
7
|
+
STREAM_STOP: 'stopStream',
|
|
6
8
|
OBSERVE: 'observe',
|
|
7
9
|
CLICK: 'click',
|
|
8
10
|
DRAG: 'drag',
|
|
@@ -18,6 +20,9 @@ const DESKTOP_COMMANDS = Object.freeze({
|
|
|
18
20
|
PING: 'ping',
|
|
19
21
|
});
|
|
20
22
|
|
|
23
|
+
const FRAME_TYPE_VIDEO = 0x01;
|
|
24
|
+
const MAX_DESKTOP_STREAM_FRAME_BYTES = 8 * 1024 * 1024;
|
|
25
|
+
|
|
21
26
|
class DesktopCompanionUnavailableError extends Error {
|
|
22
27
|
constructor(message = 'Desktop companion is not connected.') {
|
|
23
28
|
super(message);
|
|
@@ -55,11 +60,38 @@ function parseDesktopMessage(data) {
|
|
|
55
60
|
}
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
function parseBinaryFrame(buffer) {
|
|
64
|
+
if (
|
|
65
|
+
!Buffer.isBuffer(buffer)
|
|
66
|
+
|| buffer.length <= 10
|
|
67
|
+
|| buffer.length > MAX_DESKTOP_STREAM_FRAME_BYTES
|
|
68
|
+
|| buffer[0] !== FRAME_TYPE_VIDEO
|
|
69
|
+
) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const jpeg = buffer.subarray(10);
|
|
73
|
+
const hasJpegMarkers = jpeg.length >= 4
|
|
74
|
+
&& jpeg[0] === 0xff
|
|
75
|
+
&& jpeg[1] === 0xd8
|
|
76
|
+
&& jpeg[jpeg.length - 2] === 0xff
|
|
77
|
+
&& jpeg[jpeg.length - 1] === 0xd9;
|
|
78
|
+
if (!hasJpegMarkers) return null;
|
|
79
|
+
return {
|
|
80
|
+
seq: buffer.readUInt32BE(1),
|
|
81
|
+
ts: buffer.readUInt32BE(5),
|
|
82
|
+
flags: buffer.readUInt8(9),
|
|
83
|
+
jpeg,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
58
87
|
module.exports = {
|
|
59
88
|
DESKTOP_COMPANION_WS_PATH,
|
|
60
89
|
DESKTOP_COMMANDS,
|
|
90
|
+
FRAME_TYPE_VIDEO,
|
|
91
|
+
MAX_DESKTOP_STREAM_FRAME_BYTES,
|
|
61
92
|
DesktopCompanionUnavailableError,
|
|
62
93
|
DesktopCompanionSelectionError,
|
|
63
94
|
createDesktopCommandMessage,
|
|
95
|
+
parseBinaryFrame,
|
|
64
96
|
parseDesktopMessage,
|
|
65
97
|
};
|
|
@@ -130,6 +130,16 @@ class DesktopProvider {
|
|
|
130
130
|
return this._dispatch(DESKTOP_COMMANDS.CAPTURE_FRAME, options);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
startStream(options = {}) {
|
|
134
|
+
this._assertReady();
|
|
135
|
+
return this.registry.startStream(this.userId, options.deviceId || null, options);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
stopStream(options = {}) {
|
|
139
|
+
this._assertReady();
|
|
140
|
+
return this.registry.stopStream(this.userId, options.deviceId || null);
|
|
141
|
+
}
|
|
142
|
+
|
|
133
143
|
observe(options = {}) {
|
|
134
144
|
return this._dispatch(DESKTOP_COMMANDS.OBSERVE, options);
|
|
135
145
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
2
|
const db = require('../../db/database');
|
|
3
3
|
const {
|
|
4
|
+
DESKTOP_COMMANDS,
|
|
5
|
+
FRAME_TYPE_VIDEO,
|
|
4
6
|
DesktopCompanionSelectionError,
|
|
5
7
|
DesktopCompanionUnavailableError,
|
|
6
8
|
createDesktopCommandMessage,
|
|
@@ -373,6 +375,42 @@ class DesktopCompanionRegistry {
|
|
|
373
375
|
};
|
|
374
376
|
}
|
|
375
377
|
|
|
378
|
+
async startStream(userId, deviceId, options = {}) {
|
|
379
|
+
const device = this.resolveDevice(userId, deviceId);
|
|
380
|
+
const connection = this.getConnection(userId, device.deviceId);
|
|
381
|
+
if (!connection || !connection.isOpen()) {
|
|
382
|
+
throw new DesktopCompanionUnavailableError();
|
|
383
|
+
}
|
|
384
|
+
const result = await connection.sendCommand(DESKTOP_COMMANDS.STREAM_START, {
|
|
385
|
+
fps: options.fps,
|
|
386
|
+
quality: options.quality,
|
|
387
|
+
displayId: options.displayId || device.activeDisplayId || null,
|
|
388
|
+
}, options);
|
|
389
|
+
connection._streaming = true;
|
|
390
|
+
return {
|
|
391
|
+
...result,
|
|
392
|
+
success: result?.success !== false,
|
|
393
|
+
deviceId: device.deviceId,
|
|
394
|
+
device: this.getDeviceRecordByDeviceId(userId, device.deviceId),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async stopStream(userId, deviceId) {
|
|
399
|
+
const device = this.resolveDevice(userId, deviceId);
|
|
400
|
+
const connection = this.getConnection(userId, device.deviceId);
|
|
401
|
+
if (!connection || !connection.isOpen()) {
|
|
402
|
+
throw new DesktopCompanionUnavailableError();
|
|
403
|
+
}
|
|
404
|
+
const result = await connection.sendCommand(DESKTOP_COMMANDS.STREAM_STOP, {});
|
|
405
|
+
connection._streaming = false;
|
|
406
|
+
return {
|
|
407
|
+
...result,
|
|
408
|
+
success: result?.success !== false,
|
|
409
|
+
deviceId: device.deviceId,
|
|
410
|
+
device: this.getDeviceRecordByDeviceId(userId, device.deviceId),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
376
414
|
getStatus(userId) {
|
|
377
415
|
const devices = this.listDevices(userId);
|
|
378
416
|
const onlineDevices = devices.filter((device) => device.online);
|
|
@@ -511,6 +549,9 @@ class DesktopCompanionConnection {
|
|
|
511
549
|
}
|
|
512
550
|
|
|
513
551
|
_handleMessage(data) {
|
|
552
|
+
if (Buffer.isBuffer(data) && data.length > 0 && data[0] === FRAME_TYPE_VIDEO) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
514
555
|
let message;
|
|
515
556
|
try {
|
|
516
557
|
message = parseDesktopMessage(data);
|
|
@@ -438,6 +438,7 @@ function configureRealtime(app, io, services) {
|
|
|
438
438
|
recordingManager: services.recordingManager,
|
|
439
439
|
memoryManager: services.memoryManager,
|
|
440
440
|
voiceRuntimeManager: services.voiceRuntimeManager,
|
|
441
|
+
streamHub: app.locals.streamHub || services.streamHub || null,
|
|
441
442
|
app,
|
|
442
443
|
});
|
|
443
444
|
app.locals.io = io;
|
|
@@ -516,6 +517,7 @@ async function startServices(app, io) {
|
|
|
516
517
|
recordingManager,
|
|
517
518
|
memoryManager,
|
|
518
519
|
voiceRuntimeManager,
|
|
520
|
+
streamHub: app.locals.streamHub || null,
|
|
519
521
|
});
|
|
520
522
|
|
|
521
523
|
resumePendingRecordingSessions(recordingManager);
|
|
@@ -541,6 +543,15 @@ async function stopServices(app) {
|
|
|
541
543
|
}
|
|
542
544
|
}
|
|
543
545
|
|
|
546
|
+
if (app.locals.streamHub) {
|
|
547
|
+
try {
|
|
548
|
+
await app.locals.streamHub.shutdown();
|
|
549
|
+
logServiceReady('Stream hub stopped');
|
|
550
|
+
} catch (err) {
|
|
551
|
+
console.error('[StreamHub] Shutdown error:', getErrorMessage(err));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
544
555
|
if (app.locals.memoryIngestionService) {
|
|
545
556
|
try {
|
|
546
557
|
app.locals.memoryIngestionService.stop();
|
|
@@ -253,6 +253,12 @@ class VmBrowserProvider {
|
|
|
253
253
|
extract(selector, attribute, all = false) { return this.client.request('POST', '/browser/extract', { selector, attribute, all }); }
|
|
254
254
|
evaluate(script) { return this.client.request('POST', '/browser/execute', { code: script }); }
|
|
255
255
|
async screenshot(options = {}) { return this.#materialize(await this.client.request('POST', '/browser/screenshot', options)); }
|
|
256
|
+
async screenshotJpeg(quality = 80, options = {}) {
|
|
257
|
+
const result = await this.client.request('POST', '/browser/screenshot-jpeg', { ...options, quality });
|
|
258
|
+
const content = String(result?.contentBase64 || '');
|
|
259
|
+
if (!content) throw new Error('VM browser screenshot-jpeg returned no data.');
|
|
260
|
+
return Buffer.from(content, 'base64');
|
|
261
|
+
}
|
|
256
262
|
launch(options = {}) { return this.client.request('POST', '/browser/launch', options); }
|
|
257
263
|
closeBrowser() { return this.client.request('POST', '/browser/close'); }
|
|
258
264
|
fill(selector, value) { return this.type(selector, value); }
|
|
@@ -101,6 +101,10 @@ class RuntimeManager {
|
|
|
101
101
|
const settings = this.getSettings(userId);
|
|
102
102
|
if (settings.cli_backend === 'desktop' && this.hasActiveDesktopCompanion(userId)) {
|
|
103
103
|
const desktopProvider = this.getDesktopCliProvider(userId);
|
|
104
|
+
const preferredDeviceId = settings.cli_desktop_device_id;
|
|
105
|
+
if (preferredDeviceId) {
|
|
106
|
+
desktopProvider.selectDevice(preferredDeviceId);
|
|
107
|
+
}
|
|
104
108
|
return {
|
|
105
109
|
backend: 'desktop-companion',
|
|
106
110
|
execute: (command, options = {}) => desktopProvider.executeCommand(command, options),
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const sharp = require('sharp');
|
|
4
|
+
|
|
5
|
+
function clampInt(value, fallback, min, max) {
|
|
6
|
+
const parsed = Number(value);
|
|
7
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
8
|
+
return Math.min(max, Math.max(min, Math.floor(parsed)));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class AndroidStream {
|
|
12
|
+
constructor({ userId, deviceId, controller, streamHub, fps = 10, quality = 75 }) {
|
|
13
|
+
this.userId = String(userId || '');
|
|
14
|
+
this.deviceId = String(deviceId || '');
|
|
15
|
+
this.controller = controller;
|
|
16
|
+
this.streamHub = streamHub;
|
|
17
|
+
this.fps = clampInt(fps, 10, 1, 15);
|
|
18
|
+
this.quality = clampInt(quality, 75, 30, 95);
|
|
19
|
+
this._timer = null;
|
|
20
|
+
this._capturing = false;
|
|
21
|
+
this._seq = 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
start() {
|
|
25
|
+
if (this._timer) return;
|
|
26
|
+
const interval = Math.max(1, Math.floor(1000 / this.fps));
|
|
27
|
+
this._timer = setInterval(() => {
|
|
28
|
+
void this._captureOnce();
|
|
29
|
+
}, interval);
|
|
30
|
+
this._timer.unref?.();
|
|
31
|
+
void this._captureOnce();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
stop() {
|
|
35
|
+
if (this._timer) {
|
|
36
|
+
clearInterval(this._timer);
|
|
37
|
+
this._timer = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async _captureOnce() {
|
|
42
|
+
if (this._capturing) return;
|
|
43
|
+
this._capturing = true;
|
|
44
|
+
try {
|
|
45
|
+
if (!this.controller || typeof this.controller.capturePng !== 'function') {
|
|
46
|
+
throw new Error('Android streaming requires a controller with capturePng().');
|
|
47
|
+
}
|
|
48
|
+
const png = await this.controller.capturePng({ deviceId: this.deviceId });
|
|
49
|
+
if (!png?.length) return;
|
|
50
|
+
const jpeg = await sharp(png).jpeg({ quality: this.quality }).toBuffer();
|
|
51
|
+
this.streamHub.handleFrame(this.userId, this.deviceId, {
|
|
52
|
+
jpeg,
|
|
53
|
+
platform: 'android',
|
|
54
|
+
seq: this._seq++ >>> 0,
|
|
55
|
+
ts: Date.now() >>> 0,
|
|
56
|
+
flags: 1,
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn('[AndroidStream] frame capture failed', {
|
|
60
|
+
userId: this.userId,
|
|
61
|
+
deviceId: this.deviceId,
|
|
62
|
+
error: String(error?.message || error),
|
|
63
|
+
});
|
|
64
|
+
} finally {
|
|
65
|
+
this._capturing = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
AndroidStream,
|
|
72
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const sharp = require('sharp');
|
|
5
|
+
|
|
6
|
+
function clampInt(value, fallback, min, max) {
|
|
7
|
+
const parsed = Number(value);
|
|
8
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
9
|
+
return Math.min(max, Math.max(min, Math.floor(parsed)));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class BrowserStream {
|
|
13
|
+
constructor({ userId, deviceId = 'browser', controller, streamHub, fps = 15, quality = 80 }) {
|
|
14
|
+
this.userId = String(userId || '');
|
|
15
|
+
this.deviceId = String(deviceId || 'browser');
|
|
16
|
+
this.controller = controller;
|
|
17
|
+
this.streamHub = streamHub;
|
|
18
|
+
this.fps = clampInt(fps, 15, 1, 20);
|
|
19
|
+
this.quality = clampInt(quality, 80, 30, 95);
|
|
20
|
+
this._timer = null;
|
|
21
|
+
this._capturing = false;
|
|
22
|
+
this._seq = 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
start() {
|
|
26
|
+
if (this._timer) return;
|
|
27
|
+
const interval = Math.max(1, Math.floor(1000 / this.fps));
|
|
28
|
+
this._timer = setInterval(() => {
|
|
29
|
+
void this._captureOnce();
|
|
30
|
+
}, interval);
|
|
31
|
+
this._timer.unref?.();
|
|
32
|
+
void this._captureOnce();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
stop() {
|
|
36
|
+
if (this._timer) {
|
|
37
|
+
clearInterval(this._timer);
|
|
38
|
+
this._timer = null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async _captureOnce() {
|
|
43
|
+
if (this._capturing) return;
|
|
44
|
+
this._capturing = true;
|
|
45
|
+
try {
|
|
46
|
+
const jpeg = await this._captureJpeg();
|
|
47
|
+
if (!jpeg?.length) return;
|
|
48
|
+
this.streamHub.handleFrame(this.userId, this.deviceId, {
|
|
49
|
+
jpeg,
|
|
50
|
+
platform: 'browser',
|
|
51
|
+
seq: this._seq++ >>> 0,
|
|
52
|
+
ts: Date.now() >>> 0,
|
|
53
|
+
flags: 1,
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.warn('[BrowserStream] frame capture failed', {
|
|
57
|
+
userId: this.userId,
|
|
58
|
+
deviceId: this.deviceId,
|
|
59
|
+
error: String(error?.message || error),
|
|
60
|
+
});
|
|
61
|
+
} finally {
|
|
62
|
+
this._capturing = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async _captureJpeg() {
|
|
67
|
+
if (!this.controller) {
|
|
68
|
+
throw new Error('Browser controller is unavailable.');
|
|
69
|
+
}
|
|
70
|
+
if (typeof this.controller.screenshotJpeg === 'function') {
|
|
71
|
+
return this.controller.screenshotJpeg(this.quality);
|
|
72
|
+
}
|
|
73
|
+
if (typeof this.controller.screenshot !== 'function') {
|
|
74
|
+
throw new Error('Browser streaming requires a screenshot-capable controller.');
|
|
75
|
+
}
|
|
76
|
+
const result = await this.controller.screenshot({ fullPage: false });
|
|
77
|
+
if (!result?.fullPath || !fs.existsSync(result.fullPath)) {
|
|
78
|
+
throw new Error('Browser screenshot did not produce a readable file.');
|
|
79
|
+
}
|
|
80
|
+
const png = fs.readFileSync(result.fullPath);
|
|
81
|
+
return sharp(png).jpeg({ quality: this.quality }).toBuffer();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
BrowserStream,
|
|
87
|
+
};
|