livedesk 0.1.0 → 0.1.2
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/bin/livedesk.js +7 -2
- package/hub/package.json +27 -0
- package/hub/src/remote-hub.js +3949 -0
- package/hub/src/server.js +724 -0
- package/package.json +5 -2
|
@@ -0,0 +1,3949 @@
|
|
|
1
|
+
import net from 'net';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_REMOTE_HUB_PORT = 5197;
|
|
6
|
+
const DEFAULT_REMOTE_HUB_HOST = '0.0.0.0';
|
|
7
|
+
const DEFAULT_HEARTBEAT_MS = 5000;
|
|
8
|
+
const DEFAULT_AGENT_TASK_TIMEOUT_MS = 120000;
|
|
9
|
+
const MAX_LINE_CHARS = 4 * 1024 * 1024;
|
|
10
|
+
const MAX_THUMBNAIL_BASE64_CHARS = 3 * 1024 * 1024;
|
|
11
|
+
const MAX_STREAM_BASE64_CHARS = 3 * 1024 * 1024;
|
|
12
|
+
const MAX_THUMBNAIL_BINARY_BYTES = Math.floor(MAX_THUMBNAIL_BASE64_CHARS * 3 / 4);
|
|
13
|
+
const MAX_STREAM_BINARY_BYTES = Math.floor(MAX_STREAM_BASE64_CHARS * 3 / 4);
|
|
14
|
+
const MAX_AUDIO_BASE64_CHARS = 1024 * 1024;
|
|
15
|
+
const MAX_AUDIO_BINARY_BYTES = Math.floor(MAX_AUDIO_BASE64_CHARS * 3 / 4);
|
|
16
|
+
const MAX_AGENT_TASK_CHARS = 4000;
|
|
17
|
+
const MAX_AGENT_TASK_RESULT_CHARS = 3000;
|
|
18
|
+
const RECENT_TASK_LIMIT = 12;
|
|
19
|
+
const RECENT_TASK_BATCH_LIMIT = 16;
|
|
20
|
+
const RECENT_FRAME_CACHE_TTL_MS = 12000;
|
|
21
|
+
const RECENT_THUMBNAIL_FRAME_CACHE_LIMIT = 4;
|
|
22
|
+
const RECENT_LIVE_FRAME_CACHE_LIMIT = 80;
|
|
23
|
+
const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 3 * 1024 * 1024;
|
|
24
|
+
const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
|
25
|
+
const REMOTE_PROTOCOL_VERSION = 2;
|
|
26
|
+
const REMOTE_AGENT_PROTOCOL = 'mindexec.remote.agent';
|
|
27
|
+
const REMOTE_FRAME_PROTOCOL = 'mindexec.remote.frame.v2';
|
|
28
|
+
const REMOTE_FRAME_PROTOCOL_VERSION = 2;
|
|
29
|
+
const REMOTE_FRAME_HANDSHAKE = 'smartview-request-open-frame';
|
|
30
|
+
const DEFAULT_REMOTE_FRAME_MODE = 'remote-fast';
|
|
31
|
+
const REMOTE_FRAME_MODE_PROFILES = Object.freeze({
|
|
32
|
+
thumbnail: Object.freeze({
|
|
33
|
+
mode: 'thumbnail',
|
|
34
|
+
label: 'Thumbnail PNG/JPEG Binary',
|
|
35
|
+
transport: 'ws-binary',
|
|
36
|
+
encoding: 'image',
|
|
37
|
+
codec: 'image',
|
|
38
|
+
compression: 'image/*',
|
|
39
|
+
profile: 'thumbnail-binary-v1',
|
|
40
|
+
modeVersion: 1,
|
|
41
|
+
implemented: true
|
|
42
|
+
}),
|
|
43
|
+
'remote-fast': Object.freeze({
|
|
44
|
+
mode: 'remote-fast',
|
|
45
|
+
label: 'RemoteFast JPEG Binary',
|
|
46
|
+
transport: 'ws-binary',
|
|
47
|
+
encoding: 'image',
|
|
48
|
+
codec: 'jpeg',
|
|
49
|
+
compression: 'image/jpeg',
|
|
50
|
+
profile: 'jpeg-binary-v1',
|
|
51
|
+
modeVersion: 1,
|
|
52
|
+
implemented: true
|
|
53
|
+
}),
|
|
54
|
+
'remote-quality': Object.freeze({
|
|
55
|
+
mode: 'remote-quality',
|
|
56
|
+
label: 'RemoteQuality JPEG Binary',
|
|
57
|
+
transport: 'ws-binary',
|
|
58
|
+
encoding: 'image',
|
|
59
|
+
codec: 'jpeg',
|
|
60
|
+
compression: 'image/jpeg',
|
|
61
|
+
profile: 'jpeg-quality-binary-v1',
|
|
62
|
+
modeVersion: 1,
|
|
63
|
+
implemented: true
|
|
64
|
+
}),
|
|
65
|
+
'video-codec': Object.freeze({
|
|
66
|
+
mode: 'video-codec',
|
|
67
|
+
label: 'Video Codec Stream',
|
|
68
|
+
transport: 'ws-binary',
|
|
69
|
+
encoding: 'video',
|
|
70
|
+
codec: 'video',
|
|
71
|
+
compression: 'video/*',
|
|
72
|
+
profile: 'future-video-codec',
|
|
73
|
+
modeVersion: 0,
|
|
74
|
+
implemented: false
|
|
75
|
+
})
|
|
76
|
+
});
|
|
77
|
+
const REMOTE_FRAME_MODE_ALIASES = new Map([
|
|
78
|
+
['fast', 'remote-fast'],
|
|
79
|
+
['remotefast', 'remote-fast'],
|
|
80
|
+
['remote_fast', 'remote-fast'],
|
|
81
|
+
['remote-fast', 'remote-fast'],
|
|
82
|
+
['quality', 'remote-quality'],
|
|
83
|
+
['remotequality', 'remote-quality'],
|
|
84
|
+
['remote_quality', 'remote-quality'],
|
|
85
|
+
['remote-quality', 'remote-quality'],
|
|
86
|
+
['thumb', 'thumbnail'],
|
|
87
|
+
['thumbnail', 'thumbnail'],
|
|
88
|
+
['video', 'video-codec'],
|
|
89
|
+
['codec', 'video-codec'],
|
|
90
|
+
['video-codec', 'video-codec']
|
|
91
|
+
]);
|
|
92
|
+
const MAX_SYNTHETIC_DEVICES = 1000;
|
|
93
|
+
const DEFAULT_HOST_TARGET_LEASE_MS = 30000;
|
|
94
|
+
const DEFAULT_PUBLIC_IP_REFRESH_MS = 5 * 60 * 1000;
|
|
95
|
+
const DEFAULT_PUBLIC_IP_TIMEOUT_MS = 1800;
|
|
96
|
+
const DUPLICATE_DEVICE_ACTIVE_REJECT_MS = 15000;
|
|
97
|
+
const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
|
|
98
|
+
const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
|
|
99
|
+
const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
|
|
100
|
+
const SYNTHETIC_FRAME_PAYLOAD = Buffer.from(SYNTHETIC_FRAME_DATA_URL.split(',')[1], 'base64');
|
|
101
|
+
const SYNTHETIC_FRAME_HASH = crypto.createHash('sha256').update(SYNTHETIC_FRAME_PAYLOAD).digest('hex').slice(0, 16);
|
|
102
|
+
const PUBLIC_IPV4_PROVIDERS = Object.freeze([
|
|
103
|
+
'https://api.ipify.org?format=json',
|
|
104
|
+
'https://checkip.amazonaws.com/',
|
|
105
|
+
'https://ifconfig.me/ip'
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
function isEnabledValue(value, fallback = true) {
|
|
109
|
+
if (value === undefined || value === null || value === '') {
|
|
110
|
+
return fallback;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return /^(1|true|yes|on)$/i.test(String(value).trim());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isDisabledValue(value) {
|
|
117
|
+
return /^(1|true|yes|on)$/i.test(String(value || '').trim());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function clampNumber(value, min, max, fallback) {
|
|
121
|
+
const number = Number(value);
|
|
122
|
+
if (!Number.isFinite(number)) {
|
|
123
|
+
return fallback;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return Math.max(min, Math.min(max, Math.floor(number)));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizePort(value) {
|
|
130
|
+
return clampNumber(value, 0, 65535, DEFAULT_REMOTE_HUB_PORT);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function safeString(value, maxLength = 200) {
|
|
134
|
+
return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeRemoteFrameMode(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
|
|
138
|
+
const requested = safeString(value, 80).toLowerCase();
|
|
139
|
+
const fallbackMode = REMOTE_FRAME_MODE_PROFILES[fallback] ? fallback : DEFAULT_REMOTE_FRAME_MODE;
|
|
140
|
+
if (!requested) {
|
|
141
|
+
return fallbackMode;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const normalized = REMOTE_FRAME_MODE_ALIASES.get(requested) || requested;
|
|
145
|
+
const profile = REMOTE_FRAME_MODE_PROFILES[normalized];
|
|
146
|
+
if (!profile || profile.implemented !== true) {
|
|
147
|
+
return fallbackMode;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return normalized;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function serializeRemoteFrameModeProfile(profile) {
|
|
154
|
+
return {
|
|
155
|
+
mode: profile.mode,
|
|
156
|
+
label: profile.label,
|
|
157
|
+
transport: profile.transport,
|
|
158
|
+
encoding: profile.encoding,
|
|
159
|
+
codec: profile.codec,
|
|
160
|
+
compression: profile.compression,
|
|
161
|
+
profile: profile.profile,
|
|
162
|
+
modeVersion: profile.modeVersion,
|
|
163
|
+
implemented: profile.implemented === true
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getRemoteFrameModeProfile(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
|
|
168
|
+
const mode = normalizeRemoteFrameMode(value, fallback);
|
|
169
|
+
return serializeRemoteFrameModeProfile(REMOTE_FRAME_MODE_PROFILES[mode]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function getSupportedRemoteFrameModeProfiles() {
|
|
173
|
+
return Object.values(REMOTE_FRAME_MODE_PROFILES)
|
|
174
|
+
.filter(profile => profile.implemented === true)
|
|
175
|
+
.map(serializeRemoteFrameModeProfile);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildRemoteFrameProtocolDescriptor() {
|
|
179
|
+
return {
|
|
180
|
+
protocol: REMOTE_FRAME_PROTOCOL,
|
|
181
|
+
version: REMOTE_FRAME_PROTOCOL_VERSION,
|
|
182
|
+
handshake: REMOTE_FRAME_HANDSHAKE,
|
|
183
|
+
defaultMode: DEFAULT_REMOTE_FRAME_MODE,
|
|
184
|
+
modes: getSupportedRemoteFrameModeProfiles()
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildRemoteFrameTransferDescriptor(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
|
|
189
|
+
const profile = getRemoteFrameModeProfile(value, fallback);
|
|
190
|
+
return {
|
|
191
|
+
mode: profile.mode,
|
|
192
|
+
frameMode: profile.mode,
|
|
193
|
+
frameProfile: profile.profile,
|
|
194
|
+
encoding: profile.encoding,
|
|
195
|
+
codec: profile.codec,
|
|
196
|
+
compression: profile.compression,
|
|
197
|
+
transferProtocol: REMOTE_FRAME_PROTOCOL,
|
|
198
|
+
transferProtocolVersion: REMOTE_FRAME_PROTOCOL_VERSION,
|
|
199
|
+
handshake: REMOTE_FRAME_HANDSHAKE
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildRemoteFrameTransferDescriptorFromMessage(message = {}, fallback = DEFAULT_REMOTE_FRAME_MODE) {
|
|
204
|
+
const descriptor = buildRemoteFrameTransferDescriptor(message.frameMode || message.mode, fallback);
|
|
205
|
+
return {
|
|
206
|
+
...descriptor,
|
|
207
|
+
frameProfile: safeString(message.frameProfile, 80) || descriptor.frameProfile,
|
|
208
|
+
encoding: safeString(message.encoding, 40) || descriptor.encoding,
|
|
209
|
+
codec: safeString(message.codec, 40) || descriptor.codec,
|
|
210
|
+
compression: safeString(message.compression || message.mimeType || message.format, 80) || descriptor.compression,
|
|
211
|
+
transferProtocol: safeString(message.transferProtocol, 80) || descriptor.transferProtocol,
|
|
212
|
+
transferProtocolVersion: Number.isFinite(Number(message.transferProtocolVersion))
|
|
213
|
+
? Math.max(1, Math.floor(Number(message.transferProtocolVersion)))
|
|
214
|
+
: descriptor.transferProtocolVersion,
|
|
215
|
+
handshake: safeString(message.handshake, 80) || descriptor.handshake
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeIncomingFrameModeProfile(value) {
|
|
220
|
+
if (!value) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (typeof value === 'string') {
|
|
225
|
+
return getRemoteFrameModeProfile(value);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (typeof value !== 'object') {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const profile = buildRemoteFrameTransferDescriptor(value.mode || value.frameMode || value.profile);
|
|
233
|
+
return {
|
|
234
|
+
mode: profile.mode,
|
|
235
|
+
label: safeString(value.label, 80) || profile.mode,
|
|
236
|
+
transport: safeString(value.transport, 40) || 'ws-binary',
|
|
237
|
+
encoding: safeString(value.encoding, 40) || profile.encoding,
|
|
238
|
+
codec: safeString(value.codec, 40) || profile.codec,
|
|
239
|
+
compression: safeString(value.compression, 80) || profile.compression,
|
|
240
|
+
profile: safeString(value.profile || value.frameProfile, 80) || profile.frameProfile,
|
|
241
|
+
modeVersion: Number.isFinite(Number(value.modeVersion)) ? Math.max(0, Math.floor(Number(value.modeVersion))) : 1,
|
|
242
|
+
implemented: value.implemented !== false
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isLoopbackHost(value) {
|
|
247
|
+
const host = String(value || '').trim().toLowerCase();
|
|
248
|
+
return host === 'localhost'
|
|
249
|
+
|| host === '127.0.0.1'
|
|
250
|
+
|| host === '::1'
|
|
251
|
+
|| host === '[::1]';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isWildcardHost(value) {
|
|
255
|
+
const host = String(value || '').trim().toLowerCase();
|
|
256
|
+
return host === '0.0.0.0'
|
|
257
|
+
|| host === '::'
|
|
258
|
+
|| host === '[::]'
|
|
259
|
+
|| host === '';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isPrivateIPv4(value) {
|
|
263
|
+
const parts = String(value || '').split('.').map(part => Number(part));
|
|
264
|
+
if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return parts[0] === 10
|
|
269
|
+
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
|
270
|
+
|| (parts[0] === 192 && parts[1] === 168);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isPublicIPv4(value) {
|
|
274
|
+
const parts = String(value || '').split('.').map(part => Number(part));
|
|
275
|
+
if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (parts[0] === 0 || parts[0] === 10 || parts[0] === 127 || parts[0] >= 224) {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (parts[0] === 169 && parts[1] === 254) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (parts[0] === 192 && (parts[1] === 0 || parts[1] === 168)) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isPreferredPhysicalInterfaceName(value) {
|
|
307
|
+
const name = String(value || '').toLowerCase();
|
|
308
|
+
return /(^|[\s_\-()])((wi[\s_\-]?fi)|wifi|wireless|wlan|ethernet|lan|이더넷|en\d+|eth\d+)(?=$|[\s_\-()])/i.test(name);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isVirtualInterfaceName(value) {
|
|
312
|
+
const name = String(value || '').toLowerCase();
|
|
313
|
+
return /virtual|vethernet|hyper-v|hyperv|vmware|virtualbox|vbox|docker|wsl|container|bluetooth|loopback|npcap|isatap|teredo/i.test(name);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function getIPv4LastOctet(value) {
|
|
317
|
+
const parts = String(value || '').split('.').map(part => Number(part));
|
|
318
|
+
if (parts.length !== 4 || parts.some(part => !Number.isInteger(part))) {
|
|
319
|
+
return -1;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return parts[3];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function scoreReachableIPv4Candidate(candidate) {
|
|
326
|
+
const address = String(candidate?.address || '');
|
|
327
|
+
const name = String(candidate?.name || '');
|
|
328
|
+
let score = 0;
|
|
329
|
+
|
|
330
|
+
if (isPrivateIPv4(address)) {
|
|
331
|
+
score += 1000;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (/^192\.168\./.test(address)) {
|
|
335
|
+
score += 90;
|
|
336
|
+
} else if (/^10\./.test(address)) {
|
|
337
|
+
score += 80;
|
|
338
|
+
} else if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(address)) {
|
|
339
|
+
score += 70;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (isPreferredPhysicalInterfaceName(name)) {
|
|
343
|
+
score += 500;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (isVirtualInterfaceName(name)) {
|
|
347
|
+
score -= 1200;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (/^169\.254\./.test(address)) {
|
|
351
|
+
score -= 2000;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const lastOctet = getIPv4LastOctet(address);
|
|
355
|
+
if (lastOctet === 1) {
|
|
356
|
+
score -= 60;
|
|
357
|
+
} else if (lastOctet === 0 || lastOctet === 255) {
|
|
358
|
+
score -= 100;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (candidate?.mac && candidate.mac === '00:00:00:00:00:00') {
|
|
362
|
+
score -= 50;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return score;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function getReachableIPv4CandidateEntries() {
|
|
369
|
+
const candidates = [];
|
|
370
|
+
const interfaces = os.networkInterfaces();
|
|
371
|
+
for (const [name, entries] of Object.entries(interfaces)) {
|
|
372
|
+
for (const entry of entries || []) {
|
|
373
|
+
if (entry?.family !== 'IPv4' || entry.internal) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const address = safeString(entry.address, 64);
|
|
378
|
+
if (!address || address === '0.0.0.0') {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
candidates.push({
|
|
383
|
+
address,
|
|
384
|
+
name: safeString(name, 128),
|
|
385
|
+
mac: safeString(entry.mac, 32),
|
|
386
|
+
score: 0,
|
|
387
|
+
virtual: isVirtualInterfaceName(name),
|
|
388
|
+
preferred: isPreferredPhysicalInterfaceName(name)
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return candidates
|
|
394
|
+
.map(candidate => ({
|
|
395
|
+
...candidate,
|
|
396
|
+
score: scoreReachableIPv4Candidate(candidate)
|
|
397
|
+
}))
|
|
398
|
+
.sort((left, right) => right.score - left.score || left.address.localeCompare(right.address))
|
|
399
|
+
.filter((candidate, index, all) => all.findIndex(item => item.address === candidate.address) === index);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function getReachableIPv4Candidates() {
|
|
403
|
+
return getReachableIPv4CandidateEntries().map(candidate => candidate.address);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function getReachableIPv4Address() {
|
|
407
|
+
const candidates = getReachableIPv4Candidates();
|
|
408
|
+
return candidates.find(isPrivateIPv4) || candidates[0] || '127.0.0.1';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function buildEndpoint(hostValue, portValue) {
|
|
412
|
+
const hostText = safeString(hostValue, 128);
|
|
413
|
+
const port = normalizePort(portValue);
|
|
414
|
+
if (!hostText || !port) {
|
|
415
|
+
return '';
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const hostPart = hostText.includes(':') && !hostText.startsWith('[')
|
|
419
|
+
? `[${hostText}]`
|
|
420
|
+
: hostText;
|
|
421
|
+
return `${hostPart}:${port}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function extractIPv4FromText(value) {
|
|
425
|
+
const text = safeString(value, 1000);
|
|
426
|
+
const match = text.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/);
|
|
427
|
+
return match ? match[0] : '';
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function fetchTextWithTimeout(url, timeoutMs) {
|
|
431
|
+
const controller = new AbortController();
|
|
432
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
433
|
+
try {
|
|
434
|
+
const response = await fetch(url, {
|
|
435
|
+
signal: controller.signal,
|
|
436
|
+
headers: {
|
|
437
|
+
accept: 'application/json,text/plain;q=0.9,*/*;q=0.1',
|
|
438
|
+
'user-agent': 'LiveDesk-Hub/0.1'
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
if (!response.ok) {
|
|
442
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
443
|
+
}
|
|
444
|
+
return await response.text();
|
|
445
|
+
} finally {
|
|
446
|
+
clearTimeout(timeoutId);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function normalizeSlotNumber(value) {
|
|
451
|
+
const number = Number(value);
|
|
452
|
+
return Number.isInteger(number) && number >= 1 && number <= 999 ? number : 0;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function parseManagerEndpoint(value) {
|
|
456
|
+
const endpoint = safeString(value, 256);
|
|
457
|
+
const match = endpoint.match(/^(\[[^\]]+\]|[^:\s]+):(\d{1,5})$/);
|
|
458
|
+
if (!match) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const host = match[1].replace(/^\[|\]$/g, '');
|
|
463
|
+
const port = Number(match[2]);
|
|
464
|
+
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { endpoint, host, port };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function getAccountRouteEndpointReason(endpoint, context = {}) {
|
|
472
|
+
const parsed = parseManagerEndpoint(endpoint);
|
|
473
|
+
if (!parsed) {
|
|
474
|
+
return 'invalid-manager-endpoint';
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (isLoopbackHost(parsed.host)) {
|
|
478
|
+
return context.wildcardWithoutReachableAddress ? 'no-reachable-ipv4' : 'loopback-endpoint';
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (isWildcardHost(parsed.host)) {
|
|
482
|
+
return 'wildcard-endpoint';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (/^169\.254\./.test(parsed.host)) {
|
|
486
|
+
return 'link-local-endpoint';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return 'ok';
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function safeText(value, maxLength = 1000) {
|
|
493
|
+
return String(value ?? '').replace(/\0/g, '').trim().slice(0, maxLength);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function normalizeApprovalLevel(value) {
|
|
497
|
+
const level = safeString(value, 80).toLowerCase();
|
|
498
|
+
return level === 'ai-assist' ? 'ai-assist' : 'task-only';
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function normalizeRemoteInputEvent(value = {}) {
|
|
502
|
+
const input = value && typeof value === 'object' ? value : {};
|
|
503
|
+
const type = safeString(input.type || input.Type, 48);
|
|
504
|
+
const normalizedX = Number(input.normalizedX ?? input.NormalizedX);
|
|
505
|
+
const normalizedY = Number(input.normalizedY ?? input.NormalizedY);
|
|
506
|
+
const deltaX = Number(input.deltaX ?? input.DeltaX);
|
|
507
|
+
const deltaY = Number(input.deltaY ?? input.DeltaY);
|
|
508
|
+
return {
|
|
509
|
+
type,
|
|
510
|
+
normalizedX: Number.isFinite(normalizedX) ? Math.max(0, Math.min(1, normalizedX)) : undefined,
|
|
511
|
+
normalizedY: Number.isFinite(normalizedY) ? Math.max(0, Math.min(1, normalizedY)) : undefined,
|
|
512
|
+
button: safeString(input.button || input.Button, 24),
|
|
513
|
+
deltaX: Number.isFinite(deltaX) ? Math.max(-4096, Math.min(4096, deltaX)) : 0,
|
|
514
|
+
deltaY: Number.isFinite(deltaY) ? Math.max(-4096, Math.min(4096, deltaY)) : 0,
|
|
515
|
+
key: safeString(input.key || input.Key, 80),
|
|
516
|
+
code: safeString(input.code || input.Code, 80),
|
|
517
|
+
text: safeText(input.text || input.Text, 256),
|
|
518
|
+
repeat: input.repeat === true || input.Repeat === true,
|
|
519
|
+
controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
|
|
520
|
+
issuedAt: safeString(input.issuedAt || input.IssuedAt, 80)
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function readCapabilityFlag(capabilities, key) {
|
|
525
|
+
const value = capabilities?.[key];
|
|
526
|
+
if (typeof value === 'boolean') {
|
|
527
|
+
return value;
|
|
528
|
+
}
|
|
529
|
+
if (typeof value === 'number') {
|
|
530
|
+
return value !== 0;
|
|
531
|
+
}
|
|
532
|
+
return /^(1|true|yes|on)$/i.test(String(value || '').trim());
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function createDeviceCounters(overrides = {}) {
|
|
536
|
+
return {
|
|
537
|
+
messagesReceived: 0,
|
|
538
|
+
statusReceived: 0,
|
|
539
|
+
commandsSent: 0,
|
|
540
|
+
commandResultsReceived: 0,
|
|
541
|
+
thumbnailFramesReceived: 0,
|
|
542
|
+
thumbnailFramesDropped: 0,
|
|
543
|
+
liveFramesReceived: 0,
|
|
544
|
+
liveFramesDropped: 0,
|
|
545
|
+
liveStreamsStarted: 0,
|
|
546
|
+
liveStreamsStopped: 0,
|
|
547
|
+
audioStreamsStarted: 0,
|
|
548
|
+
audioStreamsStopped: 0,
|
|
549
|
+
audioFramesReceived: 0,
|
|
550
|
+
audioFramesDropped: 0,
|
|
551
|
+
tasksQueued: 0,
|
|
552
|
+
taskResultsReceived: 0,
|
|
553
|
+
taskResultsFailed: 0,
|
|
554
|
+
taskResultsTimedOut: 0,
|
|
555
|
+
...overrides
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function normalizeDeviceId(value) {
|
|
560
|
+
const id = safeString(value, 128).replace(/[^a-zA-Z0-9_.:-]/g, '-');
|
|
561
|
+
return id || crypto.randomUUID();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function maskToken(token) {
|
|
565
|
+
const value = String(token || '');
|
|
566
|
+
if (value.length <= 8) {
|
|
567
|
+
return value ? '****' : '';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function timingSafeStringEqual(left, right) {
|
|
574
|
+
const a = Buffer.from(String(left || ''), 'utf8');
|
|
575
|
+
const b = Buffer.from(String(right || ''), 'utf8');
|
|
576
|
+
if (a.length !== b.length) {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return crypto.timingSafeEqual(a, b);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function parseJsonLine(line) {
|
|
584
|
+
const text = String(line || '').trim();
|
|
585
|
+
if (!text) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return JSON.parse(text);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function createFrameAccessToken() {
|
|
593
|
+
return crypto.randomBytes(18).toString('base64url');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function buildRemoteFramePath(deviceId, frameKind, frame) {
|
|
597
|
+
const token = safeString(frame?.accessToken, 128);
|
|
598
|
+
const frameSeq = Number(frame?.frameSeq);
|
|
599
|
+
if (!deviceId || !token || !Number.isFinite(frameSeq)) {
|
|
600
|
+
return '';
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const endpoint = frameKind === 'thumbnail' ? 'thumbnail' : 'live/frame';
|
|
604
|
+
const params = new URLSearchParams({
|
|
605
|
+
format: 'binary',
|
|
606
|
+
seq: String(Math.floor(frameSeq)),
|
|
607
|
+
token
|
|
608
|
+
});
|
|
609
|
+
return `/api/remote/devices/${encodeURIComponent(deviceId)}/${endpoint}?${params.toString()}`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
|
|
613
|
+
if (!frame) {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const {
|
|
618
|
+
payload,
|
|
619
|
+
accessToken,
|
|
620
|
+
dataUrl,
|
|
621
|
+
...publicFrame
|
|
622
|
+
} = frame;
|
|
623
|
+
|
|
624
|
+
const framePath = buildRemoteFramePath(deviceId, frameKind, frame);
|
|
625
|
+
const serialized = {
|
|
626
|
+
...publicFrame,
|
|
627
|
+
framePath,
|
|
628
|
+
frameUrl: framePath
|
|
629
|
+
};
|
|
630
|
+
if (options.includeDataUrl === true) {
|
|
631
|
+
serialized.dataUrl = dataUrl || buildFrameDataUrl(payload, '', publicFrame.mimeType || publicFrame.format || 'image/jpeg');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return serialized;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function getRecentFrameCache(device, frameKind) {
|
|
638
|
+
if (!device) {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (!device.recentFramePayloads) {
|
|
643
|
+
device.recentFramePayloads = {
|
|
644
|
+
thumbnail: [],
|
|
645
|
+
live: []
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
|
|
650
|
+
if (!Array.isArray(device.recentFramePayloads[kind])) {
|
|
651
|
+
device.recentFramePayloads[kind] = [];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return device.recentFramePayloads[kind];
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function getRecentFrameCacheLimit(frameKind) {
|
|
658
|
+
return frameKind === 'thumbnail'
|
|
659
|
+
? RECENT_THUMBNAIL_FRAME_CACHE_LIMIT
|
|
660
|
+
: RECENT_LIVE_FRAME_CACHE_LIMIT;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function getRecentFrameCacheMaxBytes(frameKind) {
|
|
664
|
+
return frameKind === 'thumbnail'
|
|
665
|
+
? RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES
|
|
666
|
+
: RECENT_LIVE_FRAME_CACHE_MAX_BYTES;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function pruneRecentFrameCache(cache, frameKind, nowMs = Date.now()) {
|
|
670
|
+
if (!Array.isArray(cache)) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const limit = getRecentFrameCacheLimit(frameKind);
|
|
675
|
+
const maxBytes = getRecentFrameCacheMaxBytes(frameKind);
|
|
676
|
+
let byteTotal = 0;
|
|
677
|
+
for (let index = cache.length - 1; index >= 0; index -= 1) {
|
|
678
|
+
const entry = cache[index];
|
|
679
|
+
const frame = entry?.frame;
|
|
680
|
+
if (!entry
|
|
681
|
+
|| entry.expiresAt <= nowMs
|
|
682
|
+
|| !frame
|
|
683
|
+
|| !Buffer.isBuffer(frame.payload)
|
|
684
|
+
|| !Number.isFinite(Number(frame.frameSeq))
|
|
685
|
+
|| !safeString(frame.accessToken, 128)) {
|
|
686
|
+
cache.splice(index, 1);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
for (let index = 0; index < cache.length; index += 1) {
|
|
691
|
+
const entry = cache[index];
|
|
692
|
+
const byteLength = Number(entry?.byteLength || entry?.frame?.payload?.length || 0) || 0;
|
|
693
|
+
byteTotal += byteLength;
|
|
694
|
+
if (index >= limit || byteTotal > maxBytes) {
|
|
695
|
+
cache.splice(index);
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function rememberRecentFramePayload(device, frameKind, frame) {
|
|
702
|
+
if (!device || !frame || !Buffer.isBuffer(frame.payload)) {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const frameSeq = Number(frame.frameSeq);
|
|
707
|
+
const accessToken = safeString(frame.accessToken, 128);
|
|
708
|
+
if (!Number.isFinite(frameSeq) || !accessToken) {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
|
|
713
|
+
const cache = getRecentFrameCache(device, kind);
|
|
714
|
+
if (!cache) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const nowMs = Date.now();
|
|
719
|
+
const normalizedSeq = Math.floor(frameSeq);
|
|
720
|
+
for (let index = cache.length - 1; index >= 0; index -= 1) {
|
|
721
|
+
const existing = cache[index]?.frame;
|
|
722
|
+
if (Number(existing?.frameSeq) === normalizedSeq
|
|
723
|
+
|| safeString(existing?.accessToken, 128) === accessToken) {
|
|
724
|
+
cache.splice(index, 1);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
cache.unshift({
|
|
729
|
+
frame,
|
|
730
|
+
byteLength: frame.payload.length,
|
|
731
|
+
cachedAt: nowMs,
|
|
732
|
+
expiresAt: nowMs + RECENT_FRAME_CACHE_TTL_MS
|
|
733
|
+
});
|
|
734
|
+
pruneRecentFrameCache(cache, kind, nowMs);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function isFramePayloadRequestMatch(frame, options = {}) {
|
|
738
|
+
if (!frame || !Buffer.isBuffer(frame.payload)) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const requestedSeq = Number(options.frameSeq);
|
|
743
|
+
if (Number.isFinite(requestedSeq) && Math.floor(requestedSeq) !== Number(frame.frameSeq)) {
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const token = safeString(options.token, 128);
|
|
748
|
+
if (token && !timingSafeStringEqual(token, frame.accessToken)) {
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (options.requireToken === true && !token) {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function findRecentFramePayload(device, frameKind, options = {}) {
|
|
760
|
+
const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
|
|
761
|
+
const latest = kind === 'thumbnail'
|
|
762
|
+
? device?.latestThumbnail
|
|
763
|
+
: device?.latestLiveFrame;
|
|
764
|
+
if (isFramePayloadRequestMatch(latest, options)) {
|
|
765
|
+
return latest;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const token = safeString(options.token, 128);
|
|
769
|
+
const requestedSeq = Number(options.frameSeq);
|
|
770
|
+
if (!token && !Number.isFinite(requestedSeq)) {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const cache = getRecentFrameCache(device, kind);
|
|
775
|
+
if (!cache) {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
pruneRecentFrameCache(cache, kind);
|
|
780
|
+
const entry = cache.find(item => isFramePayloadRequestMatch(item?.frame, options));
|
|
781
|
+
return entry?.frame || null;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function serializeDevice(device, options = {}) {
|
|
785
|
+
if (!device) {
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return {
|
|
790
|
+
deviceId: device.deviceId,
|
|
791
|
+
sessionId: device.sessionId,
|
|
792
|
+
deviceName: device.deviceName,
|
|
793
|
+
hostname: device.hostname,
|
|
794
|
+
platform: device.platform,
|
|
795
|
+
arch: device.arch,
|
|
796
|
+
slotNumber: device.slotNumber || 0,
|
|
797
|
+
pid: device.pid,
|
|
798
|
+
agentVersion: device.agentVersion,
|
|
799
|
+
protocol: device.protocol,
|
|
800
|
+
protocolVersion: device.protocolVersion,
|
|
801
|
+
frameProtocol: device.frameProtocol ? { ...device.frameProtocol } : null,
|
|
802
|
+
frameModes: Array.isArray(device.frameModes)
|
|
803
|
+
? device.frameModes.map(profile => ({ ...profile }))
|
|
804
|
+
: [],
|
|
805
|
+
capabilities: { ...device.capabilities },
|
|
806
|
+
connected: device.connected,
|
|
807
|
+
connectedAt: device.connectedAt,
|
|
808
|
+
disconnectedAt: device.disconnectedAt,
|
|
809
|
+
lastSeenAt: device.lastSeenAt,
|
|
810
|
+
lastStatusAt: device.lastStatusAt,
|
|
811
|
+
lastDisconnectReason: device.lastDisconnectReason,
|
|
812
|
+
remoteAddress: device.remoteAddress,
|
|
813
|
+
remotePort: device.remotePort,
|
|
814
|
+
latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
|
|
815
|
+
latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
|
|
816
|
+
activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
|
|
817
|
+
activeAudioStream: device.activeAudioStream ? { ...device.activeAudioStream } : null,
|
|
818
|
+
latestAudioFrame: device.latestAudioFrame
|
|
819
|
+
? {
|
|
820
|
+
streamId: device.latestAudioFrame.streamId,
|
|
821
|
+
frameSeq: device.latestAudioFrame.frameSeq,
|
|
822
|
+
mimeType: device.latestAudioFrame.mimeType,
|
|
823
|
+
sampleRate: device.latestAudioFrame.sampleRate,
|
|
824
|
+
channels: device.latestAudioFrame.channels,
|
|
825
|
+
capturedAt: device.latestAudioFrame.capturedAt,
|
|
826
|
+
receivedAt: device.latestAudioFrame.receivedAt,
|
|
827
|
+
byteLength: device.latestAudioFrame.byteLength
|
|
828
|
+
}
|
|
829
|
+
: null,
|
|
830
|
+
latestTask: device.latestTask ? { ...device.latestTask } : null,
|
|
831
|
+
synthetic: device.synthetic === true,
|
|
832
|
+
recentTasks: Array.isArray(device.recentTasks)
|
|
833
|
+
? device.recentTasks.map(task => ({ ...task }))
|
|
834
|
+
: [],
|
|
835
|
+
status: { ...device.status },
|
|
836
|
+
counters: { ...device.counters }
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function serializeTaskBatch(batch) {
|
|
841
|
+
if (!batch) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
batchId: batch.batchId,
|
|
847
|
+
title: batch.title,
|
|
848
|
+
instructionPreview: batch.instructionPreview,
|
|
849
|
+
approvalLevel: batch.approvalLevel,
|
|
850
|
+
status: batch.status,
|
|
851
|
+
requestedAt: batch.requestedAt,
|
|
852
|
+
updatedAt: batch.updatedAt,
|
|
853
|
+
total: batch.total,
|
|
854
|
+
queued: batch.queued,
|
|
855
|
+
pending: batch.pending,
|
|
856
|
+
completed: batch.completed,
|
|
857
|
+
failed: batch.failed,
|
|
858
|
+
timedOut: batch.timedOut,
|
|
859
|
+
results: Array.isArray(batch.results)
|
|
860
|
+
? batch.results.map(item => ({ ...item }))
|
|
861
|
+
: []
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function writeJsonLine(socket, payload) {
|
|
866
|
+
if (!socket || socket.destroyed) {
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (socket.__remoteHubWebSocket === true && typeof socket.sendJson === 'function') {
|
|
871
|
+
return socket.sendJson(payload);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
875
|
+
return true;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function isRemoteHubWebSocketUpgradeStart(buffer) {
|
|
879
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return buffer.subarray(0, Math.min(buffer.length, 16)).toString('latin1').startsWith('GET ');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function parseRemoteHubWebSocketUpgrade(buffer) {
|
|
887
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
888
|
+
if (headerEnd < 0) {
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const headerText = buffer.subarray(0, headerEnd).toString('latin1');
|
|
893
|
+
const lines = headerText.split('\r\n');
|
|
894
|
+
const requestLine = lines.shift() || '';
|
|
895
|
+
const [method, path] = requestLine.split(/\s+/);
|
|
896
|
+
const headers = {};
|
|
897
|
+
for (const line of lines) {
|
|
898
|
+
const separator = line.indexOf(':');
|
|
899
|
+
if (separator <= 0) {
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
headers[line.slice(0, separator).trim().toLowerCase()] = line.slice(separator + 1).trim();
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return {
|
|
907
|
+
method,
|
|
908
|
+
path,
|
|
909
|
+
headers,
|
|
910
|
+
head: buffer.subarray(headerEnd + 4)
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function buildRemoteHubWebSocketAcceptKey(key) {
|
|
915
|
+
return crypto
|
|
916
|
+
.createHash('sha1')
|
|
917
|
+
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
918
|
+
.digest('base64');
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function buildRemoteHubWebSocketFrame(opcode, payload) {
|
|
922
|
+
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload || ''), 'utf8');
|
|
923
|
+
let header = null;
|
|
924
|
+
if (body.length < 126) {
|
|
925
|
+
header = Buffer.allocUnsafe(2);
|
|
926
|
+
header[0] = 0x80 | opcode;
|
|
927
|
+
header[1] = body.length;
|
|
928
|
+
} else if (body.length <= 0xffff) {
|
|
929
|
+
header = Buffer.allocUnsafe(4);
|
|
930
|
+
header[0] = 0x80 | opcode;
|
|
931
|
+
header[1] = 126;
|
|
932
|
+
header.writeUInt16BE(body.length, 2);
|
|
933
|
+
} else {
|
|
934
|
+
header = Buffer.allocUnsafe(10);
|
|
935
|
+
header[0] = 0x80 | opcode;
|
|
936
|
+
header[1] = 127;
|
|
937
|
+
header.writeBigUInt64BE(BigInt(body.length), 2);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return Buffer.concat([header, body], header.length + body.length);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function parseRemoteHubWebSocketBinaryFrame(buffer) {
|
|
944
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 5) {
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const metaLength = buffer.readUInt32BE(0);
|
|
949
|
+
if (!Number.isFinite(metaLength)
|
|
950
|
+
|| metaLength <= 0
|
|
951
|
+
|| metaLength > 64 * 1024
|
|
952
|
+
|| 4 + metaLength >= buffer.length) {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const header = JSON.parse(buffer.subarray(4, 4 + metaLength).toString('utf8'));
|
|
957
|
+
const payload = buffer.subarray(4 + metaLength);
|
|
958
|
+
return { header, payload };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
class RemoteHubWebSocketAgentSocket {
|
|
962
|
+
constructor(socket) {
|
|
963
|
+
this.__remoteHubWebSocket = true;
|
|
964
|
+
this.socket = socket;
|
|
965
|
+
this.remoteAddress = socket.remoteAddress;
|
|
966
|
+
this.remotePort = socket.remotePort;
|
|
967
|
+
this.destroyed = false;
|
|
968
|
+
this.buffer = Buffer.alloc(0);
|
|
969
|
+
this.fragmentOpcode = 0;
|
|
970
|
+
this.fragments = [];
|
|
971
|
+
this.onTextMessage = () => {};
|
|
972
|
+
this.onBinaryMessage = () => {};
|
|
973
|
+
this.onCloseMessage = () => {};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
setNoDelay() {}
|
|
977
|
+
|
|
978
|
+
setKeepAlive() {}
|
|
979
|
+
|
|
980
|
+
sendJson(payload) {
|
|
981
|
+
return this.sendText(JSON.stringify(payload));
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
write(payload) {
|
|
985
|
+
if (this.destroyed) {
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const text = (Buffer.isBuffer(payload) ? payload.toString('utf8') : String(payload || '')).replace(/\n$/, '');
|
|
990
|
+
return this.sendText(text);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
sendText(text) {
|
|
994
|
+
return this.sendFrame(0x1, Buffer.from(String(text || ''), 'utf8'));
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
sendFrame(opcode, payload) {
|
|
998
|
+
if (this.destroyed || this.socket.destroyed) {
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
try {
|
|
1003
|
+
this.socket.write(buildRemoteHubWebSocketFrame(opcode, payload));
|
|
1004
|
+
return true;
|
|
1005
|
+
} catch {
|
|
1006
|
+
this.destroy();
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
destroy() {
|
|
1012
|
+
if (this.destroyed) {
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
this.destroyed = true;
|
|
1017
|
+
try {
|
|
1018
|
+
if (!this.socket.destroyed) {
|
|
1019
|
+
this.socket.write(buildRemoteHubWebSocketFrame(0x8, Buffer.alloc(0)));
|
|
1020
|
+
}
|
|
1021
|
+
} catch {
|
|
1022
|
+
// Ignore close-frame failures.
|
|
1023
|
+
}
|
|
1024
|
+
try {
|
|
1025
|
+
this.socket.destroy();
|
|
1026
|
+
} catch {
|
|
1027
|
+
// Ignore socket destroy failures.
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
handleData(chunk) {
|
|
1032
|
+
if (this.destroyed) {
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
this.buffer = this.buffer.length > 0
|
|
1037
|
+
? Buffer.concat([this.buffer, chunk])
|
|
1038
|
+
: chunk;
|
|
1039
|
+
|
|
1040
|
+
while (!this.destroyed) {
|
|
1041
|
+
if (this.buffer.length < 2) {
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const first = this.buffer[0];
|
|
1046
|
+
const second = this.buffer[1];
|
|
1047
|
+
const fin = (first & 0x80) !== 0;
|
|
1048
|
+
const opcode = first & 0x0f;
|
|
1049
|
+
const masked = (second & 0x80) !== 0;
|
|
1050
|
+
let payloadLength = second & 0x7f;
|
|
1051
|
+
let offset = 2;
|
|
1052
|
+
|
|
1053
|
+
if (payloadLength === 126) {
|
|
1054
|
+
if (this.buffer.length < offset + 2) return;
|
|
1055
|
+
payloadLength = this.buffer.readUInt16BE(offset);
|
|
1056
|
+
offset += 2;
|
|
1057
|
+
} else if (payloadLength === 127) {
|
|
1058
|
+
if (this.buffer.length < offset + 8) return;
|
|
1059
|
+
const bigLength = this.buffer.readBigUInt64BE(offset);
|
|
1060
|
+
if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
1061
|
+
this.destroy();
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
payloadLength = Number(bigLength);
|
|
1065
|
+
offset += 8;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (!masked) {
|
|
1069
|
+
this.destroy();
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (this.buffer.length < offset + 4 + payloadLength) {
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const mask = this.buffer.subarray(offset, offset + 4);
|
|
1078
|
+
offset += 4;
|
|
1079
|
+
const payload = Buffer.from(this.buffer.subarray(offset, offset + payloadLength));
|
|
1080
|
+
this.buffer = this.buffer.subarray(offset + payloadLength);
|
|
1081
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
1082
|
+
payload[index] ^= mask[index & 3];
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (opcode === 0x8) {
|
|
1086
|
+
this.destroyed = true;
|
|
1087
|
+
this.onCloseMessage('websocket-close');
|
|
1088
|
+
try {
|
|
1089
|
+
this.socket.destroy();
|
|
1090
|
+
} catch {
|
|
1091
|
+
// Ignore socket destroy failures.
|
|
1092
|
+
}
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (opcode === 0x9) {
|
|
1097
|
+
this.sendFrame(0xA, payload);
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (opcode === 0xA) {
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
let messageOpcode = opcode;
|
|
1106
|
+
let messagePayload = payload;
|
|
1107
|
+
if (!fin) {
|
|
1108
|
+
this.fragmentOpcode = opcode;
|
|
1109
|
+
this.fragments = [payload];
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (opcode === 0x0) {
|
|
1114
|
+
this.fragments.push(payload);
|
|
1115
|
+
messageOpcode = this.fragmentOpcode;
|
|
1116
|
+
messagePayload = Buffer.concat(this.fragments);
|
|
1117
|
+
this.fragmentOpcode = 0;
|
|
1118
|
+
this.fragments = [];
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (messageOpcode === 0x1) {
|
|
1122
|
+
this.onTextMessage(messagePayload.toString('utf8'));
|
|
1123
|
+
} else if (messageOpcode === 0x2) {
|
|
1124
|
+
this.onBinaryMessage(messagePayload);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
export function createRemoteHub(options = {}) {
|
|
1131
|
+
const env = options.env || process.env;
|
|
1132
|
+
const logEvent = options.logEvent || (() => {});
|
|
1133
|
+
const logWarn = options.logWarn || (() => {});
|
|
1134
|
+
const emitEvent = options.emitEvent || (() => {});
|
|
1135
|
+
const emitFrame = typeof options.emitFrame === 'function' ? options.emitFrame : (() => {});
|
|
1136
|
+
const emitAudio = typeof options.emitAudio === 'function' ? options.emitAudio : (() => {});
|
|
1137
|
+
|
|
1138
|
+
const enabled = isEnabledValue(env.LIVEDESK_REMOTE_HUB ?? env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
|
|
1139
|
+
&& !isDisabledValue(env.REMOTE_HUB_DISABLED);
|
|
1140
|
+
const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
|
|
1141
|
+
const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
|
|
1142
|
+
const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
|
|
1143
|
+
const taskTimeoutMs = clampNumber(
|
|
1144
|
+
options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
|
|
1145
|
+
50,
|
|
1146
|
+
30 * 60 * 1000,
|
|
1147
|
+
DEFAULT_AGENT_TASK_TIMEOUT_MS);
|
|
1148
|
+
const managerPackage = safeString(options.managerPackage ?? env.LIVEDESK_MANAGER_PACKAGE ?? env.MINDEXEC_MANAGER_PACKAGE ?? '@livedesk/hub', 128);
|
|
1149
|
+
const managerVersion = safeString(options.managerVersion ?? env.LIVEDESK_MANAGER_VERSION ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
|
|
1150
|
+
const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
|
|
1151
|
+
const publicEndpoint = safeString(env.LIVEDESK_REMOTE_PUBLIC_ENDPOINT || env.MINDEXEC_REMOTE_PUBLIC_ENDPOINT || env.REMOTE_HUB_PUBLIC_ENDPOINT, 256);
|
|
1152
|
+
const publicHost = safeString(env.LIVEDESK_REMOTE_PUBLIC_HOST || env.MINDEXEC_REMOTE_PUBLIC_HOST || env.REMOTE_HUB_PUBLIC_HOST, 128);
|
|
1153
|
+
const pairToken = safeString(
|
|
1154
|
+
options.pairToken || env.REMOTE_HUB_PAIR_TOKEN || env.LIVEDESK_CLIENT_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN || crypto.randomBytes(6).toString('hex'),
|
|
1155
|
+
256);
|
|
1156
|
+
const duplicateDeviceLogThrottleMs = clampNumber(
|
|
1157
|
+
env.MINDEXEC_REMOTE_DUPLICATE_DEVICE_LOG_MS || env.REMOTE_HUB_DUPLICATE_DEVICE_LOG_MS,
|
|
1158
|
+
5000,
|
|
1159
|
+
10 * 60 * 1000,
|
|
1160
|
+
DUPLICATE_DEVICE_LOG_THROTTLE_MS);
|
|
1161
|
+
const duplicateDeviceRetryAfterMs = clampNumber(
|
|
1162
|
+
env.MINDEXEC_REMOTE_DUPLICATE_DEVICE_RETRY_MS || env.REMOTE_HUB_DUPLICATE_DEVICE_RETRY_MS,
|
|
1163
|
+
5000,
|
|
1164
|
+
10 * 60 * 1000,
|
|
1165
|
+
DUPLICATE_DEVICE_RETRY_AFTER_MS);
|
|
1166
|
+
|
|
1167
|
+
const devices = new Map();
|
|
1168
|
+
const taskBatches = new Map();
|
|
1169
|
+
const sockets = new Map();
|
|
1170
|
+
const allSockets = new Set();
|
|
1171
|
+
const duplicateDeviceLogAt = new Map();
|
|
1172
|
+
let server = null;
|
|
1173
|
+
let started = false;
|
|
1174
|
+
let boundPort = requestedPort;
|
|
1175
|
+
let lastError = '';
|
|
1176
|
+
let hostTarget = null;
|
|
1177
|
+
let publicIPv4Cache = {
|
|
1178
|
+
address: safeString(env.LIVEDESK_REMOTE_PUBLIC_IPV4 || env.LIVEDESK_PUBLIC_IPV4 || env.REMOTE_HUB_PUBLIC_IPV4, 64),
|
|
1179
|
+
updatedAt: 0,
|
|
1180
|
+
checkedAt: 0,
|
|
1181
|
+
error: ''
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
if (!isPublicIPv4(publicIPv4Cache.address)) {
|
|
1185
|
+
publicIPv4Cache.address = '';
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const publicIpDiscoveryEnabled = isEnabledValue(env.LIVEDESK_PUBLIC_IP_DISCOVERY ?? env.REMOTE_HUB_PUBLIC_IP_DISCOVERY, true)
|
|
1189
|
+
&& !isDisabledValue(env.LIVEDESK_PUBLIC_IP_DISCOVERY_DISABLED ?? env.REMOTE_HUB_PUBLIC_IP_DISCOVERY_DISABLED);
|
|
1190
|
+
|
|
1191
|
+
function getPublicIPv4EndpointCandidate() {
|
|
1192
|
+
const address = safeString(publicIPv4Cache.address, 64);
|
|
1193
|
+
if (!isPublicIPv4(address)) {
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const endpoint = buildEndpoint(address, boundPort || requestedPort);
|
|
1198
|
+
if (!endpoint) {
|
|
1199
|
+
return null;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
return {
|
|
1203
|
+
address,
|
|
1204
|
+
name: 'Public IPv4',
|
|
1205
|
+
mac: '',
|
|
1206
|
+
score: -100,
|
|
1207
|
+
virtual: false,
|
|
1208
|
+
preferred: false,
|
|
1209
|
+
public: true,
|
|
1210
|
+
endpoint
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
async function refreshPublicIPv4Candidate(options = {}) {
|
|
1215
|
+
if (!publicIpDiscoveryEnabled) {
|
|
1216
|
+
return publicIPv4Cache.address;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const now = Date.now();
|
|
1220
|
+
const force = options.force === true;
|
|
1221
|
+
const refreshMs = clampNumber(
|
|
1222
|
+
env.LIVEDESK_PUBLIC_IP_REFRESH_MS || env.REMOTE_HUB_PUBLIC_IP_REFRESH_MS,
|
|
1223
|
+
30_000,
|
|
1224
|
+
60 * 60 * 1000,
|
|
1225
|
+
DEFAULT_PUBLIC_IP_REFRESH_MS);
|
|
1226
|
+
|
|
1227
|
+
if (!force && publicIPv4Cache.checkedAt > 0 && now - publicIPv4Cache.checkedAt < refreshMs) {
|
|
1228
|
+
return publicIPv4Cache.address;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
publicIPv4Cache.checkedAt = now;
|
|
1232
|
+
const timeoutMs = clampNumber(
|
|
1233
|
+
env.LIVEDESK_PUBLIC_IP_TIMEOUT_MS || env.REMOTE_HUB_PUBLIC_IP_TIMEOUT_MS,
|
|
1234
|
+
500,
|
|
1235
|
+
10_000,
|
|
1236
|
+
DEFAULT_PUBLIC_IP_TIMEOUT_MS);
|
|
1237
|
+
|
|
1238
|
+
for (const provider of PUBLIC_IPV4_PROVIDERS) {
|
|
1239
|
+
try {
|
|
1240
|
+
const text = await fetchTextWithTimeout(provider, timeoutMs);
|
|
1241
|
+
let address = '';
|
|
1242
|
+
try {
|
|
1243
|
+
const payload = JSON.parse(text);
|
|
1244
|
+
address = safeString(payload?.ip || payload?.origin || payload?.address, 64);
|
|
1245
|
+
} catch {
|
|
1246
|
+
address = extractIPv4FromText(text);
|
|
1247
|
+
}
|
|
1248
|
+
address = extractIPv4FromText(address) || address;
|
|
1249
|
+
if (!isPublicIPv4(address)) {
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
if (publicIPv4Cache.address !== address) {
|
|
1253
|
+
logEvent('remote', `public IPv4 candidate detected ${address}`, 'remote');
|
|
1254
|
+
}
|
|
1255
|
+
publicIPv4Cache = {
|
|
1256
|
+
address,
|
|
1257
|
+
updatedAt: Date.now(),
|
|
1258
|
+
checkedAt: Date.now(),
|
|
1259
|
+
error: ''
|
|
1260
|
+
};
|
|
1261
|
+
return publicIPv4Cache.address;
|
|
1262
|
+
} catch (err) {
|
|
1263
|
+
publicIPv4Cache.error = err instanceof Error ? err.message : String(err);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
return publicIPv4Cache.address;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function getAnnouncedHost() {
|
|
1271
|
+
if (publicHost) {
|
|
1272
|
+
return publicHost;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
if (isWildcardHost(host)) {
|
|
1276
|
+
return getReachableIPv4Address();
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
return host;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function getAgentEndpoint() {
|
|
1283
|
+
if (publicEndpoint) {
|
|
1284
|
+
return publicEndpoint;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
return `${getAnnouncedHost()}:${boundPort || requestedPort}`;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function getAgentEndpointRouteInfo() {
|
|
1291
|
+
const includeInterfaceCandidates = !publicEndpoint && isWildcardHost(host);
|
|
1292
|
+
const candidateDetails = includeInterfaceCandidates
|
|
1293
|
+
? getReachableIPv4CandidateEntries()
|
|
1294
|
+
: [];
|
|
1295
|
+
const candidateEndpoints = candidateDetails
|
|
1296
|
+
.map(candidate => ({
|
|
1297
|
+
...candidate,
|
|
1298
|
+
endpoint: buildEndpoint(candidate.address, boundPort || requestedPort)
|
|
1299
|
+
}))
|
|
1300
|
+
.filter(candidate => candidate.endpoint);
|
|
1301
|
+
const publicCandidate = getPublicIPv4EndpointCandidate();
|
|
1302
|
+
const externalCandidateEndpoints = publicCandidate ? [publicCandidate] : [];
|
|
1303
|
+
const endpoint = getAgentEndpoint();
|
|
1304
|
+
const endpointCandidates = [
|
|
1305
|
+
endpoint,
|
|
1306
|
+
...candidateEndpoints.map(candidate => candidate.endpoint),
|
|
1307
|
+
...externalCandidateEndpoints.map(candidate => candidate.endpoint)
|
|
1308
|
+
].filter((value, index, all) => value && all.indexOf(value) === index);
|
|
1309
|
+
const wildcardWithoutReachableAddress =
|
|
1310
|
+
!publicEndpoint
|
|
1311
|
+
&& !publicHost
|
|
1312
|
+
&& isWildcardHost(host)
|
|
1313
|
+
&& candidateDetails.length === 0;
|
|
1314
|
+
const reason = getAccountRouteEndpointReason(endpoint, { wildcardWithoutReachableAddress });
|
|
1315
|
+
const parsed = parseManagerEndpoint(endpoint);
|
|
1316
|
+
return {
|
|
1317
|
+
endpoint,
|
|
1318
|
+
host: parsed?.host || '',
|
|
1319
|
+
port: parsed?.port || 0,
|
|
1320
|
+
accountRouteReady: reason === 'ok',
|
|
1321
|
+
reason,
|
|
1322
|
+
candidates: endpointCandidates,
|
|
1323
|
+
candidateDetails: [
|
|
1324
|
+
...candidateEndpoints,
|
|
1325
|
+
...externalCandidateEndpoints
|
|
1326
|
+
]
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function getActiveHostTarget() {
|
|
1331
|
+
if (!hostTarget) {
|
|
1332
|
+
return null;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const expiresMs = Date.parse(hostTarget.expiresAt || '');
|
|
1336
|
+
if (Number.isFinite(expiresMs) && expiresMs <= Date.now()) {
|
|
1337
|
+
const expiredTarget = hostTarget;
|
|
1338
|
+
hostTarget = null;
|
|
1339
|
+
emitRemoteEvent('RemoteHostTargetExpired', null, {
|
|
1340
|
+
hostTarget: {
|
|
1341
|
+
...expiredTarget,
|
|
1342
|
+
active: false
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return hostTarget;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function serializeHostTarget(target = getActiveHostTarget()) {
|
|
1352
|
+
if (!target) {
|
|
1353
|
+
return {
|
|
1354
|
+
active: false,
|
|
1355
|
+
nodeId: '',
|
|
1356
|
+
leaseId: '',
|
|
1357
|
+
hostInstanceId: '',
|
|
1358
|
+
endpoint: '',
|
|
1359
|
+
endpointCandidates: [],
|
|
1360
|
+
activatedAt: '',
|
|
1361
|
+
updatedAt: '',
|
|
1362
|
+
expiresAt: ''
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
return {
|
|
1367
|
+
active: true,
|
|
1368
|
+
nodeId: target.nodeId,
|
|
1369
|
+
leaseId: target.leaseId,
|
|
1370
|
+
hostInstanceId: target.hostInstanceId || hostInstanceId,
|
|
1371
|
+
endpoint: target.endpoint,
|
|
1372
|
+
endpointCandidates: Array.isArray(target.endpointCandidates) ? [...target.endpointCandidates] : [target.endpoint].filter(Boolean),
|
|
1373
|
+
activatedAt: target.activatedAt,
|
|
1374
|
+
updatedAt: target.updatedAt,
|
|
1375
|
+
expiresAt: target.expiresAt
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
async function setHostTarget(options = {}) {
|
|
1380
|
+
const enabled = options.enabled !== false;
|
|
1381
|
+
const nodeId = safeString(options.nodeId, 128);
|
|
1382
|
+
if (!enabled) {
|
|
1383
|
+
const previous = getActiveHostTarget();
|
|
1384
|
+
if (!previous) {
|
|
1385
|
+
return {
|
|
1386
|
+
ok: true,
|
|
1387
|
+
active: false,
|
|
1388
|
+
hostTarget: serializeHostTarget(null)
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (nodeId && previous.nodeId && nodeId !== previous.nodeId) {
|
|
1393
|
+
return {
|
|
1394
|
+
ok: false,
|
|
1395
|
+
error: 'host-target-node-mismatch',
|
|
1396
|
+
active: true,
|
|
1397
|
+
hostTarget: serializeHostTarget(previous)
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
hostTarget = null;
|
|
1402
|
+
emitRemoteEvent('RemoteHostTargetCleared', null, {
|
|
1403
|
+
hostTarget: {
|
|
1404
|
+
...serializeHostTarget(previous),
|
|
1405
|
+
active: false
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
return {
|
|
1409
|
+
ok: true,
|
|
1410
|
+
active: false,
|
|
1411
|
+
hostTarget: serializeHostTarget(null)
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
if (!nodeId) {
|
|
1416
|
+
return {
|
|
1417
|
+
ok: false,
|
|
1418
|
+
error: 'missing-node-id',
|
|
1419
|
+
active: false,
|
|
1420
|
+
hostTarget: serializeHostTarget()
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const now = new Date();
|
|
1425
|
+
const leaseMs = clampNumber(options.leaseMs, 5000, 10 * 60 * 1000, DEFAULT_HOST_TARGET_LEASE_MS);
|
|
1426
|
+
const previous = getActiveHostTarget();
|
|
1427
|
+
const activeSameNode = previous?.nodeId === nodeId;
|
|
1428
|
+
await refreshPublicIPv4Candidate({ force: options.refreshPublicIp === true || options.takeover === true });
|
|
1429
|
+
const routeInfo = getAgentEndpointRouteInfo();
|
|
1430
|
+
hostTarget = {
|
|
1431
|
+
nodeId,
|
|
1432
|
+
leaseId: activeSameNode && previous?.leaseId
|
|
1433
|
+
? previous.leaseId
|
|
1434
|
+
: (safeString(options.leaseId, 128) || crypto.randomUUID()),
|
|
1435
|
+
hostInstanceId,
|
|
1436
|
+
endpoint: routeInfo.endpoint,
|
|
1437
|
+
endpointCandidates: routeInfo.candidates,
|
|
1438
|
+
activatedAt: activeSameNode && previous?.activatedAt ? previous.activatedAt : now.toISOString(),
|
|
1439
|
+
updatedAt: now.toISOString(),
|
|
1440
|
+
expiresAt: new Date(now.getTime() + leaseMs).toISOString()
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
emitRemoteEvent('RemoteHostTargetSet', null, {
|
|
1444
|
+
hostTarget: serializeHostTarget(hostTarget),
|
|
1445
|
+
replacedNodeId: previous && previous.nodeId !== nodeId ? previous.nodeId : ''
|
|
1446
|
+
});
|
|
1447
|
+
if (!activeSameNode || !routeInfo.accountRouteReady) {
|
|
1448
|
+
logEvent(
|
|
1449
|
+
'remote',
|
|
1450
|
+
`host target ${routeInfo.accountRouteReady ? 'account-ready' : 'local-only'} endpoint=${routeInfo.endpoint} candidates=${routeInfo.candidates.length} reason=${routeInfo.reason}`,
|
|
1451
|
+
'remote');
|
|
1452
|
+
}
|
|
1453
|
+
return {
|
|
1454
|
+
ok: true,
|
|
1455
|
+
active: true,
|
|
1456
|
+
hostTarget: serializeHostTarget(hostTarget)
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function getStatus({ includeSecrets = false } = {}) {
|
|
1461
|
+
const connectedDevices = [...devices.values()].filter(device => device.connected).length;
|
|
1462
|
+
const activeHostTarget = serializeHostTarget();
|
|
1463
|
+
const routeInfo = getAgentEndpointRouteInfo();
|
|
1464
|
+
return {
|
|
1465
|
+
enabled,
|
|
1466
|
+
started,
|
|
1467
|
+
host,
|
|
1468
|
+
agentHost: routeInfo.host || getAnnouncedHost(),
|
|
1469
|
+
port: boundPort || requestedPort,
|
|
1470
|
+
protocol: 'tcp-jsonl',
|
|
1471
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
1472
|
+
agentProtocol: REMOTE_AGENT_PROTOCOL,
|
|
1473
|
+
frameProtocol: buildRemoteFrameProtocolDescriptor(),
|
|
1474
|
+
frameModes: getSupportedRemoteFrameModeProfiles(),
|
|
1475
|
+
heartbeatMs,
|
|
1476
|
+
taskTimeoutMs,
|
|
1477
|
+
managerPackage,
|
|
1478
|
+
managerVersion,
|
|
1479
|
+
hostInstanceId,
|
|
1480
|
+
agentPackage: '@livedesk/client',
|
|
1481
|
+
agentEndpoint: routeInfo.endpoint,
|
|
1482
|
+
agentEndpointAccountRouteReady: routeInfo.accountRouteReady,
|
|
1483
|
+
agentEndpointRouteReason: routeInfo.reason,
|
|
1484
|
+
agentEndpointCandidates: routeInfo.candidates,
|
|
1485
|
+
agentEndpointCandidateDetails: routeInfo.candidateDetails,
|
|
1486
|
+
pairToken: includeSecrets ? pairToken : undefined,
|
|
1487
|
+
pairTokenPreview: maskToken(pairToken),
|
|
1488
|
+
deviceCount: devices.size,
|
|
1489
|
+
connectedDeviceCount: connectedDevices,
|
|
1490
|
+
canvasDeviceListMode: 'all-devices',
|
|
1491
|
+
canvasPagination: 'none',
|
|
1492
|
+
hostTargetActive: activeHostTarget.active,
|
|
1493
|
+
hostTargetNodeId: activeHostTarget.nodeId,
|
|
1494
|
+
hostTargetLeaseId: activeHostTarget.leaseId,
|
|
1495
|
+
hostTargetHostInstanceId: activeHostTarget.hostInstanceId,
|
|
1496
|
+
hostTargetEndpoint: activeHostTarget.endpoint,
|
|
1497
|
+
hostTargetEndpointCandidates: activeHostTarget.endpointCandidates,
|
|
1498
|
+
hostTargetActivatedAt: activeHostTarget.activatedAt,
|
|
1499
|
+
hostTargetUpdatedAt: activeHostTarget.updatedAt,
|
|
1500
|
+
hostTargetExpiresAt: activeHostTarget.expiresAt,
|
|
1501
|
+
publicIPv4Address: publicIPv4Cache.address,
|
|
1502
|
+
publicIPv4UpdatedAt: publicIPv4Cache.updatedAt ? new Date(publicIPv4Cache.updatedAt).toISOString() : '',
|
|
1503
|
+
publicIPv4Error: publicIPv4Cache.error,
|
|
1504
|
+
externalExposure: isWildcardHost(host) || !isLoopbackHost(host),
|
|
1505
|
+
lastError
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function listDevices(options = {}) {
|
|
1510
|
+
const serializeOptions = {
|
|
1511
|
+
includeDataUrl: options.includeDataUrl === true
|
|
1512
|
+
};
|
|
1513
|
+
return [...devices.values()]
|
|
1514
|
+
.map(device => serializeDevice(device, serializeOptions))
|
|
1515
|
+
.filter(Boolean)
|
|
1516
|
+
.sort((a, b) => {
|
|
1517
|
+
const nameCompare = String(a.deviceName || '').localeCompare(String(b.deviceName || ''));
|
|
1518
|
+
const slotA = Number(a.slotNumber || 0);
|
|
1519
|
+
const slotB = Number(b.slotNumber || 0);
|
|
1520
|
+
if (slotA > 0 || slotB > 0) {
|
|
1521
|
+
if (slotA <= 0) {
|
|
1522
|
+
return 1;
|
|
1523
|
+
}
|
|
1524
|
+
if (slotB <= 0) {
|
|
1525
|
+
return -1;
|
|
1526
|
+
}
|
|
1527
|
+
if (slotA !== slotB) {
|
|
1528
|
+
return slotA - slotB;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
if (nameCompare !== 0) {
|
|
1532
|
+
return nameCompare;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
return String(a.deviceId).localeCompare(String(b.deviceId));
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function listDeviceFrames(options = {}) {
|
|
1540
|
+
const ids = Array.isArray(options.deviceIds)
|
|
1541
|
+
? options.deviceIds.map(value => safeString(value, 160)).filter(Boolean)
|
|
1542
|
+
: [];
|
|
1543
|
+
const idSet = ids.length > 0 ? new Set(ids) : null;
|
|
1544
|
+
const includeThumbnail = options.includeThumbnail !== false;
|
|
1545
|
+
const includeLive = options.includeLive !== false;
|
|
1546
|
+
|
|
1547
|
+
return [...devices.values()]
|
|
1548
|
+
.filter(device => !idSet || idSet.has(device.deviceId))
|
|
1549
|
+
.map(device => ({
|
|
1550
|
+
deviceId: device.deviceId,
|
|
1551
|
+
connected: device.connected === true,
|
|
1552
|
+
thumbnailEnabled: readCapabilityFlag(device.capabilities, 'thumbnail'),
|
|
1553
|
+
liveStreamActive: device.activeLiveStream?.active === true,
|
|
1554
|
+
liveStreamId: device.activeLiveStream?.streamId || '',
|
|
1555
|
+
liveStreamFps: Number(device.activeLiveStream?.fps || 0),
|
|
1556
|
+
liveStreamLastFrameAt: device.activeLiveStream?.lastFrameAt || '',
|
|
1557
|
+
thumbnail: includeThumbnail
|
|
1558
|
+
? serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', { includeDataUrl: false })
|
|
1559
|
+
: null,
|
|
1560
|
+
live: includeLive
|
|
1561
|
+
? serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', { includeDataUrl: false })
|
|
1562
|
+
: null
|
|
1563
|
+
}))
|
|
1564
|
+
.sort((a, b) => String(a.deviceId).localeCompare(String(b.deviceId)));
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function listTaskBatches() {
|
|
1568
|
+
return [...taskBatches.values()]
|
|
1569
|
+
.map(serializeTaskBatch)
|
|
1570
|
+
.filter(Boolean)
|
|
1571
|
+
.sort((left, right) => String(right.requestedAt || '').localeCompare(String(left.requestedAt || '')));
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function getLatestTaskBatch() {
|
|
1575
|
+
return listTaskBatches()[0] || null;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function emitRemoteEvent(type, device = null, extra = {}) {
|
|
1579
|
+
emitEvent(type, {
|
|
1580
|
+
...extra,
|
|
1581
|
+
remoteHub: getStatus({ includeSecrets: false }),
|
|
1582
|
+
device: serializeDevice(device)
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
|
|
1587
|
+
const now = new Date().toISOString();
|
|
1588
|
+
const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
|
|
1589
|
+
const frameSeq = Number(device?.counters?.thumbnailFramesReceived || 0)
|
|
1590
|
+
+ Number(device?.counters?.liveFramesReceived || 0)
|
|
1591
|
+
+ 1;
|
|
1592
|
+
return {
|
|
1593
|
+
streamId: safeString(streamId, 128) || `${mode}-${Date.now()}`,
|
|
1594
|
+
frameSeq,
|
|
1595
|
+
commandId: safeString(options.commandId, 128),
|
|
1596
|
+
width: clampNumber(options.width, 2, 2560, mode === 'remote-fast' ? 960 : 360),
|
|
1597
|
+
height: clampNumber(options.height, 1, 1440, mode === 'remote-fast' ? 540 : 220),
|
|
1598
|
+
mimeType: 'image/png',
|
|
1599
|
+
format: 'image/png',
|
|
1600
|
+
mode,
|
|
1601
|
+
frameMode: descriptor.frameMode,
|
|
1602
|
+
frameProfile: descriptor.frameProfile,
|
|
1603
|
+
encoding: descriptor.encoding,
|
|
1604
|
+
codec: 'png',
|
|
1605
|
+
compression: 'image/png',
|
|
1606
|
+
transferProtocol: descriptor.transferProtocol,
|
|
1607
|
+
transferProtocolVersion: descriptor.transferProtocolVersion,
|
|
1608
|
+
handshake: descriptor.handshake,
|
|
1609
|
+
fps: clampNumber(options.fps, 1, 24, mode === 'remote-fast' ? 20 : 1),
|
|
1610
|
+
capturedAt: now,
|
|
1611
|
+
receivedAt: now,
|
|
1612
|
+
byteLength: 68,
|
|
1613
|
+
transport: 'synthetic',
|
|
1614
|
+
contentHash: SYNTHETIC_FRAME_HASH,
|
|
1615
|
+
captureMs: 0,
|
|
1616
|
+
sameContentStreak: 0,
|
|
1617
|
+
dataUrl: SYNTHETIC_FRAME_DATA_URL,
|
|
1618
|
+
payload: SYNTHETIC_FRAME_PAYLOAD,
|
|
1619
|
+
accessToken: createFrameAccessToken()
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function applySyntheticThumbnail(device, command) {
|
|
1624
|
+
if (!device) {
|
|
1625
|
+
return null;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
const payload = command?.payload || {};
|
|
1629
|
+
const frame = makeSyntheticFrame(device, payload.streamId || 'synthetic-thumb', 'thumbnail', {
|
|
1630
|
+
commandId: command?.commandId,
|
|
1631
|
+
width: payload.maxWidth || 360,
|
|
1632
|
+
height: payload.maxHeight || 220
|
|
1633
|
+
});
|
|
1634
|
+
delete frame.mode;
|
|
1635
|
+
delete frame.fps;
|
|
1636
|
+
device.latestThumbnail = frame;
|
|
1637
|
+
rememberRecentFramePayload(device, 'thumbnail', frame);
|
|
1638
|
+
device.lastSeenAt = frame.receivedAt;
|
|
1639
|
+
device.counters.thumbnailFramesReceived += 1;
|
|
1640
|
+
emitRemoteEvent('RemoteFrameReceived', device, {
|
|
1641
|
+
streamId: frame.streamId,
|
|
1642
|
+
frameSeq: frame.frameSeq,
|
|
1643
|
+
synthetic: true
|
|
1644
|
+
});
|
|
1645
|
+
return frame;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
function applySyntheticLiveFrame(device, streamId, options = {}) {
|
|
1649
|
+
if (!device?.activeLiveStream?.active) {
|
|
1650
|
+
return null;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const frame = makeSyntheticFrame(device, streamId || device.activeLiveStream.streamId, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE, {
|
|
1654
|
+
commandId: options.commandId || device.activeLiveStream.commandId,
|
|
1655
|
+
width: options.maxWidth || 960,
|
|
1656
|
+
height: options.maxHeight || 540,
|
|
1657
|
+
fps: options.fps || device.activeLiveStream.fps
|
|
1658
|
+
});
|
|
1659
|
+
device.latestLiveFrame = frame;
|
|
1660
|
+
rememberRecentFramePayload(device, 'live', frame);
|
|
1661
|
+
emitFrame({
|
|
1662
|
+
kind: 'live',
|
|
1663
|
+
deviceId: device.deviceId,
|
|
1664
|
+
frame: serializeRemoteFrame(frame, device.deviceId, 'live', { includeDataUrl: false }),
|
|
1665
|
+
payload: frame.payload,
|
|
1666
|
+
mimeType: frame.mimeType,
|
|
1667
|
+
byteLength: frame.byteLength
|
|
1668
|
+
});
|
|
1669
|
+
device.activeLiveStream.lastFrameAt = frame.receivedAt;
|
|
1670
|
+
device.activeLiveStream.lastFrameSeq = frame.frameSeq;
|
|
1671
|
+
device.activeLiveStream.framesReceived = (device.activeLiveStream.framesReceived || 0) + 1;
|
|
1672
|
+
device.lastSeenAt = frame.receivedAt;
|
|
1673
|
+
device.counters.liveFramesReceived += 1;
|
|
1674
|
+
emitRemoteEvent('RemoteFrameReceived', device, {
|
|
1675
|
+
streamId: frame.streamId,
|
|
1676
|
+
frameSeq: frame.frameSeq,
|
|
1677
|
+
synthetic: true
|
|
1678
|
+
});
|
|
1679
|
+
return frame;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
function closeExistingDeviceSocket(deviceId, nextSessionId) {
|
|
1683
|
+
const existing = devices.get(deviceId);
|
|
1684
|
+
if (!existing?.socket || existing.socket.destroyed) {
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
existing.lastDisconnectReason = 'replaced-by-new-session';
|
|
1689
|
+
failAllPendingTasks(existing, 'replaced-by-new-session');
|
|
1690
|
+
writeJsonLine(existing.socket, {
|
|
1691
|
+
type: 'disconnect',
|
|
1692
|
+
reason: 'replaced-by-new-session',
|
|
1693
|
+
nextSessionId
|
|
1694
|
+
});
|
|
1695
|
+
existing.socket.destroy();
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function getDeviceActivityMs(device) {
|
|
1699
|
+
return Math.max(
|
|
1700
|
+
Date.parse(device?.lastSeenAt || '') || 0,
|
|
1701
|
+
Date.parse(device?.lastStatusAt || '') || 0,
|
|
1702
|
+
Date.parse(device?.connectedAt || '') || 0
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function isDeviceConnectionFresh(device, nowMs = Date.now()) {
|
|
1707
|
+
if (!device?.connected || !device.socket || device.socket.destroyed) {
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const lastActivityMs = getDeviceActivityMs(device);
|
|
1712
|
+
if (!lastActivityMs) {
|
|
1713
|
+
return true;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
const freshnessMs = Math.max(DUPLICATE_DEVICE_ACTIVE_REJECT_MS, heartbeatMs * 3);
|
|
1717
|
+
return nowMs - lastActivityMs <= freshnessMs;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function rejectDuplicateActiveDevice(socket, existing, attemptedSessionId) {
|
|
1721
|
+
const retryAfterMs = Math.max(heartbeatMs, duplicateDeviceRetryAfterMs);
|
|
1722
|
+
writeJsonLine(socket, {
|
|
1723
|
+
type: 'disconnect',
|
|
1724
|
+
reason: 'duplicate-device-active',
|
|
1725
|
+
retryAfterMs,
|
|
1726
|
+
activeSessionId: existing.sessionId,
|
|
1727
|
+
attemptedSessionId
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
existing.counters.duplicateConnectionsRejected = (existing.counters.duplicateConnectionsRejected || 0) + 1;
|
|
1731
|
+
const nowMs = Date.now();
|
|
1732
|
+
const lastLoggedAt = duplicateDeviceLogAt.get(existing.deviceId) || 0;
|
|
1733
|
+
if (nowMs - lastLoggedAt >= duplicateDeviceLogThrottleMs) {
|
|
1734
|
+
duplicateDeviceLogAt.set(existing.deviceId, nowMs);
|
|
1735
|
+
logEvent(
|
|
1736
|
+
'remote',
|
|
1737
|
+
`duplicate device connection suppressed ${existing.deviceName} (${existing.deviceId}); keeping active session ${existing.sessionId}`,
|
|
1738
|
+
'remote');
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
try {
|
|
1742
|
+
socket.end?.();
|
|
1743
|
+
} catch {
|
|
1744
|
+
// Best-effort graceful close before hard destroy.
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
setTimeout(() => {
|
|
1748
|
+
try {
|
|
1749
|
+
socket.destroy?.();
|
|
1750
|
+
} catch {
|
|
1751
|
+
// Ignore destroy failures.
|
|
1752
|
+
}
|
|
1753
|
+
}, 25).unref?.();
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function clearSyntheticFleet() {
|
|
1757
|
+
let removed = 0;
|
|
1758
|
+
for (const [deviceId, device] of devices.entries()) {
|
|
1759
|
+
if (device.synthetic === true) {
|
|
1760
|
+
devices.delete(deviceId);
|
|
1761
|
+
removed += 1;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
return { ok: true, removed, total: devices.size };
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function seedSyntheticFleet(options = {}) {
|
|
1769
|
+
const count = clampNumber(options.count, 1, MAX_SYNTHETIC_DEVICES, 250);
|
|
1770
|
+
const connectedRatio = Math.max(0, Math.min(1, Number(options.connectedRatio ?? 0.88)));
|
|
1771
|
+
const thumbnailRatio = Math.max(0, Math.min(1, Number(options.thumbnailRatio ?? 0.7)));
|
|
1772
|
+
const aiAssistRatio = Math.max(0, Math.min(1, Number(options.aiAssistRatio ?? 0.35)));
|
|
1773
|
+
const liveCount = clampNumber(options.liveCount, 0, Math.min(count, 24), Math.min(6, count));
|
|
1774
|
+
const replace = options.replace !== false;
|
|
1775
|
+
const now = new Date();
|
|
1776
|
+
const platforms = ['win32', 'linux', 'darwin'];
|
|
1777
|
+
const machineKinds = ['Mac mini', 'Mini PC', 'Workstation', 'Render node', 'Dev box'];
|
|
1778
|
+
const workloadRoles = ['LLM worker', 'Image worker', 'Video worker', 'Idle reserve', 'Build worker'];
|
|
1779
|
+
const gpuNames = ['Apple M-series GPU', 'NVIDIA RTX local', 'Radeon Pro', 'Intel Arc', 'Integrated GPU'];
|
|
1780
|
+
const npuNames = ['Apple Neural Engine', 'Ryzen AI NPU', 'Intel AI Boost', 'Qualcomm Hexagon', 'NPU not present'];
|
|
1781
|
+
|
|
1782
|
+
if (replace) {
|
|
1783
|
+
clearSyntheticFleet();
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
let connected = 0;
|
|
1787
|
+
let aiAssist = 0;
|
|
1788
|
+
let live = 0;
|
|
1789
|
+
for (let index = 0; index < count; index += 1) {
|
|
1790
|
+
const ordinal = index + 1;
|
|
1791
|
+
const deviceId = `synthetic-${String(ordinal).padStart(4, '0')}`;
|
|
1792
|
+
const isConnected = index / count < connectedRatio;
|
|
1793
|
+
const hasThumbnail = index / count < thumbnailRatio;
|
|
1794
|
+
const hasAiAssist = index / count < aiAssistRatio;
|
|
1795
|
+
const isLive = isConnected && index < liveCount;
|
|
1796
|
+
const seenAt = new Date(now.getTime() - index * 1350).toISOString();
|
|
1797
|
+
const connectedAt = new Date(now.getTime() - (index + 8) * 60000).toISOString();
|
|
1798
|
+
const platform = platforms[index % platforms.length];
|
|
1799
|
+
const usedMemRatio = Number((0.18 + (index % 71) / 100).toFixed(2));
|
|
1800
|
+
const load1 = Number(((index % 19) / 3).toFixed(2));
|
|
1801
|
+
const cpuCores = platform === 'darwin' ? 10 : index % 3 === 0 ? 24 : 12;
|
|
1802
|
+
const cpuUsageRatio = Number(Math.min(0.96, Math.max(0.04, load1 / Math.max(1, cpuCores))).toFixed(2));
|
|
1803
|
+
const diskTotalBytes = (index % 4 === 0 ? 4 : 2) * 1024 ** 4;
|
|
1804
|
+
const diskUsedRatio = Number((0.22 + (index % 61) / 100).toFixed(2));
|
|
1805
|
+
const diskFreeBytes = Math.round(diskTotalBytes * (1 - diskUsedRatio));
|
|
1806
|
+
const temperatureC = 42 + (index % 48);
|
|
1807
|
+
const gpuUsageRatio = Number(Math.min(0.98, Math.max(0.03, ((index * 7) % 89) / 100)).toFixed(2));
|
|
1808
|
+
const gpuMemoryTotalBytes = (platform === 'darwin' ? 32 : index % 3 === 0 ? 24 : 12) * 1024 ** 3;
|
|
1809
|
+
const gpuMemoryUsedRatio = Number((0.2 + ((index * 5) % 67) / 100).toFixed(2));
|
|
1810
|
+
const gpuMemoryFreeBytes = Math.round(gpuMemoryTotalBytes * (1 - gpuMemoryUsedRatio));
|
|
1811
|
+
const npuUsageRatio = index % 5 === 4
|
|
1812
|
+
? undefined
|
|
1813
|
+
: Number(Math.min(0.95, Math.max(0.02, ((index * 11) % 73) / 100)).toFixed(2));
|
|
1814
|
+
const npuTops = index % 5 === 4 ? 0 : platform === 'darwin' ? 38 : index % 2 === 0 ? 45 : 16;
|
|
1815
|
+
const machineKind = machineKinds[index % machineKinds.length];
|
|
1816
|
+
const workloadRole = workloadRoles[index % workloadRoles.length];
|
|
1817
|
+
const toolBusy = index % 9 === 0;
|
|
1818
|
+
const toolOnline = index % 4 !== 3;
|
|
1819
|
+
const taskStatus = index % 17 === 0
|
|
1820
|
+
? 'failed'
|
|
1821
|
+
: index % 5 === 0
|
|
1822
|
+
? 'completed'
|
|
1823
|
+
: index % 7 === 0
|
|
1824
|
+
? 'queued'
|
|
1825
|
+
: '';
|
|
1826
|
+
const latestTask = taskStatus
|
|
1827
|
+
? {
|
|
1828
|
+
taskId: `synthetic-task-${ordinal}`,
|
|
1829
|
+
commandId: `synthetic-command-${ordinal}`,
|
|
1830
|
+
title: taskStatus === 'failed' ? 'Synthetic issue check' : 'Synthetic status task',
|
|
1831
|
+
instructionPreview: 'Synthetic fleet scale validation task.',
|
|
1832
|
+
status: taskStatus,
|
|
1833
|
+
approvalLevel: hasAiAssist ? 'ai-assist' : 'task-only',
|
|
1834
|
+
requestedAt: seenAt,
|
|
1835
|
+
sentAt: seenAt,
|
|
1836
|
+
updatedAt: seenAt,
|
|
1837
|
+
completedAt: taskStatus === 'completed' || taskStatus === 'failed' ? seenAt : '',
|
|
1838
|
+
error: taskStatus === 'failed' ? 'Synthetic task failure sample.' : '',
|
|
1839
|
+
resultKind: 'synthetic-agent-task',
|
|
1840
|
+
resultModel: hasAiAssist ? 'synthetic-ai' : '',
|
|
1841
|
+
resultResponseId: hasAiAssist ? `synthetic-response-${ordinal}` : '',
|
|
1842
|
+
resultSummary: taskStatus === 'failed'
|
|
1843
|
+
? 'Synthetic task reported a sample issue.'
|
|
1844
|
+
: 'Synthetic task completed for scale validation.'
|
|
1845
|
+
}
|
|
1846
|
+
: null;
|
|
1847
|
+
const device = {
|
|
1848
|
+
socket: null,
|
|
1849
|
+
synthetic: true,
|
|
1850
|
+
deviceId,
|
|
1851
|
+
sessionId: `synthetic-session-${ordinal}`,
|
|
1852
|
+
deviceName: `${machineKind} ${String(ordinal).padStart(3, '0')}`,
|
|
1853
|
+
hostname: `synthetic-host-${String(ordinal).padStart(4, '0')}`,
|
|
1854
|
+
platform,
|
|
1855
|
+
arch: index % 4 === 0 ? 'arm64' : 'x64',
|
|
1856
|
+
pid: 0,
|
|
1857
|
+
agentVersion: 'synthetic-scale',
|
|
1858
|
+
capabilities: {
|
|
1859
|
+
status: true,
|
|
1860
|
+
thumbnail: hasThumbnail,
|
|
1861
|
+
control: false,
|
|
1862
|
+
liveStream: true,
|
|
1863
|
+
computerAgent: true,
|
|
1864
|
+
taskDispatch: true,
|
|
1865
|
+
aiAssist: hasAiAssist,
|
|
1866
|
+
aiModel: hasAiAssist ? 'synthetic-ai' : '',
|
|
1867
|
+
aiProvider: hasAiAssist ? 'synthetic' : ''
|
|
1868
|
+
},
|
|
1869
|
+
connected: isConnected,
|
|
1870
|
+
connectedAt: isConnected ? connectedAt : '',
|
|
1871
|
+
disconnectedAt: isConnected ? '' : seenAt,
|
|
1872
|
+
lastSeenAt: seenAt,
|
|
1873
|
+
lastStatusAt: seenAt,
|
|
1874
|
+
lastDisconnectReason: isConnected ? '' : 'synthetic-offline',
|
|
1875
|
+
remoteAddress: 'synthetic.local',
|
|
1876
|
+
remotePort: 0,
|
|
1877
|
+
status: {
|
|
1878
|
+
platform,
|
|
1879
|
+
release: platform === 'win32' ? 'Windows 11' : platform === 'darwin' ? 'macOS' : 'Linux',
|
|
1880
|
+
uptimeSec: (ordinal * 791) % 1209600,
|
|
1881
|
+
role: workloadRole,
|
|
1882
|
+
usedMemRatio,
|
|
1883
|
+
totalMem: cpuCores >= 20 ? 128 * 1024 ** 3 : 64 * 1024 ** 3,
|
|
1884
|
+
freeMem: Math.round((cpuCores >= 20 ? 128 * 1024 ** 3 : 64 * 1024 ** 3) * (1 - usedMemRatio)),
|
|
1885
|
+
loadavg: [load1, Math.max(0, load1 - 0.2), Math.max(0, load1 - 0.4)],
|
|
1886
|
+
cpu: {
|
|
1887
|
+
cores: cpuCores,
|
|
1888
|
+
usageRatio: cpuUsageRatio,
|
|
1889
|
+
load1
|
|
1890
|
+
},
|
|
1891
|
+
memory: {
|
|
1892
|
+
usedRatio: usedMemRatio
|
|
1893
|
+
},
|
|
1894
|
+
disk: {
|
|
1895
|
+
root: platform === 'win32' ? 'C:\\' : '/',
|
|
1896
|
+
totalBytes: diskTotalBytes,
|
|
1897
|
+
freeBytes: diskFreeBytes,
|
|
1898
|
+
usedRatio: diskUsedRatio
|
|
1899
|
+
},
|
|
1900
|
+
gpu: {
|
|
1901
|
+
name: gpuNames[index % gpuNames.length],
|
|
1902
|
+
usageRatio: gpuUsageRatio,
|
|
1903
|
+
temperatureC: temperatureC + 3,
|
|
1904
|
+
memory: {
|
|
1905
|
+
totalBytes: gpuMemoryTotalBytes,
|
|
1906
|
+
freeBytes: gpuMemoryFreeBytes,
|
|
1907
|
+
usedRatio: gpuMemoryUsedRatio
|
|
1908
|
+
}
|
|
1909
|
+
},
|
|
1910
|
+
npu: {
|
|
1911
|
+
name: npuNames[index % npuNames.length],
|
|
1912
|
+
usageRatio: npuUsageRatio,
|
|
1913
|
+
tops: npuTops
|
|
1914
|
+
},
|
|
1915
|
+
thermal: {
|
|
1916
|
+
cpuC: temperatureC,
|
|
1917
|
+
gpuC: temperatureC + 3
|
|
1918
|
+
},
|
|
1919
|
+
network: {
|
|
1920
|
+
link: index % 6 === 0 ? '2.5GbE' : '1GbE',
|
|
1921
|
+
latencyMs: 1 + (index % 12),
|
|
1922
|
+
rxMbps: 35 + ((index * 13) % 640),
|
|
1923
|
+
txMbps: 12 + ((index * 17) % 220),
|
|
1924
|
+
usageRatio: Number(Math.min(0.94, ((index * 9) % 81) / 100).toFixed(2))
|
|
1925
|
+
},
|
|
1926
|
+
workload: {
|
|
1927
|
+
role: workloadRole,
|
|
1928
|
+
status: taskStatus || (toolBusy ? 'running' : 'idle'),
|
|
1929
|
+
title: taskStatus ? 'Fleet validation task' : toolBusy ? 'Processing local job' : 'idle',
|
|
1930
|
+
summary: taskStatus ? 'Synthetic status for fleet-scale validation.' : ''
|
|
1931
|
+
},
|
|
1932
|
+
tools: {
|
|
1933
|
+
ollama: {
|
|
1934
|
+
status: toolOnline ? (toolBusy ? 'busy' : 'online') : 'offline',
|
|
1935
|
+
port: 11434,
|
|
1936
|
+
version: toolOnline ? 'local' : ''
|
|
1937
|
+
},
|
|
1938
|
+
lmStudio: {
|
|
1939
|
+
status: index % 5 === 0 ? 'online' : 'offline',
|
|
1940
|
+
port: 1234
|
|
1941
|
+
},
|
|
1942
|
+
comfyui: {
|
|
1943
|
+
status: index % 3 === 0 ? (toolBusy ? 'busy' : 'online') : 'offline',
|
|
1944
|
+
port: 8188
|
|
1945
|
+
},
|
|
1946
|
+
stableDiffusion: {
|
|
1947
|
+
status: index % 7 === 0 ? 'online' : 'offline',
|
|
1948
|
+
port: 7860
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
},
|
|
1952
|
+
latestThumbnail: null,
|
|
1953
|
+
latestLiveFrame: null,
|
|
1954
|
+
recentFramePayloads: {
|
|
1955
|
+
thumbnail: [],
|
|
1956
|
+
live: []
|
|
1957
|
+
},
|
|
1958
|
+
activeLiveStream: null,
|
|
1959
|
+
activeAudioStream: null,
|
|
1960
|
+
latestAudioFrame: null,
|
|
1961
|
+
latestTask,
|
|
1962
|
+
recentTasks: latestTask ? [latestTask] : [],
|
|
1963
|
+
pendingTaskCommands: new Map(),
|
|
1964
|
+
pendingTaskTimers: new Map(),
|
|
1965
|
+
counters: createDeviceCounters({
|
|
1966
|
+
messagesReceived: 1,
|
|
1967
|
+
statusReceived: 1,
|
|
1968
|
+
tasksQueued: latestTask ? 1 : 0,
|
|
1969
|
+
taskResultsReceived: latestTask && ['completed', 'failed'].includes(latestTask.status) ? 1 : 0,
|
|
1970
|
+
taskResultsFailed: latestTask?.status === 'failed' ? 1 : 0
|
|
1971
|
+
})
|
|
1972
|
+
};
|
|
1973
|
+
|
|
1974
|
+
if (hasThumbnail) {
|
|
1975
|
+
const frame = makeSyntheticFrame(device, `synthetic-thumb-${ordinal}`, 'thumbnail', {
|
|
1976
|
+
width: 360,
|
|
1977
|
+
height: 220
|
|
1978
|
+
});
|
|
1979
|
+
delete frame.mode;
|
|
1980
|
+
delete frame.fps;
|
|
1981
|
+
frame.receivedAt = seenAt;
|
|
1982
|
+
frame.capturedAt = seenAt;
|
|
1983
|
+
device.latestThumbnail = frame;
|
|
1984
|
+
rememberRecentFramePayload(device, 'thumbnail', frame);
|
|
1985
|
+
device.counters.thumbnailFramesReceived = 1;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
if (isLive) {
|
|
1989
|
+
const transfer = buildRemoteFrameTransferDescriptor(DEFAULT_REMOTE_FRAME_MODE);
|
|
1990
|
+
device.activeLiveStream = {
|
|
1991
|
+
streamId: `synthetic-live-${ordinal}`,
|
|
1992
|
+
commandId: `synthetic-live-command-${ordinal}`,
|
|
1993
|
+
active: true,
|
|
1994
|
+
open: true,
|
|
1995
|
+
openedAt: seenAt,
|
|
1996
|
+
openTransport: 'synthetic',
|
|
1997
|
+
mode: transfer.mode,
|
|
1998
|
+
frameMode: transfer.frameMode,
|
|
1999
|
+
frameProfile: transfer.frameProfile,
|
|
2000
|
+
encoding: transfer.encoding,
|
|
2001
|
+
codec: transfer.codec,
|
|
2002
|
+
compression: transfer.compression,
|
|
2003
|
+
transferProtocol: transfer.transferProtocol,
|
|
2004
|
+
transferProtocolVersion: transfer.transferProtocolVersion,
|
|
2005
|
+
handshake: transfer.handshake,
|
|
2006
|
+
fps: 20,
|
|
2007
|
+
startedAt: seenAt,
|
|
2008
|
+
stoppedAt: '',
|
|
2009
|
+
stopReason: '',
|
|
2010
|
+
lastFrameAt: '',
|
|
2011
|
+
lastFrameSeq: 0,
|
|
2012
|
+
framesReceived: 0
|
|
2013
|
+
};
|
|
2014
|
+
applySyntheticLiveFrame(device, device.activeLiveStream.streamId, {
|
|
2015
|
+
commandId: device.activeLiveStream.commandId,
|
|
2016
|
+
fps: 20
|
|
2017
|
+
});
|
|
2018
|
+
live += 1;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
if (isConnected) {
|
|
2022
|
+
connected += 1;
|
|
2023
|
+
}
|
|
2024
|
+
if (hasAiAssist) {
|
|
2025
|
+
aiAssist += 1;
|
|
2026
|
+
}
|
|
2027
|
+
devices.set(deviceId, device);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
emitRemoteEvent('RemoteSyntheticFleetSeeded', null, {
|
|
2031
|
+
count,
|
|
2032
|
+
connected,
|
|
2033
|
+
aiAssist,
|
|
2034
|
+
live
|
|
2035
|
+
});
|
|
2036
|
+
return {
|
|
2037
|
+
ok: true,
|
|
2038
|
+
synthetic: true,
|
|
2039
|
+
seeded: count,
|
|
2040
|
+
connected,
|
|
2041
|
+
aiAssist,
|
|
2042
|
+
live,
|
|
2043
|
+
total: devices.size,
|
|
2044
|
+
max: MAX_SYNTHETIC_DEVICES
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
function attachDevice(socket, hello) {
|
|
2049
|
+
const nowMs = Date.now();
|
|
2050
|
+
const now = new Date(nowMs).toISOString();
|
|
2051
|
+
const sessionId = crypto.randomUUID();
|
|
2052
|
+
const deviceId = normalizeDeviceId(hello.deviceId);
|
|
2053
|
+
const existing = devices.get(deviceId);
|
|
2054
|
+
|
|
2055
|
+
if (isDeviceConnectionFresh(existing, nowMs)) {
|
|
2056
|
+
rejectDuplicateActiveDevice(socket, existing, sessionId);
|
|
2057
|
+
return null;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
closeExistingDeviceSocket(deviceId, sessionId);
|
|
2061
|
+
|
|
2062
|
+
const frameProtocol = typeof hello.frameProtocol === 'object' && hello.frameProtocol
|
|
2063
|
+
? { ...hello.frameProtocol }
|
|
2064
|
+
: null;
|
|
2065
|
+
const frameModes = Array.isArray(hello.frameModes)
|
|
2066
|
+
? hello.frameModes.map(normalizeIncomingFrameModeProfile).filter(Boolean)
|
|
2067
|
+
: [];
|
|
2068
|
+
const capabilities = typeof hello.capabilities === 'object' && hello.capabilities
|
|
2069
|
+
? { ...hello.capabilities }
|
|
2070
|
+
: {};
|
|
2071
|
+
if (!capabilities.frameProtocol) {
|
|
2072
|
+
capabilities.frameProtocol = frameProtocol || buildRemoteFrameProtocolDescriptor();
|
|
2073
|
+
}
|
|
2074
|
+
if (!capabilities.frameModes) {
|
|
2075
|
+
capabilities.frameModes = frameModes.length > 0
|
|
2076
|
+
? frameModes.map(profile => ({ ...profile }))
|
|
2077
|
+
: getSupportedRemoteFrameModeProfiles();
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
const device = {
|
|
2081
|
+
socket,
|
|
2082
|
+
deviceId,
|
|
2083
|
+
sessionId,
|
|
2084
|
+
deviceName: safeString(hello.deviceName || hello.hostname || deviceId, 120),
|
|
2085
|
+
hostname: safeString(hello.hostname, 120),
|
|
2086
|
+
platform: safeString(hello.platform, 80),
|
|
2087
|
+
arch: safeString(hello.arch, 40),
|
|
2088
|
+
slotNumber: normalizeSlotNumber(hello.slotNumber ?? hello.slot ?? hello.computerNumber),
|
|
2089
|
+
pid: Number.isFinite(Number(hello.pid)) ? Number(hello.pid) : 0,
|
|
2090
|
+
agentVersion: safeString(hello.agentVersion, 40),
|
|
2091
|
+
protocol: safeString(hello.protocol, 80) || REMOTE_AGENT_PROTOCOL,
|
|
2092
|
+
protocolVersion: Number.isFinite(Number(hello.protocolVersion)) ? Math.max(1, Math.floor(Number(hello.protocolVersion))) : 1,
|
|
2093
|
+
frameProtocol: frameProtocol || buildRemoteFrameProtocolDescriptor(),
|
|
2094
|
+
frameModes: frameModes.length > 0 ? frameModes : getSupportedRemoteFrameModeProfiles(),
|
|
2095
|
+
capabilities,
|
|
2096
|
+
connected: true,
|
|
2097
|
+
connectedAt: now,
|
|
2098
|
+
disconnectedAt: '',
|
|
2099
|
+
lastSeenAt: now,
|
|
2100
|
+
lastStatusAt: '',
|
|
2101
|
+
lastDisconnectReason: '',
|
|
2102
|
+
remoteAddress: socket.remoteAddress || '',
|
|
2103
|
+
remotePort: socket.remotePort || 0,
|
|
2104
|
+
status: {},
|
|
2105
|
+
latestThumbnail: null,
|
|
2106
|
+
latestLiveFrame: null,
|
|
2107
|
+
recentFramePayloads: {
|
|
2108
|
+
thumbnail: [],
|
|
2109
|
+
live: []
|
|
2110
|
+
},
|
|
2111
|
+
activeLiveStream: null,
|
|
2112
|
+
activeAudioStream: null,
|
|
2113
|
+
latestAudioFrame: null,
|
|
2114
|
+
latestTask: null,
|
|
2115
|
+
recentTasks: [],
|
|
2116
|
+
pendingTaskCommands: new Map(),
|
|
2117
|
+
pendingTaskTimers: new Map(),
|
|
2118
|
+
synthetic: false,
|
|
2119
|
+
counters: createDeviceCounters({ messagesReceived: 1 })
|
|
2120
|
+
};
|
|
2121
|
+
|
|
2122
|
+
devices.set(deviceId, device);
|
|
2123
|
+
sockets.set(socket, deviceId);
|
|
2124
|
+
writeJsonLine(socket, {
|
|
2125
|
+
type: 'welcome',
|
|
2126
|
+
protocol: REMOTE_AGENT_PROTOCOL,
|
|
2127
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
2128
|
+
sessionId,
|
|
2129
|
+
deviceId,
|
|
2130
|
+
heartbeatMs,
|
|
2131
|
+
frameProtocol: buildRemoteFrameProtocolDescriptor(),
|
|
2132
|
+
frameModes: getSupportedRemoteFrameModeProfiles(),
|
|
2133
|
+
serverTime: now
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
logEvent('remote', `device connected ${device.deviceName} (${deviceId})`, 'success');
|
|
2137
|
+
emitRemoteEvent('RemoteDeviceConnected', device);
|
|
2138
|
+
return device;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
function detachSocket(socket, reason = 'socket-closed') {
|
|
2142
|
+
const deviceId = sockets.get(socket);
|
|
2143
|
+
sockets.delete(socket);
|
|
2144
|
+
if (!deviceId) {
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
const device = devices.get(deviceId);
|
|
2149
|
+
if (!device || device.socket !== socket) {
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
device.connected = false;
|
|
2154
|
+
device.socket = null;
|
|
2155
|
+
device.disconnectedAt = new Date().toISOString();
|
|
2156
|
+
device.lastDisconnectReason = reason;
|
|
2157
|
+
if (device.activeLiveStream) {
|
|
2158
|
+
device.activeLiveStream.active = false;
|
|
2159
|
+
device.activeLiveStream.stoppedAt = device.disconnectedAt;
|
|
2160
|
+
device.activeLiveStream.stopReason = reason;
|
|
2161
|
+
}
|
|
2162
|
+
failAllPendingTasks(device, 'device-disconnected');
|
|
2163
|
+
logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
|
|
2164
|
+
emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
function rememberDeviceTask(device, task) {
|
|
2168
|
+
if (!device || !task) {
|
|
2169
|
+
return null;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
const existingIndex = device.recentTasks.findIndex(item =>
|
|
2173
|
+
item.taskId === task.taskId || item.commandId === task.commandId);
|
|
2174
|
+
if (existingIndex >= 0) {
|
|
2175
|
+
device.recentTasks.splice(existingIndex, 1);
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
device.recentTasks.unshift(task);
|
|
2179
|
+
if (device.recentTasks.length > RECENT_TASK_LIMIT) {
|
|
2180
|
+
device.recentTasks.length = RECENT_TASK_LIMIT;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
device.latestTask = task;
|
|
2184
|
+
if (task.commandId) {
|
|
2185
|
+
device.pendingTaskCommands.set(task.commandId, task);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
return task;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
function trimTaskBatches() {
|
|
2192
|
+
const ordered = [...taskBatches.values()]
|
|
2193
|
+
.sort((left, right) => String(right.requestedAt || '').localeCompare(String(left.requestedAt || '')));
|
|
2194
|
+
for (const batch of ordered.slice(RECENT_TASK_BATCH_LIMIT)) {
|
|
2195
|
+
taskBatches.delete(batch.batchId);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
function beginTaskBatch(deviceIds, options = {}) {
|
|
2200
|
+
const targets = [...new Set((deviceIds || [])
|
|
2201
|
+
.map(deviceId => safeString(deviceId, 128))
|
|
2202
|
+
.filter(Boolean))]
|
|
2203
|
+
.slice(0, 500);
|
|
2204
|
+
const now = new Date().toISOString();
|
|
2205
|
+
const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
|
|
2206
|
+
const batchId = safeString(options.batchId, 128) || crypto.randomUUID();
|
|
2207
|
+
const title = safeString(options.title, 120)
|
|
2208
|
+
|| safeString(instruction.split(/\r?\n/)[0], 120)
|
|
2209
|
+
|| 'Remote task batch';
|
|
2210
|
+
const batch = {
|
|
2211
|
+
batchId,
|
|
2212
|
+
title,
|
|
2213
|
+
instructionPreview: safeText(instruction, 320),
|
|
2214
|
+
approvalLevel: normalizeApprovalLevel(options.approvalLevel),
|
|
2215
|
+
status: targets.length > 0 ? 'running' : 'failed',
|
|
2216
|
+
requestedAt: now,
|
|
2217
|
+
updatedAt: now,
|
|
2218
|
+
total: targets.length,
|
|
2219
|
+
queued: 0,
|
|
2220
|
+
pending: 0,
|
|
2221
|
+
completed: 0,
|
|
2222
|
+
failed: targets.length > 0 ? 0 : 1,
|
|
2223
|
+
timedOut: 0,
|
|
2224
|
+
results: targets.map(deviceId => ({
|
|
2225
|
+
deviceId,
|
|
2226
|
+
ok: false,
|
|
2227
|
+
status: 'targeted',
|
|
2228
|
+
error: '',
|
|
2229
|
+
commandId: '',
|
|
2230
|
+
taskId: '',
|
|
2231
|
+
approvalLevel: normalizeApprovalLevel(options.approvalLevel),
|
|
2232
|
+
updatedAt: now
|
|
2233
|
+
}))
|
|
2234
|
+
};
|
|
2235
|
+
|
|
2236
|
+
taskBatches.set(batchId, batch);
|
|
2237
|
+
trimTaskBatches();
|
|
2238
|
+
return batch;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
function getTaskBatchItem(batch, deviceId) {
|
|
2242
|
+
if (!batch) {
|
|
2243
|
+
return null;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
const id = safeString(deviceId, 128);
|
|
2247
|
+
if (!id) {
|
|
2248
|
+
return null;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
let item = batch.results.find(result => result.deviceId === id);
|
|
2252
|
+
if (!item) {
|
|
2253
|
+
item = {
|
|
2254
|
+
deviceId: id,
|
|
2255
|
+
ok: false,
|
|
2256
|
+
status: 'targeted',
|
|
2257
|
+
error: '',
|
|
2258
|
+
commandId: '',
|
|
2259
|
+
taskId: '',
|
|
2260
|
+
approvalLevel: batch.approvalLevel,
|
|
2261
|
+
updatedAt: new Date().toISOString()
|
|
2262
|
+
};
|
|
2263
|
+
batch.results.push(item);
|
|
2264
|
+
batch.total = batch.results.length;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
return item;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function recalculateTaskBatch(batch) {
|
|
2271
|
+
if (!batch) {
|
|
2272
|
+
return null;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
2276
|
+
batch.total = results.length;
|
|
2277
|
+
batch.queued = results.filter(item => item.ok === true).length;
|
|
2278
|
+
batch.completed = results.filter(item => item.status === 'completed').length;
|
|
2279
|
+
batch.failed = results.filter(item => item.status === 'failed' || (item.ok === false && !!item.error)).length;
|
|
2280
|
+
batch.timedOut = results.filter(item => item.error === 'task-timeout').length;
|
|
2281
|
+
batch.pending = results.filter(item =>
|
|
2282
|
+
item.ok === true
|
|
2283
|
+
&& item.status !== 'completed'
|
|
2284
|
+
&& item.status !== 'failed').length;
|
|
2285
|
+
batch.updatedAt = new Date().toISOString();
|
|
2286
|
+
|
|
2287
|
+
if (batch.total === 0) {
|
|
2288
|
+
batch.status = 'failed';
|
|
2289
|
+
} else if (batch.completed + batch.failed >= batch.total) {
|
|
2290
|
+
batch.status = batch.failed > 0 ? 'completed-with-failures' : 'completed';
|
|
2291
|
+
} else if (batch.queued > 0) {
|
|
2292
|
+
batch.status = 'running';
|
|
2293
|
+
} else {
|
|
2294
|
+
batch.status = 'failed';
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
return batch;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
function syncTaskBatchFromTask(device, task) {
|
|
2301
|
+
const batchId = safeString(task?.batchId, 128);
|
|
2302
|
+
if (!batchId) {
|
|
2303
|
+
return null;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
const batch = taskBatches.get(batchId);
|
|
2307
|
+
if (!batch) {
|
|
2308
|
+
return null;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
const item = getTaskBatchItem(batch, device?.deviceId);
|
|
2312
|
+
if (!item) {
|
|
2313
|
+
return null;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
item.ok = true;
|
|
2317
|
+
item.status = safeString(task.status, 40) || 'queued';
|
|
2318
|
+
item.error = safeString(task.error, 500);
|
|
2319
|
+
item.commandId = safeString(task.commandId, 128);
|
|
2320
|
+
item.taskId = safeString(task.taskId, 128);
|
|
2321
|
+
item.approvalLevel = normalizeApprovalLevel(task.approvalLevel || batch.approvalLevel);
|
|
2322
|
+
item.updatedAt = task.updatedAt || task.completedAt || task.sentAt || new Date().toISOString();
|
|
2323
|
+
return recalculateTaskBatch(batch);
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function syncTaskBatchDispatchFailure(batchId, deviceId, error, extra = {}) {
|
|
2327
|
+
const batch = taskBatches.get(safeString(batchId, 128));
|
|
2328
|
+
if (!batch) {
|
|
2329
|
+
return null;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
const item = getTaskBatchItem(batch, deviceId);
|
|
2333
|
+
if (!item) {
|
|
2334
|
+
return null;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
item.ok = false;
|
|
2338
|
+
item.status = 'failed';
|
|
2339
|
+
item.error = safeString(error, 500) || 'task-dispatch-failed';
|
|
2340
|
+
item.commandId = safeString(extra.commandId, 128);
|
|
2341
|
+
item.taskId = safeString(extra.taskId, 128);
|
|
2342
|
+
item.approvalLevel = normalizeApprovalLevel(extra.approvalLevel || batch.approvalLevel);
|
|
2343
|
+
item.updatedAt = new Date().toISOString();
|
|
2344
|
+
return recalculateTaskBatch(batch);
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function clearTaskTimeout(device, commandId) {
|
|
2348
|
+
const key = safeString(commandId, 128);
|
|
2349
|
+
if (!device?.pendingTaskTimers || !key) {
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
const timer = device.pendingTaskTimers.get(key);
|
|
2354
|
+
if (timer) {
|
|
2355
|
+
clearTimeout(timer);
|
|
2356
|
+
}
|
|
2357
|
+
device.pendingTaskTimers.delete(key);
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
function failPendingTask(device, commandId, error = 'task-timeout') {
|
|
2361
|
+
const key = safeString(commandId, 128);
|
|
2362
|
+
if (!device?.pendingTaskCommands || !key) {
|
|
2363
|
+
return null;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
const task = device.pendingTaskCommands.get(key);
|
|
2367
|
+
if (!task) {
|
|
2368
|
+
clearTaskTimeout(device, key);
|
|
2369
|
+
return null;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
const now = new Date().toISOString();
|
|
2373
|
+
clearTaskTimeout(device, key);
|
|
2374
|
+
task.status = 'failed';
|
|
2375
|
+
task.updatedAt = now;
|
|
2376
|
+
task.completedAt = now;
|
|
2377
|
+
task.error = safeString(error, 500) || 'task-timeout';
|
|
2378
|
+
task.resultSummary = task.error;
|
|
2379
|
+
task.resultKind = task.resultKind || 'agent-task';
|
|
2380
|
+
device.latestTask = task;
|
|
2381
|
+
device.pendingTaskCommands.delete(key);
|
|
2382
|
+
device.counters.taskResultsReceived += 1;
|
|
2383
|
+
device.counters.taskResultsFailed += 1;
|
|
2384
|
+
if (task.error === 'task-timeout') {
|
|
2385
|
+
device.counters.taskResultsTimedOut += 1;
|
|
2386
|
+
}
|
|
2387
|
+
syncTaskBatchFromTask(device, task);
|
|
2388
|
+
emitRemoteEvent('RemoteTaskResult', device, {
|
|
2389
|
+
commandId: key,
|
|
2390
|
+
taskId: task.taskId,
|
|
2391
|
+
status: task.status,
|
|
2392
|
+
error: task.error
|
|
2393
|
+
});
|
|
2394
|
+
return task;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
function failAllPendingTasks(device, error = 'device-disconnected') {
|
|
2398
|
+
if (!device?.pendingTaskCommands) {
|
|
2399
|
+
return 0;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
const commandIds = [...device.pendingTaskCommands.keys()];
|
|
2403
|
+
let failed = 0;
|
|
2404
|
+
for (const commandId of commandIds) {
|
|
2405
|
+
if (failPendingTask(device, commandId, error)) {
|
|
2406
|
+
failed += 1;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
return failed;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
function scheduleTaskTimeout(device, task) {
|
|
2413
|
+
const commandId = safeString(task?.commandId, 128);
|
|
2414
|
+
if (!device?.pendingTaskTimers || !commandId) {
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
clearTaskTimeout(device, commandId);
|
|
2419
|
+
const timer = setTimeout(() => {
|
|
2420
|
+
failPendingTask(device, commandId, 'task-timeout');
|
|
2421
|
+
}, taskTimeoutMs);
|
|
2422
|
+
timer.unref?.();
|
|
2423
|
+
device.pendingTaskTimers.set(commandId, timer);
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function summarizeTaskResult(result) {
|
|
2427
|
+
if (result && typeof result === 'object') {
|
|
2428
|
+
return safeText(
|
|
2429
|
+
result.summary
|
|
2430
|
+
|| result.message
|
|
2431
|
+
|| result.output
|
|
2432
|
+
|| result.result
|
|
2433
|
+
|| JSON.stringify(result),
|
|
2434
|
+
MAX_AGENT_TASK_RESULT_CHARS);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
return safeText(result ?? '', MAX_AGENT_TASK_RESULT_CHARS);
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
function applyTaskResult(device, commandId, result, error) {
|
|
2441
|
+
if (!device || !commandId) {
|
|
2442
|
+
return null;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
const resultTaskId = result && typeof result === 'object'
|
|
2446
|
+
? safeString(result.taskId, 128)
|
|
2447
|
+
: '';
|
|
2448
|
+
const task = resultTaskId
|
|
2449
|
+
? device.recentTasks.find(item => item.taskId === resultTaskId)
|
|
2450
|
+
: device.pendingTaskCommands.get(commandId);
|
|
2451
|
+
if (!task) {
|
|
2452
|
+
return null;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
if (device.pendingTaskCommands.get(commandId) !== task) {
|
|
2456
|
+
return null;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
const now = device.lastSeenAt || new Date().toISOString();
|
|
2460
|
+
const status = error
|
|
2461
|
+
? 'failed'
|
|
2462
|
+
: safeString(result?.status, 40) || 'completed';
|
|
2463
|
+
clearTaskTimeout(device, commandId);
|
|
2464
|
+
task.status = status;
|
|
2465
|
+
task.updatedAt = now;
|
|
2466
|
+
task.completedAt = safeString(result?.completedAt, 80) || now;
|
|
2467
|
+
task.error = error;
|
|
2468
|
+
task.resultSummary = error || summarizeTaskResult(result);
|
|
2469
|
+
task.resultKind = safeString(result?.kind || result?.mode || 'agent-task', 80);
|
|
2470
|
+
task.resultModel = safeString(result?.model || result?.aiModel || '', 120);
|
|
2471
|
+
task.resultResponseId = safeString(result?.responseId || result?.id || '', 160);
|
|
2472
|
+
device.latestTask = task;
|
|
2473
|
+
device.pendingTaskCommands.delete(commandId);
|
|
2474
|
+
device.counters.taskResultsReceived += 1;
|
|
2475
|
+
if (error) {
|
|
2476
|
+
device.counters.taskResultsFailed += 1;
|
|
2477
|
+
}
|
|
2478
|
+
syncTaskBatchFromTask(device, task);
|
|
2479
|
+
|
|
2480
|
+
return task;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function normalizeFrameByteLength(framePayload, frameData = '') {
|
|
2484
|
+
if (Buffer.isBuffer(framePayload)) {
|
|
2485
|
+
return framePayload.length;
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
return Math.floor(String(frameData || '').length * 3 / 4);
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
function buildFrameDataUrl(framePayload, frameData, mimeType) {
|
|
2492
|
+
if (Buffer.isBuffer(framePayload)) {
|
|
2493
|
+
return `data:${mimeType};base64,${framePayload.toString('base64')}`;
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
return String(frameData || '').startsWith('data:')
|
|
2497
|
+
? String(frameData || '')
|
|
2498
|
+
: `data:${mimeType};base64,${frameData}`;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
function buildFramePayloadBuffer(framePayload, frameData) {
|
|
2502
|
+
if (Buffer.isBuffer(framePayload)) {
|
|
2503
|
+
return Buffer.from(framePayload);
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
const raw = String(frameData || '');
|
|
2507
|
+
const commaIndex = raw.indexOf(',');
|
|
2508
|
+
const base64 = raw.startsWith('data:') && commaIndex >= 0
|
|
2509
|
+
? raw.slice(commaIndex + 1)
|
|
2510
|
+
: raw;
|
|
2511
|
+
return Buffer.from(base64, 'base64');
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
function buildFrameContentHash(message, payload) {
|
|
2515
|
+
const provided = safeString(message.contentHash || message.frameHash || message.hash, 96)
|
|
2516
|
+
.toLowerCase()
|
|
2517
|
+
.replace(/[^a-z0-9:_-]/g, '')
|
|
2518
|
+
.slice(0, 64);
|
|
2519
|
+
if (provided) {
|
|
2520
|
+
return provided;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
if (!Buffer.isBuffer(payload) || payload.length === 0) {
|
|
2524
|
+
return '';
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 16);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
function readFrameCaptureMs(message) {
|
|
2531
|
+
const value = Number(message.captureMs);
|
|
2532
|
+
return Number.isFinite(value) && value >= 0
|
|
2533
|
+
? Math.round(value)
|
|
2534
|
+
: 0;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
function computeSameContentStreak(previousFrame, contentHash) {
|
|
2538
|
+
if (!contentHash || !previousFrame?.contentHash || previousFrame.contentHash !== contentHash) {
|
|
2539
|
+
return 0;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
return clampNumber(Number(previousFrame.sameContentStreak || 0) + 1, 0, 1000000, 1);
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
function applyThumbnailFrame(device, message, framePayload, transport = 'json-base64') {
|
|
2546
|
+
const frameData = Buffer.isBuffer(framePayload)
|
|
2547
|
+
? ''
|
|
2548
|
+
: safeString(framePayload, MAX_THUMBNAIL_BASE64_CHARS + 1);
|
|
2549
|
+
const frameSeq = Number(message.frameSeq);
|
|
2550
|
+
const byteLength = normalizeFrameByteLength(framePayload, frameData);
|
|
2551
|
+
if ((!Buffer.isBuffer(framePayload) && !frameData)
|
|
2552
|
+
|| byteLength > MAX_THUMBNAIL_BINARY_BYTES
|
|
2553
|
+
|| !Number.isFinite(frameSeq)) {
|
|
2554
|
+
device.counters.thumbnailFramesDropped += 1;
|
|
2555
|
+
emitRemoteEvent('RemoteFrameDropped', device, {
|
|
2556
|
+
reason: 'invalid-thumbnail-frame',
|
|
2557
|
+
frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
|
|
2558
|
+
transport
|
|
2559
|
+
});
|
|
2560
|
+
return false;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
const mimeType = safeString(message.mimeType || message.format || 'image/jpeg', 80) || 'image/jpeg';
|
|
2564
|
+
const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
|
|
2565
|
+
const payload = buildFramePayloadBuffer(framePayload, frameData);
|
|
2566
|
+
const contentHash = buildFrameContentHash(message, payload);
|
|
2567
|
+
const sameContentStreak = computeSameContentStreak(device.latestThumbnail, contentHash);
|
|
2568
|
+
const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, 'thumbnail');
|
|
2569
|
+
device.latestThumbnail = {
|
|
2570
|
+
streamId: safeString(message.streamId, 128) || 'thumbnail',
|
|
2571
|
+
frameSeq,
|
|
2572
|
+
commandId: safeString(message.commandId, 128),
|
|
2573
|
+
width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
|
|
2574
|
+
height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
|
|
2575
|
+
mimeType,
|
|
2576
|
+
format: mimeType,
|
|
2577
|
+
frameMode: transfer.frameMode,
|
|
2578
|
+
frameProfile: transfer.frameProfile,
|
|
2579
|
+
encoding: transfer.encoding,
|
|
2580
|
+
codec: transfer.codec,
|
|
2581
|
+
compression: transfer.compression || mimeType,
|
|
2582
|
+
transferProtocol: transfer.transferProtocol,
|
|
2583
|
+
transferProtocolVersion: transfer.transferProtocolVersion,
|
|
2584
|
+
handshake: transfer.handshake,
|
|
2585
|
+
capturedAt,
|
|
2586
|
+
receivedAt: device.lastSeenAt,
|
|
2587
|
+
byteLength,
|
|
2588
|
+
transport,
|
|
2589
|
+
contentHash,
|
|
2590
|
+
captureMs: readFrameCaptureMs(message),
|
|
2591
|
+
sameContentStreak,
|
|
2592
|
+
payload,
|
|
2593
|
+
accessToken: createFrameAccessToken()
|
|
2594
|
+
};
|
|
2595
|
+
rememberRecentFramePayload(device, 'thumbnail', device.latestThumbnail);
|
|
2596
|
+
device.counters.thumbnailFramesReceived += 1;
|
|
2597
|
+
emitRemoteEvent('RemoteFrameReceived', device, {
|
|
2598
|
+
streamId: device.latestThumbnail.streamId,
|
|
2599
|
+
frameSeq,
|
|
2600
|
+
width: device.latestThumbnail.width,
|
|
2601
|
+
height: device.latestThumbnail.height,
|
|
2602
|
+
transport,
|
|
2603
|
+
contentHash,
|
|
2604
|
+
sameContentStreak
|
|
2605
|
+
});
|
|
2606
|
+
return true;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
function applyLiveStreamOpen(device, message, transport = 'json') {
|
|
2610
|
+
const streamId = safeString(message.streamId, 128) || 'live';
|
|
2611
|
+
if (!device.activeLiveStream?.active || device.activeLiveStream.streamId !== streamId) {
|
|
2612
|
+
emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
|
|
2613
|
+
reason: 'stale-live-stream-open',
|
|
2614
|
+
streamId,
|
|
2615
|
+
transport
|
|
2616
|
+
});
|
|
2617
|
+
return false;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE);
|
|
2621
|
+
const openedAt = safeString(message.openedAt, 80) || device.lastSeenAt || new Date().toISOString();
|
|
2622
|
+
device.activeLiveStream = {
|
|
2623
|
+
...device.activeLiveStream,
|
|
2624
|
+
open: true,
|
|
2625
|
+
openedAt,
|
|
2626
|
+
openTransport: transport,
|
|
2627
|
+
mode: transfer.mode,
|
|
2628
|
+
frameMode: transfer.frameMode,
|
|
2629
|
+
frameProfile: transfer.frameProfile,
|
|
2630
|
+
encoding: transfer.encoding,
|
|
2631
|
+
codec: transfer.codec,
|
|
2632
|
+
compression: transfer.compression,
|
|
2633
|
+
transferProtocol: transfer.transferProtocol,
|
|
2634
|
+
transferProtocolVersion: transfer.transferProtocolVersion,
|
|
2635
|
+
handshake: transfer.handshake,
|
|
2636
|
+
width: Number.isFinite(Number(message.width)) ? Number(message.width) : Number(device.activeLiveStream.width || 0),
|
|
2637
|
+
height: Number.isFinite(Number(message.height)) ? Number(message.height) : Number(device.activeLiveStream.height || 0),
|
|
2638
|
+
openedFrameSeq: Number.isFinite(Number(message.frameSeq)) ? Number(message.frameSeq) : Number(device.activeLiveStream.openedFrameSeq || 0)
|
|
2639
|
+
};
|
|
2640
|
+
emitRemoteEvent('RemoteLiveStreamOpened', device, {
|
|
2641
|
+
streamId,
|
|
2642
|
+
mode: transfer.mode,
|
|
2643
|
+
frameMode: transfer.frameMode,
|
|
2644
|
+
frameProfile: transfer.frameProfile,
|
|
2645
|
+
encoding: transfer.encoding,
|
|
2646
|
+
compression: transfer.compression,
|
|
2647
|
+
transport
|
|
2648
|
+
});
|
|
2649
|
+
return true;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
function applyLiveFrame(device, message, framePayload, transport = 'json-base64') {
|
|
2653
|
+
const frameData = Buffer.isBuffer(framePayload)
|
|
2654
|
+
? ''
|
|
2655
|
+
: safeString(framePayload, MAX_STREAM_BASE64_CHARS + 1);
|
|
2656
|
+
const frameSeq = Number(message.frameSeq);
|
|
2657
|
+
const streamId = safeString(message.streamId, 128) || 'live';
|
|
2658
|
+
if (!device.activeLiveStream?.active || device.activeLiveStream.streamId !== streamId) {
|
|
2659
|
+
device.counters.liveFramesDropped += 1;
|
|
2660
|
+
emitRemoteEvent('RemoteFrameDropped', device, {
|
|
2661
|
+
reason: 'stale-live-stream-frame',
|
|
2662
|
+
streamId,
|
|
2663
|
+
frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
|
|
2664
|
+
transport
|
|
2665
|
+
});
|
|
2666
|
+
return false;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
const byteLength = normalizeFrameByteLength(framePayload, frameData);
|
|
2670
|
+
if ((!Buffer.isBuffer(framePayload) && !frameData)
|
|
2671
|
+
|| byteLength > MAX_STREAM_BINARY_BYTES
|
|
2672
|
+
|| !Number.isFinite(frameSeq)) {
|
|
2673
|
+
device.counters.liveFramesDropped += 1;
|
|
2674
|
+
emitRemoteEvent('RemoteFrameDropped', device, {
|
|
2675
|
+
reason: 'invalid-live-frame',
|
|
2676
|
+
streamId,
|
|
2677
|
+
frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
|
|
2678
|
+
transport
|
|
2679
|
+
});
|
|
2680
|
+
return false;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
const mimeType = safeString(message.mimeType || message.format || 'image/jpeg', 80) || 'image/jpeg';
|
|
2684
|
+
const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
|
|
2685
|
+
const payload = buildFramePayloadBuffer(framePayload, frameData);
|
|
2686
|
+
const contentHash = buildFrameContentHash(message, payload);
|
|
2687
|
+
const sameContentStreak = computeSameContentStreak(device.latestLiveFrame, contentHash);
|
|
2688
|
+
const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE);
|
|
2689
|
+
if (device.activeLiveStream.open !== true) {
|
|
2690
|
+
device.activeLiveStream.open = true;
|
|
2691
|
+
device.activeLiveStream.openedAt = device.lastSeenAt;
|
|
2692
|
+
device.activeLiveStream.openTransport = `${transport}-implicit`;
|
|
2693
|
+
device.activeLiveStream.mode = transfer.mode;
|
|
2694
|
+
device.activeLiveStream.frameMode = transfer.frameMode;
|
|
2695
|
+
device.activeLiveStream.frameProfile = transfer.frameProfile;
|
|
2696
|
+
device.activeLiveStream.encoding = transfer.encoding;
|
|
2697
|
+
device.activeLiveStream.codec = transfer.codec;
|
|
2698
|
+
device.activeLiveStream.compression = transfer.compression;
|
|
2699
|
+
device.activeLiveStream.transferProtocol = transfer.transferProtocol;
|
|
2700
|
+
device.activeLiveStream.transferProtocolVersion = transfer.transferProtocolVersion;
|
|
2701
|
+
device.activeLiveStream.handshake = transfer.handshake;
|
|
2702
|
+
}
|
|
2703
|
+
device.latestLiveFrame = {
|
|
2704
|
+
streamId,
|
|
2705
|
+
frameSeq,
|
|
2706
|
+
commandId: safeString(message.commandId, 128),
|
|
2707
|
+
width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
|
|
2708
|
+
height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
|
|
2709
|
+
mimeType,
|
|
2710
|
+
format: mimeType,
|
|
2711
|
+
mode: transfer.mode,
|
|
2712
|
+
frameMode: transfer.frameMode,
|
|
2713
|
+
frameProfile: transfer.frameProfile,
|
|
2714
|
+
encoding: transfer.encoding,
|
|
2715
|
+
codec: transfer.codec,
|
|
2716
|
+
compression: transfer.compression || mimeType,
|
|
2717
|
+
transferProtocol: transfer.transferProtocol,
|
|
2718
|
+
transferProtocolVersion: transfer.transferProtocolVersion,
|
|
2719
|
+
handshake: transfer.handshake,
|
|
2720
|
+
fps: Number.isFinite(Number(message.fps)) ? Number(message.fps) : device.activeLiveStream.fps,
|
|
2721
|
+
capturedAt,
|
|
2722
|
+
receivedAt: device.lastSeenAt,
|
|
2723
|
+
byteLength,
|
|
2724
|
+
transport,
|
|
2725
|
+
contentHash,
|
|
2726
|
+
captureMs: readFrameCaptureMs(message),
|
|
2727
|
+
sameContentStreak,
|
|
2728
|
+
payload,
|
|
2729
|
+
accessToken: createFrameAccessToken()
|
|
2730
|
+
};
|
|
2731
|
+
rememberRecentFramePayload(device, 'live', device.latestLiveFrame);
|
|
2732
|
+
emitFrame({
|
|
2733
|
+
kind: 'live',
|
|
2734
|
+
deviceId: device.deviceId,
|
|
2735
|
+
frame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', { includeDataUrl: false }),
|
|
2736
|
+
payload: device.latestLiveFrame.payload,
|
|
2737
|
+
mimeType: device.latestLiveFrame.mimeType,
|
|
2738
|
+
byteLength: device.latestLiveFrame.byteLength
|
|
2739
|
+
});
|
|
2740
|
+
device.activeLiveStream.lastFrameAt = device.lastSeenAt;
|
|
2741
|
+
device.activeLiveStream.lastFrameSeq = frameSeq;
|
|
2742
|
+
device.activeLiveStream.framesReceived = (device.activeLiveStream.framesReceived || 0) + 1;
|
|
2743
|
+
device.activeLiveStream.mode = transfer.mode;
|
|
2744
|
+
device.activeLiveStream.frameMode = transfer.frameMode;
|
|
2745
|
+
device.activeLiveStream.frameProfile = transfer.frameProfile;
|
|
2746
|
+
device.activeLiveStream.encoding = transfer.encoding;
|
|
2747
|
+
device.activeLiveStream.codec = transfer.codec;
|
|
2748
|
+
device.activeLiveStream.compression = transfer.compression;
|
|
2749
|
+
device.activeLiveStream.transferProtocol = transfer.transferProtocol;
|
|
2750
|
+
device.activeLiveStream.transferProtocolVersion = transfer.transferProtocolVersion;
|
|
2751
|
+
device.activeLiveStream.handshake = transfer.handshake;
|
|
2752
|
+
device.counters.liveFramesReceived += 1;
|
|
2753
|
+
emitRemoteEvent('RemoteFrameReceived', device, {
|
|
2754
|
+
streamId,
|
|
2755
|
+
frameSeq,
|
|
2756
|
+
width: device.latestLiveFrame.width,
|
|
2757
|
+
height: device.latestLiveFrame.height,
|
|
2758
|
+
mode: device.latestLiveFrame.mode,
|
|
2759
|
+
transport,
|
|
2760
|
+
contentHash,
|
|
2761
|
+
sameContentStreak
|
|
2762
|
+
});
|
|
2763
|
+
return true;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
function applyAudioFrame(device, message, framePayload, transport = 'json-base64') {
|
|
2767
|
+
const audioData = Buffer.isBuffer(framePayload)
|
|
2768
|
+
? ''
|
|
2769
|
+
: safeString(framePayload, MAX_AUDIO_BASE64_CHARS + 1);
|
|
2770
|
+
const frameSeq = Number(message.frameSeq);
|
|
2771
|
+
const streamId = safeString(message.streamId, 128) || device.activeAudioStream?.streamId || 'audio';
|
|
2772
|
+
const byteLength = normalizeFrameByteLength(framePayload, audioData);
|
|
2773
|
+
if ((!Buffer.isBuffer(framePayload) && !audioData)
|
|
2774
|
+
|| byteLength > MAX_AUDIO_BINARY_BYTES
|
|
2775
|
+
|| !Number.isFinite(frameSeq)) {
|
|
2776
|
+
device.counters.audioFramesDropped += 1;
|
|
2777
|
+
emitRemoteEvent('RemoteAudioFrameDropped', device, {
|
|
2778
|
+
reason: 'invalid-audio-frame',
|
|
2779
|
+
streamId,
|
|
2780
|
+
frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
|
|
2781
|
+
transport
|
|
2782
|
+
});
|
|
2783
|
+
return false;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
const payload = buildFramePayloadBuffer(framePayload, audioData);
|
|
2787
|
+
const mimeType = safeString(message.mimeType || message.format || 'audio/webm;codecs=opus', 120) || 'audio/webm;codecs=opus';
|
|
2788
|
+
const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
|
|
2789
|
+
device.latestAudioFrame = {
|
|
2790
|
+
streamId,
|
|
2791
|
+
frameSeq,
|
|
2792
|
+
commandId: safeString(message.commandId, 128),
|
|
2793
|
+
mimeType,
|
|
2794
|
+
format: mimeType,
|
|
2795
|
+
sampleRate: Number.isFinite(Number(message.sampleRate)) ? Number(message.sampleRate) : 48000,
|
|
2796
|
+
channels: Number.isFinite(Number(message.channels)) ? Math.max(1, Math.min(8, Math.floor(Number(message.channels)))) : 2,
|
|
2797
|
+
capturedAt,
|
|
2798
|
+
receivedAt: device.lastSeenAt,
|
|
2799
|
+
byteLength,
|
|
2800
|
+
transport,
|
|
2801
|
+
payload
|
|
2802
|
+
};
|
|
2803
|
+
if (device.activeAudioStream) {
|
|
2804
|
+
device.activeAudioStream.active = true;
|
|
2805
|
+
device.activeAudioStream.open = true;
|
|
2806
|
+
device.activeAudioStream.lastFrameAt = device.lastSeenAt;
|
|
2807
|
+
device.activeAudioStream.lastFrameSeq = frameSeq;
|
|
2808
|
+
device.activeAudioStream.framesReceived = (device.activeAudioStream.framesReceived || 0) + 1;
|
|
2809
|
+
device.activeAudioStream.mimeType = mimeType;
|
|
2810
|
+
device.activeAudioStream.sampleRate = device.latestAudioFrame.sampleRate;
|
|
2811
|
+
device.activeAudioStream.channels = device.latestAudioFrame.channels;
|
|
2812
|
+
}
|
|
2813
|
+
device.counters.audioFramesReceived += 1;
|
|
2814
|
+
emitAudio({
|
|
2815
|
+
deviceId: device.deviceId,
|
|
2816
|
+
frame: {
|
|
2817
|
+
streamId,
|
|
2818
|
+
frameSeq,
|
|
2819
|
+
mimeType,
|
|
2820
|
+
sampleRate: device.latestAudioFrame.sampleRate,
|
|
2821
|
+
channels: device.latestAudioFrame.channels,
|
|
2822
|
+
capturedAt,
|
|
2823
|
+
receivedAt: device.latestAudioFrame.receivedAt,
|
|
2824
|
+
byteLength
|
|
2825
|
+
},
|
|
2826
|
+
payload,
|
|
2827
|
+
mimeType,
|
|
2828
|
+
byteLength
|
|
2829
|
+
});
|
|
2830
|
+
emitRemoteEvent('RemoteAudioFrameReceived', device, {
|
|
2831
|
+
streamId,
|
|
2832
|
+
frameSeq,
|
|
2833
|
+
mimeType,
|
|
2834
|
+
byteLength,
|
|
2835
|
+
transport
|
|
2836
|
+
});
|
|
2837
|
+
return true;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
function handleAgentBinaryFrame(socket, state, header, framePayload) {
|
|
2841
|
+
if (!state.authenticated || !state.device) {
|
|
2842
|
+
writeJsonLine(socket, { type: 'error', error: 'hello-required' });
|
|
2843
|
+
socket.destroy();
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
const device = state.device;
|
|
2848
|
+
device.counters.messagesReceived += 1;
|
|
2849
|
+
device.lastSeenAt = new Date().toISOString();
|
|
2850
|
+
|
|
2851
|
+
const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
|
|
2852
|
+
if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
|
|
2853
|
+
applyThumbnailFrame(device, header, framePayload, 'binary');
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
|
|
2858
|
+
applyLiveFrame(device, header, framePayload, 'binary');
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
if (frameKind === 'audio' || header.type === 'audio.binary') {
|
|
2863
|
+
applyAudioFrame(device, header, framePayload, 'binary');
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
emitRemoteEvent('RemoteAgentMessageIgnored', device, {
|
|
2868
|
+
messageType: safeString(header.type, 80),
|
|
2869
|
+
frameKind
|
|
2870
|
+
});
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
function handleAgentMessage(socket, state, message) {
|
|
2874
|
+
if (!message || typeof message !== 'object') {
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
if (!state.authenticated) {
|
|
2879
|
+
if (message.type !== 'hello') {
|
|
2880
|
+
writeJsonLine(socket, { type: 'error', error: 'hello-required' });
|
|
2881
|
+
socket.destroy();
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
if (!timingSafeStringEqual(message.pairToken, pairToken)) {
|
|
2886
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-pair-token' });
|
|
2887
|
+
socket.destroy();
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
const device = attachDevice(socket, message);
|
|
2892
|
+
if (!device) {
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
state.authenticated = true;
|
|
2897
|
+
state.device = device;
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
const device = state.device;
|
|
2902
|
+
if (!device) {
|
|
2903
|
+
socket.destroy();
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
device.counters.messagesReceived += 1;
|
|
2908
|
+
device.lastSeenAt = new Date().toISOString();
|
|
2909
|
+
|
|
2910
|
+
switch (message.type) {
|
|
2911
|
+
case 'heartbeat':
|
|
2912
|
+
case 'status':
|
|
2913
|
+
device.status = typeof message.status === 'object' && message.status
|
|
2914
|
+
? { ...message.status }
|
|
2915
|
+
: {};
|
|
2916
|
+
{
|
|
2917
|
+
const nextSlotNumber = normalizeSlotNumber(device.status.slotNumber);
|
|
2918
|
+
if (nextSlotNumber > 0) {
|
|
2919
|
+
device.slotNumber = nextSlotNumber;
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
device.lastStatusAt = device.lastSeenAt;
|
|
2923
|
+
device.counters.statusReceived += 1;
|
|
2924
|
+
emitRemoteEvent('RemoteDeviceStatus', device);
|
|
2925
|
+
break;
|
|
2926
|
+
case 'command.result':
|
|
2927
|
+
device.counters.commandResultsReceived += 1;
|
|
2928
|
+
{
|
|
2929
|
+
const commandId = safeString(message.commandId, 128);
|
|
2930
|
+
const error = safeString(message.error, 500);
|
|
2931
|
+
const task = applyTaskResult(device, commandId, message.result ?? null, error);
|
|
2932
|
+
if (task) {
|
|
2933
|
+
emitRemoteEvent('RemoteTaskResult', device, {
|
|
2934
|
+
commandId,
|
|
2935
|
+
taskId: task.taskId,
|
|
2936
|
+
status: task.status,
|
|
2937
|
+
error: task.error
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
emitRemoteEvent('RemoteCommandResult', device, {
|
|
2942
|
+
commandId: safeString(message.commandId, 128),
|
|
2943
|
+
result: message.result ?? null,
|
|
2944
|
+
error: safeString(message.error, 500)
|
|
2945
|
+
});
|
|
2946
|
+
break;
|
|
2947
|
+
case 'thumbnail.frame': {
|
|
2948
|
+
applyThumbnailFrame(device, message, message.data, 'json-base64');
|
|
2949
|
+
break;
|
|
2950
|
+
}
|
|
2951
|
+
case 'stream.open': {
|
|
2952
|
+
applyLiveStreamOpen(device, message, 'json');
|
|
2953
|
+
break;
|
|
2954
|
+
}
|
|
2955
|
+
case 'stream.frame': {
|
|
2956
|
+
applyLiveFrame(device, message, message.data, 'json-base64');
|
|
2957
|
+
break;
|
|
2958
|
+
}
|
|
2959
|
+
case 'audio.frame': {
|
|
2960
|
+
applyAudioFrame(device, message, message.data, 'json-base64');
|
|
2961
|
+
break;
|
|
2962
|
+
}
|
|
2963
|
+
default:
|
|
2964
|
+
emitRemoteEvent('RemoteAgentMessageIgnored', device, {
|
|
2965
|
+
messageType: safeString(message.type, 80)
|
|
2966
|
+
});
|
|
2967
|
+
break;
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
function handleWebSocketAgentSocket(socket) {
|
|
2972
|
+
allSockets.add(socket);
|
|
2973
|
+
socket.setNoDelay(true);
|
|
2974
|
+
socket.setKeepAlive(true, heartbeatMs);
|
|
2975
|
+
|
|
2976
|
+
const state = {
|
|
2977
|
+
authenticated: false,
|
|
2978
|
+
device: null
|
|
2979
|
+
};
|
|
2980
|
+
|
|
2981
|
+
const helloTimer = setTimeout(() => {
|
|
2982
|
+
if (!state.authenticated) {
|
|
2983
|
+
writeJsonLine(socket, { type: 'error', error: 'hello-timeout' });
|
|
2984
|
+
socket.destroy();
|
|
2985
|
+
}
|
|
2986
|
+
}, 10000);
|
|
2987
|
+
|
|
2988
|
+
socket.onTextMessage = text => {
|
|
2989
|
+
try {
|
|
2990
|
+
handleAgentMessage(socket, state, parseJsonLine(String(text || '')));
|
|
2991
|
+
} catch (err) {
|
|
2992
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
|
|
2993
|
+
logWarn('remote', `invalid websocket agent message: ${err?.message || err}`);
|
|
2994
|
+
}
|
|
2995
|
+
};
|
|
2996
|
+
|
|
2997
|
+
socket.onBinaryMessage = payload => {
|
|
2998
|
+
try {
|
|
2999
|
+
const packet = parseRemoteHubWebSocketBinaryFrame(payload);
|
|
3000
|
+
if (!packet?.header || !Buffer.isBuffer(packet.payload)) {
|
|
3001
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
const frameKind = safeString(packet.header.frameKind || packet.header.kind || packet.header.frameType, 40).toLowerCase();
|
|
3006
|
+
const maxBytes = frameKind === 'thumbnail'
|
|
3007
|
+
? MAX_THUMBNAIL_BINARY_BYTES
|
|
3008
|
+
: MAX_STREAM_BINARY_BYTES;
|
|
3009
|
+
const byteLength = Number(packet.header.byteLength ?? packet.header.payloadBytes ?? packet.payload.length);
|
|
3010
|
+
if (!Number.isFinite(byteLength)
|
|
3011
|
+
|| byteLength < 1
|
|
3012
|
+
|| byteLength > maxBytes
|
|
3013
|
+
|| byteLength !== packet.payload.length) {
|
|
3014
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
|
|
3015
|
+
logWarn('remote', 'invalid websocket binary frame from agent.');
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
handleAgentBinaryFrame(socket, state, packet.header, packet.payload);
|
|
3020
|
+
} catch (err) {
|
|
3021
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
|
|
3022
|
+
logWarn('remote', `invalid websocket binary frame: ${err?.message || err}`);
|
|
3023
|
+
}
|
|
3024
|
+
};
|
|
3025
|
+
|
|
3026
|
+
const detach = reason => {
|
|
3027
|
+
allSockets.delete(socket);
|
|
3028
|
+
clearTimeout(helloTimer);
|
|
3029
|
+
detachSocket(socket, reason);
|
|
3030
|
+
};
|
|
3031
|
+
|
|
3032
|
+
socket.onCloseMessage = reason => detach(reason || 'websocket-closed');
|
|
3033
|
+
socket.socket.on('close', () => detach('websocket-closed'));
|
|
3034
|
+
socket.socket.on('error', err => detach(err?.message || 'websocket-error'));
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
function handleWebSocketUpgradeSocket(socket, firstChunk) {
|
|
3038
|
+
let upgradeBuffer = Buffer.isBuffer(firstChunk) ? firstChunk : Buffer.from(firstChunk);
|
|
3039
|
+
let onUpgradeData = null;
|
|
3040
|
+
const fail = (status, message) => {
|
|
3041
|
+
try {
|
|
3042
|
+
socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
|
|
3043
|
+
} catch {
|
|
3044
|
+
// Ignore write failures while rejecting the upgrade.
|
|
3045
|
+
}
|
|
3046
|
+
socket.destroy();
|
|
3047
|
+
};
|
|
3048
|
+
|
|
3049
|
+
const completeUpgrade = () => {
|
|
3050
|
+
const request = parseRemoteHubWebSocketUpgrade(upgradeBuffer);
|
|
3051
|
+
if (!request) {
|
|
3052
|
+
return false;
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
const upgrade = String(request.headers.upgrade || '').toLowerCase();
|
|
3056
|
+
const connection = String(request.headers.connection || '').toLowerCase();
|
|
3057
|
+
const key = safeString(request.headers['sec-websocket-key'], 256);
|
|
3058
|
+
const pathName = safeString(String(request.path || '').split('?')[0], 128) || '/';
|
|
3059
|
+
const allowedPath = pathName === '/'
|
|
3060
|
+
|| pathName === '/remote-agent'
|
|
3061
|
+
|| pathName === '/api/remote/agent/ws';
|
|
3062
|
+
if (request.method !== 'GET'
|
|
3063
|
+
|| upgrade !== 'websocket'
|
|
3064
|
+
|| !connection.includes('upgrade')
|
|
3065
|
+
|| !key
|
|
3066
|
+
|| !allowedPath) {
|
|
3067
|
+
fail(400, 'Bad Request');
|
|
3068
|
+
return true;
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
const acceptKey = buildRemoteHubWebSocketAcceptKey(key);
|
|
3072
|
+
socket.write([
|
|
3073
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
3074
|
+
'Upgrade: websocket',
|
|
3075
|
+
'Connection: Upgrade',
|
|
3076
|
+
`Sec-WebSocket-Accept: ${acceptKey}`,
|
|
3077
|
+
'\r\n'
|
|
3078
|
+
].join('\r\n'));
|
|
3079
|
+
|
|
3080
|
+
if (onUpgradeData) {
|
|
3081
|
+
socket.removeListener('data', onUpgradeData);
|
|
3082
|
+
}
|
|
3083
|
+
const wsSocket = new RemoteHubWebSocketAgentSocket(socket);
|
|
3084
|
+
const onFrameData = chunk => wsSocket.handleData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
3085
|
+
socket.on('data', onFrameData);
|
|
3086
|
+
socket.once('close', () => {
|
|
3087
|
+
socket.removeListener('data', onFrameData);
|
|
3088
|
+
});
|
|
3089
|
+
handleWebSocketAgentSocket(wsSocket);
|
|
3090
|
+
if (request.head.length > 0) {
|
|
3091
|
+
wsSocket.handleData(request.head);
|
|
3092
|
+
}
|
|
3093
|
+
return true;
|
|
3094
|
+
};
|
|
3095
|
+
|
|
3096
|
+
onUpgradeData = chunk => {
|
|
3097
|
+
upgradeBuffer = Buffer.concat([upgradeBuffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
|
3098
|
+
if (upgradeBuffer.length > 16 * 1024) {
|
|
3099
|
+
fail(431, 'Request Header Fields Too Large');
|
|
3100
|
+
return;
|
|
3101
|
+
}
|
|
3102
|
+
completeUpgrade();
|
|
3103
|
+
};
|
|
3104
|
+
|
|
3105
|
+
if (!completeUpgrade()) {
|
|
3106
|
+
socket.on('data', onUpgradeData);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
function handleTcpAgentSocket(socket, firstChunk = null) {
|
|
3111
|
+
allSockets.add(socket);
|
|
3112
|
+
socket.setNoDelay(true);
|
|
3113
|
+
socket.setKeepAlive(true, heartbeatMs);
|
|
3114
|
+
|
|
3115
|
+
const state = {
|
|
3116
|
+
authenticated: false,
|
|
3117
|
+
device: null,
|
|
3118
|
+
buffer: Buffer.alloc(0),
|
|
3119
|
+
pendingBinaryFrame: null
|
|
3120
|
+
};
|
|
3121
|
+
|
|
3122
|
+
const helloTimer = setTimeout(() => {
|
|
3123
|
+
if (!state.authenticated) {
|
|
3124
|
+
writeJsonLine(socket, { type: 'error', error: 'hello-timeout' });
|
|
3125
|
+
socket.destroy();
|
|
3126
|
+
}
|
|
3127
|
+
}, 10000);
|
|
3128
|
+
|
|
3129
|
+
const processData = chunk => {
|
|
3130
|
+
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
3131
|
+
state.buffer = state.buffer.length > 0
|
|
3132
|
+
? Buffer.concat([state.buffer, incoming])
|
|
3133
|
+
: incoming;
|
|
3134
|
+
|
|
3135
|
+
while (!socket.destroyed) {
|
|
3136
|
+
if (state.pendingBinaryFrame) {
|
|
3137
|
+
const { header, byteLength } = state.pendingBinaryFrame;
|
|
3138
|
+
if (state.buffer.length < byteLength) {
|
|
3139
|
+
return;
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
const framePayload = state.buffer.subarray(0, byteLength);
|
|
3143
|
+
state.buffer = state.buffer.subarray(byteLength);
|
|
3144
|
+
state.pendingBinaryFrame = null;
|
|
3145
|
+
handleAgentBinaryFrame(socket, state, header, framePayload);
|
|
3146
|
+
continue;
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
const newlineIndex = state.buffer.indexOf(0x0a);
|
|
3150
|
+
if (newlineIndex < 0) {
|
|
3151
|
+
if (state.buffer.length > MAX_LINE_CHARS) {
|
|
3152
|
+
writeJsonLine(socket, { type: 'error', error: 'message-too-large' });
|
|
3153
|
+
socket.destroy();
|
|
3154
|
+
}
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
const lineBuffer = state.buffer.subarray(0, newlineIndex);
|
|
3159
|
+
state.buffer = state.buffer.subarray(newlineIndex + 1);
|
|
3160
|
+
|
|
3161
|
+
try {
|
|
3162
|
+
const message = parseJsonLine(lineBuffer.toString('utf8'));
|
|
3163
|
+
if (message?.type === 'frame.binary') {
|
|
3164
|
+
const frameKind = safeString(message.frameKind || message.kind || message.frameType, 40).toLowerCase();
|
|
3165
|
+
const maxBytes = frameKind === 'thumbnail'
|
|
3166
|
+
? MAX_THUMBNAIL_BINARY_BYTES
|
|
3167
|
+
: MAX_STREAM_BINARY_BYTES;
|
|
3168
|
+
const byteLength = Number(message.byteLength ?? message.payloadBytes ?? message.dataLength);
|
|
3169
|
+
if (!Number.isFinite(byteLength) || byteLength < 1 || byteLength > maxBytes) {
|
|
3170
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
|
|
3171
|
+
logWarn('remote', 'invalid binary frame header from agent.');
|
|
3172
|
+
continue;
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
state.pendingBinaryFrame = {
|
|
3176
|
+
header: message,
|
|
3177
|
+
byteLength: Math.floor(byteLength)
|
|
3178
|
+
};
|
|
3179
|
+
continue;
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
handleAgentMessage(socket, state, message);
|
|
3183
|
+
} catch (err) {
|
|
3184
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
|
|
3185
|
+
logWarn('remote', `invalid agent message: ${err?.message || err}`);
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
};
|
|
3189
|
+
|
|
3190
|
+
socket.on('data', processData);
|
|
3191
|
+
if (firstChunk) {
|
|
3192
|
+
processData(firstChunk);
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
socket.on('close', () => {
|
|
3196
|
+
allSockets.delete(socket);
|
|
3197
|
+
clearTimeout(helloTimer);
|
|
3198
|
+
detachSocket(socket);
|
|
3199
|
+
});
|
|
3200
|
+
socket.on('error', err => {
|
|
3201
|
+
allSockets.delete(socket);
|
|
3202
|
+
clearTimeout(helloTimer);
|
|
3203
|
+
detachSocket(socket, err?.message || 'socket-error');
|
|
3204
|
+
});
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
function handleSocket(socket) {
|
|
3208
|
+
socket.once('data', chunk => {
|
|
3209
|
+
const firstChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
3210
|
+
if (isRemoteHubWebSocketUpgradeStart(firstChunk)) {
|
|
3211
|
+
handleWebSocketUpgradeSocket(socket, firstChunk);
|
|
3212
|
+
return;
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
handleTcpAgentSocket(socket, firstChunk);
|
|
3216
|
+
});
|
|
3217
|
+
|
|
3218
|
+
socket.once('error', () => {
|
|
3219
|
+
// The transport-specific handler owns logging after the first byte.
|
|
3220
|
+
});
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
async function start() {
|
|
3224
|
+
if (!enabled || started) {
|
|
3225
|
+
return getStatus({ includeSecrets: false });
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
await new Promise((resolve, reject) => {
|
|
3229
|
+
server = net.createServer(handleSocket);
|
|
3230
|
+
server.once('error', err => {
|
|
3231
|
+
lastError = err?.message || String(err);
|
|
3232
|
+
server = null;
|
|
3233
|
+
reject(err);
|
|
3234
|
+
});
|
|
3235
|
+
server.listen(requestedPort, host, () => {
|
|
3236
|
+
started = true;
|
|
3237
|
+
boundPort = server.address()?.port || requestedPort;
|
|
3238
|
+
lastError = '';
|
|
3239
|
+
logEvent('remote', `RemoteHub listening on tcp://${host}:${boundPort}`, 'success');
|
|
3240
|
+
if (host === '0.0.0.0' || host === '::') {
|
|
3241
|
+
logWarn('remote', 'RemoteHub is externally reachable. Use a strong pairing token and trusted network.');
|
|
3242
|
+
}
|
|
3243
|
+
emitRemoteEvent('RemoteHubStarted', null);
|
|
3244
|
+
resolve();
|
|
3245
|
+
});
|
|
3246
|
+
});
|
|
3247
|
+
|
|
3248
|
+
return getStatus({ includeSecrets: false });
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
async function close() {
|
|
3252
|
+
for (const device of devices.values()) {
|
|
3253
|
+
failAllPendingTasks(device, 'hub-shutdown');
|
|
3254
|
+
if (device.socket && !device.socket.destroyed) {
|
|
3255
|
+
writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
|
|
3256
|
+
device.socket.destroy();
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
for (const socket of allSockets) {
|
|
3261
|
+
if (!socket.destroyed) {
|
|
3262
|
+
socket.destroy();
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
sockets.clear();
|
|
3267
|
+
if (!server) {
|
|
3268
|
+
started = false;
|
|
3269
|
+
return;
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
await new Promise(resolve => {
|
|
3273
|
+
let settled = false;
|
|
3274
|
+
const settle = () => {
|
|
3275
|
+
if (settled) {
|
|
3276
|
+
return;
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
settled = true;
|
|
3280
|
+
clearTimeout(closeTimer);
|
|
3281
|
+
resolve();
|
|
3282
|
+
};
|
|
3283
|
+
|
|
3284
|
+
const closeTimer = setTimeout(settle, 1000);
|
|
3285
|
+
closeTimer.unref?.();
|
|
3286
|
+
|
|
3287
|
+
try {
|
|
3288
|
+
server.close(settle);
|
|
3289
|
+
} catch {
|
|
3290
|
+
settle();
|
|
3291
|
+
}
|
|
3292
|
+
});
|
|
3293
|
+
|
|
3294
|
+
server = null;
|
|
3295
|
+
started = false;
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
function disconnectDevice(deviceId, reason = 'manager-disconnect') {
|
|
3299
|
+
const device = devices.get(String(deviceId || ''));
|
|
3300
|
+
if (device?.synthetic === true) {
|
|
3301
|
+
device.connected = false;
|
|
3302
|
+
device.disconnectedAt = new Date().toISOString();
|
|
3303
|
+
device.lastDisconnectReason = reason;
|
|
3304
|
+
if (device.activeLiveStream) {
|
|
3305
|
+
device.activeLiveStream.active = false;
|
|
3306
|
+
device.activeLiveStream.stoppedAt = device.disconnectedAt;
|
|
3307
|
+
device.activeLiveStream.stopReason = reason;
|
|
3308
|
+
}
|
|
3309
|
+
failAllPendingTasks(device, 'device-disconnected');
|
|
3310
|
+
emitRemoteEvent('RemoteDeviceDisconnected', device, { reason, synthetic: true });
|
|
3311
|
+
return true;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
if (!device?.socket || device.socket.destroyed) {
|
|
3315
|
+
return false;
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
writeJsonLine(device.socket, { type: 'disconnect', reason });
|
|
3319
|
+
device.socket.destroy();
|
|
3320
|
+
return true;
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
function sendCommand(deviceId, command) {
|
|
3324
|
+
const device = devices.get(String(deviceId || ''));
|
|
3325
|
+
if (device?.synthetic === true && device.connected) {
|
|
3326
|
+
const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
|
|
3327
|
+
device.counters.commandsSent += 1;
|
|
3328
|
+
device.lastSeenAt = new Date().toISOString();
|
|
3329
|
+
if (command?.command === 'thumbnail.capture') {
|
|
3330
|
+
applySyntheticThumbnail(device, {
|
|
3331
|
+
commandId,
|
|
3332
|
+
payload: command?.payload || {}
|
|
3333
|
+
});
|
|
3334
|
+
}
|
|
3335
|
+
emitRemoteEvent('RemoteCommandQueued', device, {
|
|
3336
|
+
commandId,
|
|
3337
|
+
command: safeString(command?.command || 'ping', 80),
|
|
3338
|
+
synthetic: true
|
|
3339
|
+
});
|
|
3340
|
+
emitRemoteEvent('RemoteCommandResult', device, {
|
|
3341
|
+
commandId,
|
|
3342
|
+
result: { ok: true, synthetic: true },
|
|
3343
|
+
error: ''
|
|
3344
|
+
});
|
|
3345
|
+
return { ok: true, commandId, synthetic: true };
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
3349
|
+
return { ok: false, error: 'device-not-connected' };
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
|
|
3353
|
+
const payload = {
|
|
3354
|
+
type: 'command',
|
|
3355
|
+
commandId,
|
|
3356
|
+
command: safeString(command?.command || 'ping', 80),
|
|
3357
|
+
payload: command?.payload ?? null,
|
|
3358
|
+
issuedAt: new Date().toISOString()
|
|
3359
|
+
};
|
|
3360
|
+
|
|
3361
|
+
writeJsonLine(device.socket, payload);
|
|
3362
|
+
device.counters.commandsSent += 1;
|
|
3363
|
+
emitRemoteEvent('RemoteCommandQueued', device, {
|
|
3364
|
+
commandId,
|
|
3365
|
+
command: payload.command
|
|
3366
|
+
});
|
|
3367
|
+
return { ok: true, commandId };
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
function sendInputControl(deviceId, input = {}) {
|
|
3371
|
+
const device = devices.get(String(deviceId || ''));
|
|
3372
|
+
if (!device) {
|
|
3373
|
+
return { ok: false, error: 'device-not-found' };
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
if (!device.connected) {
|
|
3377
|
+
return { ok: false, error: 'device-not-connected' };
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
if (!readCapabilityFlag(device.capabilities, 'control')) {
|
|
3381
|
+
return { ok: false, error: 'device-input-control-unavailable' };
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
const normalized = normalizeRemoteInputEvent(input);
|
|
3385
|
+
if (!normalized.type) {
|
|
3386
|
+
return { ok: false, error: 'missing-input-type' };
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
return sendCommand(deviceId, {
|
|
3390
|
+
command: 'input.control',
|
|
3391
|
+
payload: {
|
|
3392
|
+
...normalized,
|
|
3393
|
+
issuedAt: normalized.issuedAt || new Date().toISOString()
|
|
3394
|
+
}
|
|
3395
|
+
});
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
function requestAgentTask(deviceId, options = {}) {
|
|
3399
|
+
const device = devices.get(String(deviceId || ''));
|
|
3400
|
+
if (!device) {
|
|
3401
|
+
return { ok: false, error: 'device-not-found' };
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
if (device?.synthetic === true && device.connected) {
|
|
3405
|
+
const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
|
|
3406
|
+
if (!instruction) {
|
|
3407
|
+
return { ok: false, error: 'missing-instruction' };
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
const now = new Date().toISOString();
|
|
3411
|
+
const approvalLevel = normalizeApprovalLevel(options.approvalLevel);
|
|
3412
|
+
if (approvalLevel === 'ai-assist' && !readCapabilityFlag(device.capabilities, 'aiAssist')) {
|
|
3413
|
+
return { ok: false, error: 'device-ai-assist-unavailable' };
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
3417
|
+
const taskId = safeString(options.taskId, 128) || crypto.randomUUID();
|
|
3418
|
+
const title = safeString(options.title, 120)
|
|
3419
|
+
|| safeString(instruction.split(/\r?\n/)[0], 120)
|
|
3420
|
+
|| 'Remote task';
|
|
3421
|
+
const task = {
|
|
3422
|
+
batchId: safeString(options.batchId, 128),
|
|
3423
|
+
taskId,
|
|
3424
|
+
commandId,
|
|
3425
|
+
title,
|
|
3426
|
+
instructionPreview: safeText(instruction, 320),
|
|
3427
|
+
status: 'completed',
|
|
3428
|
+
approvalLevel,
|
|
3429
|
+
requestedAt: now,
|
|
3430
|
+
sentAt: now,
|
|
3431
|
+
updatedAt: now,
|
|
3432
|
+
completedAt: now,
|
|
3433
|
+
error: '',
|
|
3434
|
+
resultKind: approvalLevel === 'ai-assist' ? 'synthetic-ai-assist' : 'synthetic-agent-task',
|
|
3435
|
+
resultModel: approvalLevel === 'ai-assist'
|
|
3436
|
+
? safeString(options.model, 120) || 'synthetic-ai'
|
|
3437
|
+
: '',
|
|
3438
|
+
resultResponseId: approvalLevel === 'ai-assist'
|
|
3439
|
+
? `synthetic-response-${taskId}`
|
|
3440
|
+
: '',
|
|
3441
|
+
resultSummary: approvalLevel === 'ai-assist'
|
|
3442
|
+
? `Synthetic AI assist completed for ${device.deviceName}.`
|
|
3443
|
+
: `Synthetic task accepted by ${device.deviceName}.`
|
|
3444
|
+
};
|
|
3445
|
+
|
|
3446
|
+
device.counters.commandsSent += 1;
|
|
3447
|
+
device.counters.commandResultsReceived += 1;
|
|
3448
|
+
device.counters.tasksQueued += 1;
|
|
3449
|
+
device.counters.taskResultsReceived += 1;
|
|
3450
|
+
device.lastSeenAt = now;
|
|
3451
|
+
rememberDeviceTask(device, task);
|
|
3452
|
+
device.pendingTaskCommands.delete(commandId);
|
|
3453
|
+
syncTaskBatchFromTask(device, task);
|
|
3454
|
+
emitRemoteEvent('RemoteTaskQueued', device, {
|
|
3455
|
+
commandId,
|
|
3456
|
+
taskId,
|
|
3457
|
+
title,
|
|
3458
|
+
approvalLevel,
|
|
3459
|
+
synthetic: true
|
|
3460
|
+
});
|
|
3461
|
+
emitRemoteEvent('RemoteTaskResult', device, {
|
|
3462
|
+
commandId,
|
|
3463
|
+
taskId,
|
|
3464
|
+
status: task.status,
|
|
3465
|
+
error: '',
|
|
3466
|
+
synthetic: true
|
|
3467
|
+
});
|
|
3468
|
+
return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
3472
|
+
return { ok: false, error: 'device-not-connected' };
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
|
|
3476
|
+
if (!instruction) {
|
|
3477
|
+
return { ok: false, error: 'missing-instruction' };
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
const now = new Date().toISOString();
|
|
3481
|
+
const approvalLevel = normalizeApprovalLevel(options.approvalLevel);
|
|
3482
|
+
if (approvalLevel === 'ai-assist' && !readCapabilityFlag(device.capabilities, 'aiAssist')) {
|
|
3483
|
+
return { ok: false, error: 'device-ai-assist-unavailable' };
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
3487
|
+
const taskId = safeString(options.taskId, 128) || crypto.randomUUID();
|
|
3488
|
+
const title = safeString(options.title, 120)
|
|
3489
|
+
|| safeString(instruction.split(/\r?\n/)[0], 120)
|
|
3490
|
+
|| 'Remote task';
|
|
3491
|
+
const task = {
|
|
3492
|
+
batchId: safeString(options.batchId, 128),
|
|
3493
|
+
taskId,
|
|
3494
|
+
commandId,
|
|
3495
|
+
title,
|
|
3496
|
+
instructionPreview: safeText(instruction, 320),
|
|
3497
|
+
status: 'queued',
|
|
3498
|
+
approvalLevel,
|
|
3499
|
+
requestedAt: now,
|
|
3500
|
+
sentAt: now,
|
|
3501
|
+
updatedAt: now,
|
|
3502
|
+
completedAt: '',
|
|
3503
|
+
error: '',
|
|
3504
|
+
resultKind: '',
|
|
3505
|
+
resultModel: '',
|
|
3506
|
+
resultResponseId: '',
|
|
3507
|
+
resultSummary: ''
|
|
3508
|
+
};
|
|
3509
|
+
|
|
3510
|
+
const sent = writeJsonLine(device.socket, {
|
|
3511
|
+
type: 'command',
|
|
3512
|
+
commandId,
|
|
3513
|
+
command: 'agent.task',
|
|
3514
|
+
payload: {
|
|
3515
|
+
taskId,
|
|
3516
|
+
title,
|
|
3517
|
+
instruction,
|
|
3518
|
+
approvalLevel,
|
|
3519
|
+
model: safeString(options.model, 120),
|
|
3520
|
+
requestedAt: now
|
|
3521
|
+
},
|
|
3522
|
+
issuedAt: now
|
|
3523
|
+
});
|
|
3524
|
+
|
|
3525
|
+
if (!sent) {
|
|
3526
|
+
return { ok: false, error: 'device-not-connected' };
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
device.counters.commandsSent += 1;
|
|
3530
|
+
device.counters.tasksQueued += 1;
|
|
3531
|
+
rememberDeviceTask(device, task);
|
|
3532
|
+
syncTaskBatchFromTask(device, task);
|
|
3533
|
+
scheduleTaskTimeout(device, task);
|
|
3534
|
+
emitRemoteEvent('RemoteTaskQueued', device, {
|
|
3535
|
+
commandId,
|
|
3536
|
+
taskId,
|
|
3537
|
+
title,
|
|
3538
|
+
approvalLevel
|
|
3539
|
+
});
|
|
3540
|
+
return { ok: true, commandId, taskId, approvalLevel };
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
function requestAgentTaskBatch(deviceIds, options = {}) {
|
|
3544
|
+
const targets = [...new Set((deviceIds || [])
|
|
3545
|
+
.map(deviceId => safeString(deviceId, 128))
|
|
3546
|
+
.filter(Boolean))]
|
|
3547
|
+
.slice(0, 500);
|
|
3548
|
+
|
|
3549
|
+
if (targets.length === 0) {
|
|
3550
|
+
return {
|
|
3551
|
+
ok: false,
|
|
3552
|
+
error: 'no-target-devices',
|
|
3553
|
+
total: 0,
|
|
3554
|
+
queued: 0,
|
|
3555
|
+
approvalLevel: normalizeApprovalLevel(options.approvalLevel),
|
|
3556
|
+
results: []
|
|
3557
|
+
};
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
const batch = beginTaskBatch(targets, options);
|
|
3561
|
+
const results = targets.map(deviceId => {
|
|
3562
|
+
const result = requestAgentTask(deviceId, {
|
|
3563
|
+
...options,
|
|
3564
|
+
batchId: batch.batchId
|
|
3565
|
+
});
|
|
3566
|
+
|
|
3567
|
+
if (result.ok !== true) {
|
|
3568
|
+
syncTaskBatchDispatchFailure(batch.batchId, deviceId, result.error, result);
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
return {
|
|
3572
|
+
deviceId,
|
|
3573
|
+
...result
|
|
3574
|
+
};
|
|
3575
|
+
});
|
|
3576
|
+
|
|
3577
|
+
const queued = results.filter(result => result.ok === true).length;
|
|
3578
|
+
const updatedBatch = taskBatches.get(batch.batchId) || batch;
|
|
3579
|
+
recalculateTaskBatch(updatedBatch);
|
|
3580
|
+
return {
|
|
3581
|
+
ok: queued > 0,
|
|
3582
|
+
batchId: batch.batchId,
|
|
3583
|
+
total: targets.length,
|
|
3584
|
+
queued,
|
|
3585
|
+
approvalLevel: batch.approvalLevel,
|
|
3586
|
+
results,
|
|
3587
|
+
batch: serializeTaskBatch(updatedBatch),
|
|
3588
|
+
error: queued > 0 ? undefined : 'no-task-queued'
|
|
3589
|
+
};
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
function requestThumbnail(deviceId, options = {}) {
|
|
3593
|
+
return sendCommand(deviceId, {
|
|
3594
|
+
command: 'thumbnail.capture',
|
|
3595
|
+
commandId: safeString(options.commandId, 128) || crypto.randomUUID(),
|
|
3596
|
+
payload: {
|
|
3597
|
+
streamId: safeString(options.streamId, 128) || `thumb-${Date.now()}`,
|
|
3598
|
+
maxWidth: clampNumber(options.maxWidth, 160, 1920, 360),
|
|
3599
|
+
maxHeight: clampNumber(options.maxHeight, 90, 1080, 220),
|
|
3600
|
+
quality: clampNumber(options.quality, 20, 95, 55),
|
|
3601
|
+
requestedAt: new Date().toISOString()
|
|
3602
|
+
}
|
|
3603
|
+
});
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
function startLiveStream(deviceId, options = {}) {
|
|
3607
|
+
const device = devices.get(String(deviceId || ''));
|
|
3608
|
+
if (device?.synthetic === true && device.connected) {
|
|
3609
|
+
if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
|
|
3610
|
+
return { ok: false, error: 'device-live-stream-unavailable' };
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
const now = new Date().toISOString();
|
|
3614
|
+
const streamId = safeString(options.streamId, 128) || `live-${Date.now()}`;
|
|
3615
|
+
const fps = clampNumber(options.fps, 1, 24, 20);
|
|
3616
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
3617
|
+
const transfer = buildRemoteFrameTransferDescriptor(options.mode || DEFAULT_REMOTE_FRAME_MODE);
|
|
3618
|
+
device.counters.commandsSent += 1;
|
|
3619
|
+
device.activeLiveStream = {
|
|
3620
|
+
streamId,
|
|
3621
|
+
commandId,
|
|
3622
|
+
active: true,
|
|
3623
|
+
open: true,
|
|
3624
|
+
openedAt: now,
|
|
3625
|
+
openTransport: 'synthetic',
|
|
3626
|
+
mode: transfer.mode,
|
|
3627
|
+
frameMode: transfer.frameMode,
|
|
3628
|
+
frameProfile: transfer.frameProfile,
|
|
3629
|
+
encoding: transfer.encoding,
|
|
3630
|
+
codec: transfer.codec,
|
|
3631
|
+
compression: transfer.compression,
|
|
3632
|
+
transferProtocol: transfer.transferProtocol,
|
|
3633
|
+
transferProtocolVersion: transfer.transferProtocolVersion,
|
|
3634
|
+
handshake: transfer.handshake,
|
|
3635
|
+
fps,
|
|
3636
|
+
startedAt: now,
|
|
3637
|
+
stoppedAt: '',
|
|
3638
|
+
stopReason: '',
|
|
3639
|
+
lastFrameAt: '',
|
|
3640
|
+
lastFrameSeq: 0,
|
|
3641
|
+
framesReceived: 0
|
|
3642
|
+
};
|
|
3643
|
+
device.counters.liveStreamsStarted += 1;
|
|
3644
|
+
applySyntheticLiveFrame(device, streamId, {
|
|
3645
|
+
commandId,
|
|
3646
|
+
maxWidth: options.maxWidth,
|
|
3647
|
+
maxHeight: options.maxHeight,
|
|
3648
|
+
fps
|
|
3649
|
+
});
|
|
3650
|
+
emitRemoteEvent('RemoteLiveStreamStarted', device, {
|
|
3651
|
+
streamId,
|
|
3652
|
+
commandId,
|
|
3653
|
+
fps,
|
|
3654
|
+
mode: transfer.mode,
|
|
3655
|
+
synthetic: true
|
|
3656
|
+
});
|
|
3657
|
+
return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode, synthetic: true };
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
3661
|
+
return { ok: false, error: 'device-not-connected' };
|
|
3662
|
+
}
|
|
3663
|
+
if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
|
|
3664
|
+
return { ok: false, error: 'device-live-stream-unavailable' };
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
const now = new Date().toISOString();
|
|
3668
|
+
const streamId = safeString(options.streamId, 128) || `live-${Date.now()}`;
|
|
3669
|
+
const fps = clampNumber(options.fps, 1, 24, 12);
|
|
3670
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
3671
|
+
const transfer = buildRemoteFrameTransferDescriptor(options.mode || DEFAULT_REMOTE_FRAME_MODE);
|
|
3672
|
+
const result = sendCommand(deviceId, {
|
|
3673
|
+
command: 'stream.start',
|
|
3674
|
+
commandId,
|
|
3675
|
+
payload: {
|
|
3676
|
+
streamId,
|
|
3677
|
+
mode: transfer.mode,
|
|
3678
|
+
frameMode: transfer.frameMode,
|
|
3679
|
+
frameProfile: transfer.frameProfile,
|
|
3680
|
+
encoding: transfer.encoding,
|
|
3681
|
+
codec: transfer.codec,
|
|
3682
|
+
compression: transfer.compression,
|
|
3683
|
+
transferProtocol: transfer.transferProtocol,
|
|
3684
|
+
transferProtocolVersion: transfer.transferProtocolVersion,
|
|
3685
|
+
handshake: transfer.handshake,
|
|
3686
|
+
fps,
|
|
3687
|
+
maxWidth: clampNumber(options.maxWidth, 320, 2560, 960),
|
|
3688
|
+
maxHeight: clampNumber(options.maxHeight, 180, 1440, 540),
|
|
3689
|
+
quality: clampNumber(options.quality, 20, 95, 60),
|
|
3690
|
+
requestedAt: now
|
|
3691
|
+
}
|
|
3692
|
+
});
|
|
3693
|
+
|
|
3694
|
+
if (!result.ok) {
|
|
3695
|
+
return result;
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3698
|
+
device.activeLiveStream = {
|
|
3699
|
+
streamId,
|
|
3700
|
+
commandId,
|
|
3701
|
+
active: true,
|
|
3702
|
+
open: false,
|
|
3703
|
+
openedAt: '',
|
|
3704
|
+
openTransport: '',
|
|
3705
|
+
mode: transfer.mode,
|
|
3706
|
+
frameMode: transfer.frameMode,
|
|
3707
|
+
frameProfile: transfer.frameProfile,
|
|
3708
|
+
encoding: transfer.encoding,
|
|
3709
|
+
codec: transfer.codec,
|
|
3710
|
+
compression: transfer.compression,
|
|
3711
|
+
transferProtocol: transfer.transferProtocol,
|
|
3712
|
+
transferProtocolVersion: transfer.transferProtocolVersion,
|
|
3713
|
+
handshake: transfer.handshake,
|
|
3714
|
+
fps,
|
|
3715
|
+
startedAt: now,
|
|
3716
|
+
stoppedAt: '',
|
|
3717
|
+
stopReason: '',
|
|
3718
|
+
lastFrameAt: '',
|
|
3719
|
+
lastFrameSeq: 0,
|
|
3720
|
+
framesReceived: 0
|
|
3721
|
+
};
|
|
3722
|
+
device.counters.liveStreamsStarted += 1;
|
|
3723
|
+
emitRemoteEvent('RemoteLiveStreamStarted', device, {
|
|
3724
|
+
streamId,
|
|
3725
|
+
commandId,
|
|
3726
|
+
fps,
|
|
3727
|
+
mode: transfer.mode,
|
|
3728
|
+
frameMode: transfer.frameMode
|
|
3729
|
+
});
|
|
3730
|
+
|
|
3731
|
+
return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode };
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
function stopLiveStream(deviceId, options = {}) {
|
|
3735
|
+
const device = devices.get(String(deviceId || ''));
|
|
3736
|
+
if (device?.synthetic === true && device.connected) {
|
|
3737
|
+
const streamId = safeString(options.streamId, 128) || device.activeLiveStream?.streamId || '';
|
|
3738
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
3739
|
+
device.counters.commandsSent += 1;
|
|
3740
|
+
if (device.activeLiveStream && (!streamId || device.activeLiveStream.streamId === streamId)) {
|
|
3741
|
+
device.activeLiveStream.active = false;
|
|
3742
|
+
device.activeLiveStream.stoppedAt = new Date().toISOString();
|
|
3743
|
+
device.activeLiveStream.stopReason = 'manager-request';
|
|
3744
|
+
}
|
|
3745
|
+
device.counters.liveStreamsStopped += 1;
|
|
3746
|
+
emitRemoteEvent('RemoteLiveStreamStopped', device, {
|
|
3747
|
+
streamId,
|
|
3748
|
+
commandId,
|
|
3749
|
+
synthetic: true
|
|
3750
|
+
});
|
|
3751
|
+
return { ok: true, commandId, streamId, synthetic: true };
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
3755
|
+
return { ok: false, error: 'device-not-connected' };
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
const streamId = safeString(options.streamId, 128) || device.activeLiveStream?.streamId || '';
|
|
3759
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
3760
|
+
const result = sendCommand(deviceId, {
|
|
3761
|
+
command: 'stream.stop',
|
|
3762
|
+
commandId,
|
|
3763
|
+
payload: {
|
|
3764
|
+
streamId,
|
|
3765
|
+
requestedAt: new Date().toISOString()
|
|
3766
|
+
}
|
|
3767
|
+
});
|
|
3768
|
+
|
|
3769
|
+
if (!result.ok) {
|
|
3770
|
+
return result;
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
if (device.activeLiveStream && (!streamId || device.activeLiveStream.streamId === streamId)) {
|
|
3774
|
+
device.activeLiveStream.active = false;
|
|
3775
|
+
device.activeLiveStream.stoppedAt = new Date().toISOString();
|
|
3776
|
+
device.activeLiveStream.stopReason = 'manager-request';
|
|
3777
|
+
}
|
|
3778
|
+
device.counters.liveStreamsStopped += 1;
|
|
3779
|
+
emitRemoteEvent('RemoteLiveStreamStopped', device, {
|
|
3780
|
+
streamId,
|
|
3781
|
+
commandId
|
|
3782
|
+
});
|
|
3783
|
+
|
|
3784
|
+
return { ok: true, commandId, streamId };
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
function startAudioStream(deviceId, options = {}) {
|
|
3788
|
+
const device = devices.get(String(deviceId || ''));
|
|
3789
|
+
if (!device) {
|
|
3790
|
+
return { ok: false, error: 'device-not-found' };
|
|
3791
|
+
}
|
|
3792
|
+
if (!device.connected) {
|
|
3793
|
+
return { ok: false, error: 'device-not-connected' };
|
|
3794
|
+
}
|
|
3795
|
+
if (!readCapabilityFlag(device.capabilities, 'audio') && !readCapabilityFlag(device.capabilities, 'remoteAudio')) {
|
|
3796
|
+
return { ok: false, error: 'device-audio-unavailable' };
|
|
3797
|
+
}
|
|
3798
|
+
if (device.synthetic === true || !device.socket || device.socket.destroyed) {
|
|
3799
|
+
return { ok: false, error: 'device-audio-unavailable' };
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
const now = new Date().toISOString();
|
|
3803
|
+
const streamId = safeString(options.streamId, 128) || `audio-${Date.now()}`;
|
|
3804
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
3805
|
+
const result = sendCommand(deviceId, {
|
|
3806
|
+
command: 'audio.start',
|
|
3807
|
+
commandId,
|
|
3808
|
+
payload: {
|
|
3809
|
+
streamId,
|
|
3810
|
+
mimeType: safeString(options.mimeType, 120) || 'audio/webm;codecs=opus',
|
|
3811
|
+
sampleRate: clampNumber(options.sampleRate, 8000, 192000, 48000),
|
|
3812
|
+
channels: clampNumber(options.channels, 1, 8, 2),
|
|
3813
|
+
requestedAt: now
|
|
3814
|
+
}
|
|
3815
|
+
});
|
|
3816
|
+
if (!result.ok) {
|
|
3817
|
+
return result;
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
device.activeAudioStream = {
|
|
3821
|
+
streamId,
|
|
3822
|
+
commandId,
|
|
3823
|
+
active: true,
|
|
3824
|
+
open: false,
|
|
3825
|
+
startedAt: now,
|
|
3826
|
+
stoppedAt: '',
|
|
3827
|
+
stopReason: '',
|
|
3828
|
+
lastFrameAt: '',
|
|
3829
|
+
lastFrameSeq: 0,
|
|
3830
|
+
framesReceived: 0,
|
|
3831
|
+
mimeType: safeString(options.mimeType, 120) || 'audio/webm;codecs=opus',
|
|
3832
|
+
sampleRate: clampNumber(options.sampleRate, 8000, 192000, 48000),
|
|
3833
|
+
channels: clampNumber(options.channels, 1, 8, 2)
|
|
3834
|
+
};
|
|
3835
|
+
device.counters.audioStreamsStarted += 1;
|
|
3836
|
+
emitRemoteEvent('RemoteAudioStreamStarted', device, {
|
|
3837
|
+
streamId,
|
|
3838
|
+
commandId
|
|
3839
|
+
});
|
|
3840
|
+
return { ok: true, commandId, streamId };
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
function stopAudioStream(deviceId, options = {}) {
|
|
3844
|
+
const device = devices.get(String(deviceId || ''));
|
|
3845
|
+
if (!device) {
|
|
3846
|
+
return { ok: false, error: 'device-not-found' };
|
|
3847
|
+
}
|
|
3848
|
+
if (!device.connected) {
|
|
3849
|
+
return { ok: false, error: 'device-not-connected' };
|
|
3850
|
+
}
|
|
3851
|
+
if (device.synthetic === true || !device.socket || device.socket.destroyed) {
|
|
3852
|
+
return { ok: false, error: 'device-audio-unavailable' };
|
|
3853
|
+
}
|
|
3854
|
+
const streamId = safeString(options.streamId, 128) || device.activeAudioStream?.streamId || '';
|
|
3855
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
3856
|
+
const result = sendCommand(deviceId, {
|
|
3857
|
+
command: 'audio.stop',
|
|
3858
|
+
commandId,
|
|
3859
|
+
payload: {
|
|
3860
|
+
streamId,
|
|
3861
|
+
requestedAt: new Date().toISOString()
|
|
3862
|
+
}
|
|
3863
|
+
});
|
|
3864
|
+
if (!result.ok) {
|
|
3865
|
+
return result;
|
|
3866
|
+
}
|
|
3867
|
+
if (device.activeAudioStream && (!streamId || device.activeAudioStream.streamId === streamId)) {
|
|
3868
|
+
device.activeAudioStream.active = false;
|
|
3869
|
+
device.activeAudioStream.stoppedAt = new Date().toISOString();
|
|
3870
|
+
device.activeAudioStream.stopReason = 'manager-request';
|
|
3871
|
+
}
|
|
3872
|
+
device.counters.audioStreamsStopped += 1;
|
|
3873
|
+
emitRemoteEvent('RemoteAudioStreamStopped', device, {
|
|
3874
|
+
streamId,
|
|
3875
|
+
commandId
|
|
3876
|
+
});
|
|
3877
|
+
return { ok: true, commandId, streamId };
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3880
|
+
function getDeviceLiveFrame(deviceId, options = {}) {
|
|
3881
|
+
const device = devices.get(String(deviceId || ''));
|
|
3882
|
+
return serializeRemoteFrame(device?.latestLiveFrame, device?.deviceId, 'live', {
|
|
3883
|
+
includeDataUrl: options.includeDataUrl === true
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
function getDeviceThumbnail(deviceId, options = {}) {
|
|
3888
|
+
const device = devices.get(String(deviceId || ''));
|
|
3889
|
+
return serializeRemoteFrame(device?.latestThumbnail, device?.deviceId, 'thumbnail', {
|
|
3890
|
+
includeDataUrl: options.includeDataUrl === true
|
|
3891
|
+
});
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
function getFramePayload(deviceId, frameKind, options = {}) {
|
|
3895
|
+
const device = devices.get(String(deviceId || ''));
|
|
3896
|
+
if (!device) {
|
|
3897
|
+
return null;
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
|
|
3901
|
+
const frame = findRecentFramePayload(device, kind, options);
|
|
3902
|
+
if (!frame) {
|
|
3903
|
+
return null;
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3906
|
+
return {
|
|
3907
|
+
frame: serializeRemoteFrame(frame, device.deviceId, kind, { includeDataUrl: false }),
|
|
3908
|
+
payload: frame.payload,
|
|
3909
|
+
mimeType: safeString(frame.mimeType || frame.format || 'application/octet-stream', 120) || 'application/octet-stream',
|
|
3910
|
+
byteLength: frame.payload.length
|
|
3911
|
+
};
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3914
|
+
return {
|
|
3915
|
+
start,
|
|
3916
|
+
close,
|
|
3917
|
+
getStatus,
|
|
3918
|
+
listDevices,
|
|
3919
|
+
listTaskBatches,
|
|
3920
|
+
getLatestTaskBatch,
|
|
3921
|
+
listDeviceFrames,
|
|
3922
|
+
disconnectDevice,
|
|
3923
|
+
sendCommand,
|
|
3924
|
+
sendInputControl,
|
|
3925
|
+
requestAgentTask,
|
|
3926
|
+
requestAgentTaskBatch,
|
|
3927
|
+
setHostTarget,
|
|
3928
|
+
requestThumbnail,
|
|
3929
|
+
startLiveStream,
|
|
3930
|
+
stopLiveStream,
|
|
3931
|
+
startAudioStream,
|
|
3932
|
+
stopAudioStream,
|
|
3933
|
+
getDeviceLiveFrame,
|
|
3934
|
+
getDeviceThumbnail,
|
|
3935
|
+
getFramePayload,
|
|
3936
|
+
seedSyntheticFleet,
|
|
3937
|
+
clearSyntheticFleet,
|
|
3938
|
+
getPairToken: () => pairToken
|
|
3939
|
+
};
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
export function getDefaultRemoteAgentDeviceInfo() {
|
|
3943
|
+
return {
|
|
3944
|
+
hostname: os.hostname(),
|
|
3945
|
+
platform: os.platform(),
|
|
3946
|
+
arch: os.arch(),
|
|
3947
|
+
pid: process.pid
|
|
3948
|
+
};
|
|
3949
|
+
}
|