mindexec-ai 0.2.385
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/README.md +354 -0
- package/codex-runtime.js +1339 -0
- package/launch-bridge.cjs +236 -0
- package/package.json +77 -0
- package/port-guard.cjs +232 -0
- package/remote-fast/osx-arm64/mindexec-remote-fast +0 -0
- package/remote-fast/osx-arm64/mindexec-remote-fast.deps.json +24 -0
- package/remote-fast/osx-arm64/mindexec-remote-fast.dll +0 -0
- package/remote-fast/osx-arm64/mindexec-remote-fast.runtimeconfig.json +13 -0
- package/remote-fast/osx-x64/mindexec-remote-fast +0 -0
- package/remote-fast/osx-x64/mindexec-remote-fast.deps.json +24 -0
- package/remote-fast/osx-x64/mindexec-remote-fast.dll +0 -0
- package/remote-fast/osx-x64/mindexec-remote-fast.runtimeconfig.json +13 -0
- package/remote-fast/win-x64/mindexec-remote-fast.deps.json +24 -0
- package/remote-fast/win-x64/mindexec-remote-fast.dll +0 -0
- package/remote-fast/win-x64/mindexec-remote-fast.exe +0 -0
- package/remote-fast/win-x64/mindexec-remote-fast.runtimeconfig.json +20 -0
- package/remote-hub.js +3106 -0
- package/scripts/auth-session-smoke.mjs +262 -0
- package/scripts/remote-agent-managed-smoke.mjs +291 -0
- package/scripts/remote-agent-package-smoke.mjs +64 -0
- package/scripts/remote-agent-ws-smoke.mjs +202 -0
- package/scripts/remote-fast-live-rate-smoke.mjs +355 -0
- package/scripts/remote-fast-mdm-browser-smoke.mjs +476 -0
- package/scripts/remote-fleet-render-smoke.mjs +1491 -0
- package/scripts/remote-frame-ws-smoke.mjs +234 -0
- package/scripts/remote-http-smoke.mjs +592 -0
- package/scripts/remote-hub-identity-smoke.mjs +146 -0
- package/scripts/remote-hub-scale-smoke.mjs +124 -0
- package/scripts/remote-hub-smoke.mjs +631 -0
- package/scripts/remote-input-ws-smoke.mjs +263 -0
- package/scripts/remote-registry-follower-smoke.mjs +752 -0
- package/scripts/setup-tree-sitter-grammars.mjs +80 -0
- package/server.js +15709 -0
- package/start-bridge.bat +32 -0
- package/start-bridge.sh +81 -0
- package/tree-sitter-grammars/README.md +18 -0
- package/tree-sitter-grammars/tree-sitter-c_sharp.wasm +0 -0
- package/tree-sitter-grammars/tree-sitter-go.wasm +0 -0
- package/tree-sitter-grammars/tree-sitter-java.wasm +0 -0
- package/tree-sitter-grammars/tree-sitter-javascript.wasm +0 -0
- package/tree-sitter-grammars/tree-sitter-python.wasm +0 -0
- package/tree-sitter-grammars/tree-sitter-rust.wasm +0 -0
- package/tree-sitter-grammars/tree-sitter-tsx.wasm +0 -0
- package/tree-sitter-grammars/tree-sitter-typescript.wasm +0 -0
- package/wwwroot/_headers +73 -0
- package/wwwroot/_redirects +1 -0
- package/wwwroot/appsettings.json +83 -0
- package/wwwroot/assets/AdminDashboardPage-B2vz2Px9.css +1 -0
- package/wwwroot/assets/AdminDashboardPage-DnuCHywn.js +1 -0
- package/wwwroot/assets/AppSidebar-DU2OgSiv.js +2 -0
- package/wwwroot/assets/AuthPages-BrH6kRcv.css +1 -0
- package/wwwroot/assets/AuthPages-Dgezl7Vj.js +1 -0
- package/wwwroot/assets/CodePage-7kgZlB3O.js +87 -0
- package/wwwroot/assets/CodePage-Bncc352E.css +1 -0
- package/wwwroot/assets/CompanyCorePage-ChBnq1ve.css +1 -0
- package/wwwroot/assets/CompanyCorePage-CzIZIIU_.js +13 -0
- package/wwwroot/assets/ExecutionModePage-B-etp_mc.js +18 -0
- package/wwwroot/assets/ExecutionModePage-TLuld9l3.css +1 -0
- package/wwwroot/assets/LaunchLeadCapture-Bx9LM0IX.js +1 -0
- package/wwwroot/assets/LaunchLeadCapture-CiRI1shz.css +1 -0
- package/wwwroot/assets/MarketingHome-BsyerRpe.js +1 -0
- package/wwwroot/assets/MarketingHome-DPzaYzA_.css +1 -0
- package/wwwroot/assets/MindCanvas-DtqOZnoW.css +1 -0
- package/wwwroot/assets/MindCanvas-zEDXzaxW.js +49 -0
- package/wwwroot/assets/PlanMasterPage-CJ36rep-.css +1 -0
- package/wwwroot/assets/PlanMasterPage-NZ_mPvaE.js +4 -0
- package/wwwroot/assets/PricingPage-Cg_0i_ZR.css +1 -0
- package/wwwroot/assets/PricingPage-Ylrn8l2g.js +1 -0
- package/wwwroot/assets/ToolPages-3M2KqA9k.js +28 -0
- package/wwwroot/assets/ToolPages-DIB187pZ.css +1 -0
- package/wwwroot/assets/YouTubeSearchPage-COv1oAA7.js +4 -0
- package/wwwroot/assets/YouTubeSearchPage-IPPa_BIH.css +1 -0
- package/wwwroot/assets/app-runtime-xD2Z3NdN.js +1 -0
- package/wwwroot/assets/canvas-runtime-BbicBcOj.js +44 -0
- package/wwwroot/assets/code-agent-runtime-B5PPZd1t.js +74 -0
- package/wwwroot/assets/executionModeSettings-NJqurj-o.js +1 -0
- package/wwwroot/assets/index-CQMKCp-t.js +2 -0
- package/wwwroot/assets/index-yNpEK-gp.css +1 -0
- package/wwwroot/assets/marketingTools-DN_rnHeB.js +4 -0
- package/wwwroot/assets/mindCanvasSearchWorker-BzPMsHOB.js +1 -0
- package/wwwroot/assets/mindexecution-mindcanvas.png +0 -0
- package/wwwroot/assets/mindexecution-prod-home-current.png +0 -0
- package/wwwroot/assets/mindexecution-prod-pricing-current.png +0 -0
- package/wwwroot/assets/pricingCheckoutShell-O-DnwmbU.js +1 -0
- package/wwwroot/assets/productionAdapterConfig-C5jfk6oG.js +1 -0
- package/wwwroot/assets/runtimeSettingsPersistenceProjection-BoNWmYjU.js +1 -0
- package/wwwroot/assets/storage-TM3YrWaj.js +1 -0
- package/wwwroot/assets/supabaseAuthAdapter-DA43DeSY.js +44 -0
- package/wwwroot/assets/toolHandoff-D5e5f7t5.js +4 -0
- package/wwwroot/assets/vendor-icons-DE3gIReG.js +681 -0
- package/wwwroot/assets/vendor-msgpack-BE8aAsr3.js +1 -0
- package/wwwroot/assets/vendor-react-BXzpOyCS.js +40 -0
- package/wwwroot/favicon.svg +7 -0
- package/wwwroot/index.html +22 -0
- package/wwwroot/manifest.webmanifest +19 -0
- package/wwwroot/robots.txt +4 -0
- package/wwwroot/service-worker.js +7 -0
- package/wwwroot/sitemap.xml +39 -0
package/remote-hub.js
ADDED
|
@@ -0,0 +1,3106 @@
|
|
|
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_AGENT_TASK_CHARS = 4000;
|
|
15
|
+
const MAX_AGENT_TASK_RESULT_CHARS = 3000;
|
|
16
|
+
const RECENT_TASK_LIMIT = 12;
|
|
17
|
+
const RECENT_TASK_BATCH_LIMIT = 16;
|
|
18
|
+
const RECENT_FRAME_CACHE_TTL_MS = 12000;
|
|
19
|
+
const RECENT_THUMBNAIL_FRAME_CACHE_LIMIT = 4;
|
|
20
|
+
const RECENT_LIVE_FRAME_CACHE_LIMIT = 80;
|
|
21
|
+
const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 3 * 1024 * 1024;
|
|
22
|
+
const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
|
23
|
+
const REMOTE_PROTOCOL_VERSION = 1;
|
|
24
|
+
const MAX_SYNTHETIC_DEVICES = 1000;
|
|
25
|
+
const DEFAULT_HOST_TARGET_LEASE_MS = 30000;
|
|
26
|
+
const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
|
|
27
|
+
const SYNTHETIC_FRAME_PAYLOAD = Buffer.from(SYNTHETIC_FRAME_DATA_URL.split(',')[1], 'base64');
|
|
28
|
+
const SYNTHETIC_FRAME_HASH = crypto.createHash('sha256').update(SYNTHETIC_FRAME_PAYLOAD).digest('hex').slice(0, 16);
|
|
29
|
+
|
|
30
|
+
function isEnabledValue(value, fallback = true) {
|
|
31
|
+
if (value === undefined || value === null || value === '') {
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return /^(1|true|yes|on)$/i.test(String(value).trim());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isDisabledValue(value) {
|
|
39
|
+
return /^(1|true|yes|on)$/i.test(String(value || '').trim());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clampNumber(value, min, max, fallback) {
|
|
43
|
+
const number = Number(value);
|
|
44
|
+
if (!Number.isFinite(number)) {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return Math.max(min, Math.min(max, Math.floor(number)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizePort(value) {
|
|
52
|
+
return clampNumber(value, 0, 65535, DEFAULT_REMOTE_HUB_PORT);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function safeString(value, maxLength = 200) {
|
|
56
|
+
return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isLoopbackHost(value) {
|
|
60
|
+
const host = String(value || '').trim().toLowerCase();
|
|
61
|
+
return host === 'localhost'
|
|
62
|
+
|| host === '127.0.0.1'
|
|
63
|
+
|| host === '::1'
|
|
64
|
+
|| host === '[::1]';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isWildcardHost(value) {
|
|
68
|
+
const host = String(value || '').trim().toLowerCase();
|
|
69
|
+
return host === '0.0.0.0'
|
|
70
|
+
|| host === '::'
|
|
71
|
+
|| host === '[::]'
|
|
72
|
+
|| host === '';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isPrivateIPv4(value) {
|
|
76
|
+
const parts = String(value || '').split('.').map(part => Number(part));
|
|
77
|
+
if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return parts[0] === 10
|
|
82
|
+
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
|
83
|
+
|| (parts[0] === 192 && parts[1] === 168);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isPreferredPhysicalInterfaceName(value) {
|
|
87
|
+
const name = String(value || '').toLowerCase();
|
|
88
|
+
return /(^|[\s_\-()])((wi[\s_\-]?fi)|wifi|wireless|wlan|ethernet|lan|이더넷|en\d+|eth\d+)(?=$|[\s_\-()])/i.test(name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isVirtualInterfaceName(value) {
|
|
92
|
+
const name = String(value || '').toLowerCase();
|
|
93
|
+
return /virtual|vethernet|hyper-v|hyperv|vmware|virtualbox|vbox|docker|wsl|container|bluetooth|loopback|npcap|isatap|teredo/i.test(name);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getIPv4LastOctet(value) {
|
|
97
|
+
const parts = String(value || '').split('.').map(part => Number(part));
|
|
98
|
+
if (parts.length !== 4 || parts.some(part => !Number.isInteger(part))) {
|
|
99
|
+
return -1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return parts[3];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scoreReachableIPv4Candidate(candidate) {
|
|
106
|
+
const address = String(candidate?.address || '');
|
|
107
|
+
const name = String(candidate?.name || '');
|
|
108
|
+
let score = 0;
|
|
109
|
+
|
|
110
|
+
if (isPrivateIPv4(address)) {
|
|
111
|
+
score += 1000;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (/^192\.168\./.test(address)) {
|
|
115
|
+
score += 90;
|
|
116
|
+
} else if (/^10\./.test(address)) {
|
|
117
|
+
score += 80;
|
|
118
|
+
} else if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(address)) {
|
|
119
|
+
score += 70;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isPreferredPhysicalInterfaceName(name)) {
|
|
123
|
+
score += 500;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (isVirtualInterfaceName(name)) {
|
|
127
|
+
score -= 1200;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (/^169\.254\./.test(address)) {
|
|
131
|
+
score -= 2000;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lastOctet = getIPv4LastOctet(address);
|
|
135
|
+
if (lastOctet === 1) {
|
|
136
|
+
score -= 60;
|
|
137
|
+
} else if (lastOctet === 0 || lastOctet === 255) {
|
|
138
|
+
score -= 100;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (candidate?.mac && candidate.mac === '00:00:00:00:00:00') {
|
|
142
|
+
score -= 50;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return score;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getReachableIPv4CandidateEntries() {
|
|
149
|
+
const candidates = [];
|
|
150
|
+
const interfaces = os.networkInterfaces();
|
|
151
|
+
for (const [name, entries] of Object.entries(interfaces)) {
|
|
152
|
+
for (const entry of entries || []) {
|
|
153
|
+
if (entry?.family !== 'IPv4' || entry.internal) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const address = safeString(entry.address, 64);
|
|
158
|
+
if (!address || address === '0.0.0.0') {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
candidates.push({
|
|
163
|
+
address,
|
|
164
|
+
name: safeString(name, 128),
|
|
165
|
+
mac: safeString(entry.mac, 32),
|
|
166
|
+
score: 0,
|
|
167
|
+
virtual: isVirtualInterfaceName(name),
|
|
168
|
+
preferred: isPreferredPhysicalInterfaceName(name)
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return candidates
|
|
174
|
+
.map(candidate => ({
|
|
175
|
+
...candidate,
|
|
176
|
+
score: scoreReachableIPv4Candidate(candidate)
|
|
177
|
+
}))
|
|
178
|
+
.sort((left, right) => right.score - left.score || left.address.localeCompare(right.address))
|
|
179
|
+
.filter((candidate, index, all) => all.findIndex(item => item.address === candidate.address) === index);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function getReachableIPv4Candidates() {
|
|
183
|
+
return getReachableIPv4CandidateEntries().map(candidate => candidate.address);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getReachableIPv4Address() {
|
|
187
|
+
const candidates = getReachableIPv4Candidates();
|
|
188
|
+
return candidates.find(isPrivateIPv4) || candidates[0] || '127.0.0.1';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildEndpoint(hostValue, portValue) {
|
|
192
|
+
const hostText = safeString(hostValue, 128);
|
|
193
|
+
const port = normalizePort(portValue);
|
|
194
|
+
if (!hostText || !port) {
|
|
195
|
+
return '';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const hostPart = hostText.includes(':') && !hostText.startsWith('[')
|
|
199
|
+
? `[${hostText}]`
|
|
200
|
+
: hostText;
|
|
201
|
+
return `${hostPart}:${port}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseManagerEndpoint(value) {
|
|
205
|
+
const endpoint = safeString(value, 256);
|
|
206
|
+
const match = endpoint.match(/^(\[[^\]]+\]|[^:\s]+):(\d{1,5})$/);
|
|
207
|
+
if (!match) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const host = match[1].replace(/^\[|\]$/g, '');
|
|
212
|
+
const port = Number(match[2]);
|
|
213
|
+
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { endpoint, host, port };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getAccountRouteEndpointReason(endpoint, context = {}) {
|
|
221
|
+
const parsed = parseManagerEndpoint(endpoint);
|
|
222
|
+
if (!parsed) {
|
|
223
|
+
return 'invalid-manager-endpoint';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (isLoopbackHost(parsed.host)) {
|
|
227
|
+
return context.wildcardWithoutReachableAddress ? 'no-reachable-ipv4' : 'loopback-endpoint';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (isWildcardHost(parsed.host)) {
|
|
231
|
+
return 'wildcard-endpoint';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (/^169\.254\./.test(parsed.host)) {
|
|
235
|
+
return 'link-local-endpoint';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return 'ok';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function safeText(value, maxLength = 1000) {
|
|
242
|
+
return String(value ?? '').replace(/\0/g, '').trim().slice(0, maxLength);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeApprovalLevel(value) {
|
|
246
|
+
const level = safeString(value, 80).toLowerCase();
|
|
247
|
+
return level === 'ai-assist' ? 'ai-assist' : 'task-only';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function normalizeRemoteInputEvent(value = {}) {
|
|
251
|
+
const input = value && typeof value === 'object' ? value : {};
|
|
252
|
+
const type = safeString(input.type || input.Type, 48);
|
|
253
|
+
const normalizedX = Number(input.normalizedX ?? input.NormalizedX);
|
|
254
|
+
const normalizedY = Number(input.normalizedY ?? input.NormalizedY);
|
|
255
|
+
const deltaX = Number(input.deltaX ?? input.DeltaX);
|
|
256
|
+
const deltaY = Number(input.deltaY ?? input.DeltaY);
|
|
257
|
+
return {
|
|
258
|
+
type,
|
|
259
|
+
normalizedX: Number.isFinite(normalizedX) ? Math.max(0, Math.min(1, normalizedX)) : undefined,
|
|
260
|
+
normalizedY: Number.isFinite(normalizedY) ? Math.max(0, Math.min(1, normalizedY)) : undefined,
|
|
261
|
+
button: safeString(input.button || input.Button, 24),
|
|
262
|
+
deltaX: Number.isFinite(deltaX) ? Math.max(-4096, Math.min(4096, deltaX)) : 0,
|
|
263
|
+
deltaY: Number.isFinite(deltaY) ? Math.max(-4096, Math.min(4096, deltaY)) : 0,
|
|
264
|
+
key: safeString(input.key || input.Key, 80),
|
|
265
|
+
code: safeString(input.code || input.Code, 80),
|
|
266
|
+
text: safeText(input.text || input.Text, 256),
|
|
267
|
+
repeat: input.repeat === true || input.Repeat === true,
|
|
268
|
+
controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
|
|
269
|
+
issuedAt: safeString(input.issuedAt || input.IssuedAt, 80)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readCapabilityFlag(capabilities, key) {
|
|
274
|
+
const value = capabilities?.[key];
|
|
275
|
+
if (typeof value === 'boolean') {
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
if (typeof value === 'number') {
|
|
279
|
+
return value !== 0;
|
|
280
|
+
}
|
|
281
|
+
return /^(1|true|yes|on)$/i.test(String(value || '').trim());
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function createDeviceCounters(overrides = {}) {
|
|
285
|
+
return {
|
|
286
|
+
messagesReceived: 0,
|
|
287
|
+
statusReceived: 0,
|
|
288
|
+
commandsSent: 0,
|
|
289
|
+
commandResultsReceived: 0,
|
|
290
|
+
thumbnailFramesReceived: 0,
|
|
291
|
+
thumbnailFramesDropped: 0,
|
|
292
|
+
liveFramesReceived: 0,
|
|
293
|
+
liveFramesDropped: 0,
|
|
294
|
+
liveStreamsStarted: 0,
|
|
295
|
+
liveStreamsStopped: 0,
|
|
296
|
+
tasksQueued: 0,
|
|
297
|
+
taskResultsReceived: 0,
|
|
298
|
+
taskResultsFailed: 0,
|
|
299
|
+
taskResultsTimedOut: 0,
|
|
300
|
+
...overrides
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function normalizeDeviceId(value) {
|
|
305
|
+
const id = safeString(value, 128).replace(/[^a-zA-Z0-9_.:-]/g, '-');
|
|
306
|
+
return id || crypto.randomUUID();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function maskToken(token) {
|
|
310
|
+
const value = String(token || '');
|
|
311
|
+
if (value.length <= 8) {
|
|
312
|
+
return value ? '****' : '';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function timingSafeStringEqual(left, right) {
|
|
319
|
+
const a = Buffer.from(String(left || ''), 'utf8');
|
|
320
|
+
const b = Buffer.from(String(right || ''), 'utf8');
|
|
321
|
+
if (a.length !== b.length) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return crypto.timingSafeEqual(a, b);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function parseJsonLine(line) {
|
|
329
|
+
const text = String(line || '').trim();
|
|
330
|
+
if (!text) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return JSON.parse(text);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function createFrameAccessToken() {
|
|
338
|
+
return crypto.randomBytes(18).toString('base64url');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function buildRemoteFramePath(deviceId, frameKind, frame) {
|
|
342
|
+
const token = safeString(frame?.accessToken, 128);
|
|
343
|
+
const frameSeq = Number(frame?.frameSeq);
|
|
344
|
+
if (!deviceId || !token || !Number.isFinite(frameSeq)) {
|
|
345
|
+
return '';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const endpoint = frameKind === 'thumbnail' ? 'thumbnail' : 'live/frame';
|
|
349
|
+
const params = new URLSearchParams({
|
|
350
|
+
format: 'binary',
|
|
351
|
+
seq: String(Math.floor(frameSeq)),
|
|
352
|
+
token
|
|
353
|
+
});
|
|
354
|
+
return `/api/remote/devices/${encodeURIComponent(deviceId)}/${endpoint}?${params.toString()}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
|
|
358
|
+
if (!frame) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const {
|
|
363
|
+
payload,
|
|
364
|
+
accessToken,
|
|
365
|
+
dataUrl,
|
|
366
|
+
...publicFrame
|
|
367
|
+
} = frame;
|
|
368
|
+
|
|
369
|
+
const framePath = buildRemoteFramePath(deviceId, frameKind, frame);
|
|
370
|
+
const serialized = {
|
|
371
|
+
...publicFrame,
|
|
372
|
+
framePath,
|
|
373
|
+
frameUrl: framePath
|
|
374
|
+
};
|
|
375
|
+
if (options.includeDataUrl === true) {
|
|
376
|
+
serialized.dataUrl = dataUrl || buildFrameDataUrl(payload, '', publicFrame.mimeType || publicFrame.format || 'image/jpeg');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return serialized;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function getRecentFrameCache(device, frameKind) {
|
|
383
|
+
if (!device) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!device.recentFramePayloads) {
|
|
388
|
+
device.recentFramePayloads = {
|
|
389
|
+
thumbnail: [],
|
|
390
|
+
live: []
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
|
|
395
|
+
if (!Array.isArray(device.recentFramePayloads[kind])) {
|
|
396
|
+
device.recentFramePayloads[kind] = [];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return device.recentFramePayloads[kind];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function getRecentFrameCacheLimit(frameKind) {
|
|
403
|
+
return frameKind === 'thumbnail'
|
|
404
|
+
? RECENT_THUMBNAIL_FRAME_CACHE_LIMIT
|
|
405
|
+
: RECENT_LIVE_FRAME_CACHE_LIMIT;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function getRecentFrameCacheMaxBytes(frameKind) {
|
|
409
|
+
return frameKind === 'thumbnail'
|
|
410
|
+
? RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES
|
|
411
|
+
: RECENT_LIVE_FRAME_CACHE_MAX_BYTES;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function pruneRecentFrameCache(cache, frameKind, nowMs = Date.now()) {
|
|
415
|
+
if (!Array.isArray(cache)) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const limit = getRecentFrameCacheLimit(frameKind);
|
|
420
|
+
const maxBytes = getRecentFrameCacheMaxBytes(frameKind);
|
|
421
|
+
let byteTotal = 0;
|
|
422
|
+
for (let index = cache.length - 1; index >= 0; index -= 1) {
|
|
423
|
+
const entry = cache[index];
|
|
424
|
+
const frame = entry?.frame;
|
|
425
|
+
if (!entry
|
|
426
|
+
|| entry.expiresAt <= nowMs
|
|
427
|
+
|| !frame
|
|
428
|
+
|| !Buffer.isBuffer(frame.payload)
|
|
429
|
+
|| !Number.isFinite(Number(frame.frameSeq))
|
|
430
|
+
|| !safeString(frame.accessToken, 128)) {
|
|
431
|
+
cache.splice(index, 1);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
for (let index = 0; index < cache.length; index += 1) {
|
|
436
|
+
const entry = cache[index];
|
|
437
|
+
const byteLength = Number(entry?.byteLength || entry?.frame?.payload?.length || 0) || 0;
|
|
438
|
+
byteTotal += byteLength;
|
|
439
|
+
if (index >= limit || byteTotal > maxBytes) {
|
|
440
|
+
cache.splice(index);
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function rememberRecentFramePayload(device, frameKind, frame) {
|
|
447
|
+
if (!device || !frame || !Buffer.isBuffer(frame.payload)) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const frameSeq = Number(frame.frameSeq);
|
|
452
|
+
const accessToken = safeString(frame.accessToken, 128);
|
|
453
|
+
if (!Number.isFinite(frameSeq) || !accessToken) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
|
|
458
|
+
const cache = getRecentFrameCache(device, kind);
|
|
459
|
+
if (!cache) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const nowMs = Date.now();
|
|
464
|
+
const normalizedSeq = Math.floor(frameSeq);
|
|
465
|
+
for (let index = cache.length - 1; index >= 0; index -= 1) {
|
|
466
|
+
const existing = cache[index]?.frame;
|
|
467
|
+
if (Number(existing?.frameSeq) === normalizedSeq
|
|
468
|
+
|| safeString(existing?.accessToken, 128) === accessToken) {
|
|
469
|
+
cache.splice(index, 1);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
cache.unshift({
|
|
474
|
+
frame,
|
|
475
|
+
byteLength: frame.payload.length,
|
|
476
|
+
cachedAt: nowMs,
|
|
477
|
+
expiresAt: nowMs + RECENT_FRAME_CACHE_TTL_MS
|
|
478
|
+
});
|
|
479
|
+
pruneRecentFrameCache(cache, kind, nowMs);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function isFramePayloadRequestMatch(frame, options = {}) {
|
|
483
|
+
if (!frame || !Buffer.isBuffer(frame.payload)) {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const requestedSeq = Number(options.frameSeq);
|
|
488
|
+
if (Number.isFinite(requestedSeq) && Math.floor(requestedSeq) !== Number(frame.frameSeq)) {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const token = safeString(options.token, 128);
|
|
493
|
+
if (token && !timingSafeStringEqual(token, frame.accessToken)) {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (options.requireToken === true && !token) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function findRecentFramePayload(device, frameKind, options = {}) {
|
|
505
|
+
const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
|
|
506
|
+
const latest = kind === 'thumbnail'
|
|
507
|
+
? device?.latestThumbnail
|
|
508
|
+
: device?.latestLiveFrame;
|
|
509
|
+
if (isFramePayloadRequestMatch(latest, options)) {
|
|
510
|
+
return latest;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const token = safeString(options.token, 128);
|
|
514
|
+
const requestedSeq = Number(options.frameSeq);
|
|
515
|
+
if (!token && !Number.isFinite(requestedSeq)) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const cache = getRecentFrameCache(device, kind);
|
|
520
|
+
if (!cache) {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
pruneRecentFrameCache(cache, kind);
|
|
525
|
+
const entry = cache.find(item => isFramePayloadRequestMatch(item?.frame, options));
|
|
526
|
+
return entry?.frame || null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function serializeDevice(device, options = {}) {
|
|
530
|
+
if (!device) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
deviceId: device.deviceId,
|
|
536
|
+
sessionId: device.sessionId,
|
|
537
|
+
deviceName: device.deviceName,
|
|
538
|
+
hostname: device.hostname,
|
|
539
|
+
platform: device.platform,
|
|
540
|
+
arch: device.arch,
|
|
541
|
+
pid: device.pid,
|
|
542
|
+
agentVersion: device.agentVersion,
|
|
543
|
+
capabilities: { ...device.capabilities },
|
|
544
|
+
connected: device.connected,
|
|
545
|
+
connectedAt: device.connectedAt,
|
|
546
|
+
disconnectedAt: device.disconnectedAt,
|
|
547
|
+
lastSeenAt: device.lastSeenAt,
|
|
548
|
+
lastStatusAt: device.lastStatusAt,
|
|
549
|
+
lastDisconnectReason: device.lastDisconnectReason,
|
|
550
|
+
remoteAddress: device.remoteAddress,
|
|
551
|
+
remotePort: device.remotePort,
|
|
552
|
+
latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
|
|
553
|
+
latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
|
|
554
|
+
activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
|
|
555
|
+
latestTask: device.latestTask ? { ...device.latestTask } : null,
|
|
556
|
+
synthetic: device.synthetic === true,
|
|
557
|
+
recentTasks: Array.isArray(device.recentTasks)
|
|
558
|
+
? device.recentTasks.map(task => ({ ...task }))
|
|
559
|
+
: [],
|
|
560
|
+
status: { ...device.status },
|
|
561
|
+
counters: { ...device.counters }
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function serializeTaskBatch(batch) {
|
|
566
|
+
if (!batch) {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
batchId: batch.batchId,
|
|
572
|
+
title: batch.title,
|
|
573
|
+
instructionPreview: batch.instructionPreview,
|
|
574
|
+
approvalLevel: batch.approvalLevel,
|
|
575
|
+
status: batch.status,
|
|
576
|
+
requestedAt: batch.requestedAt,
|
|
577
|
+
updatedAt: batch.updatedAt,
|
|
578
|
+
total: batch.total,
|
|
579
|
+
queued: batch.queued,
|
|
580
|
+
pending: batch.pending,
|
|
581
|
+
completed: batch.completed,
|
|
582
|
+
failed: batch.failed,
|
|
583
|
+
timedOut: batch.timedOut,
|
|
584
|
+
results: Array.isArray(batch.results)
|
|
585
|
+
? batch.results.map(item => ({ ...item }))
|
|
586
|
+
: []
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function writeJsonLine(socket, payload) {
|
|
591
|
+
if (!socket || socket.destroyed) {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (socket.__remoteHubWebSocket === true && typeof socket.sendJson === 'function') {
|
|
596
|
+
return socket.sendJson(payload);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function isRemoteHubWebSocketUpgradeStart(buffer) {
|
|
604
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return buffer.subarray(0, Math.min(buffer.length, 16)).toString('latin1').startsWith('GET ');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function parseRemoteHubWebSocketUpgrade(buffer) {
|
|
612
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
613
|
+
if (headerEnd < 0) {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const headerText = buffer.subarray(0, headerEnd).toString('latin1');
|
|
618
|
+
const lines = headerText.split('\r\n');
|
|
619
|
+
const requestLine = lines.shift() || '';
|
|
620
|
+
const [method, path] = requestLine.split(/\s+/);
|
|
621
|
+
const headers = {};
|
|
622
|
+
for (const line of lines) {
|
|
623
|
+
const separator = line.indexOf(':');
|
|
624
|
+
if (separator <= 0) {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
headers[line.slice(0, separator).trim().toLowerCase()] = line.slice(separator + 1).trim();
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
method,
|
|
633
|
+
path,
|
|
634
|
+
headers,
|
|
635
|
+
head: buffer.subarray(headerEnd + 4)
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function buildRemoteHubWebSocketAcceptKey(key) {
|
|
640
|
+
return crypto
|
|
641
|
+
.createHash('sha1')
|
|
642
|
+
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
643
|
+
.digest('base64');
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function buildRemoteHubWebSocketFrame(opcode, payload) {
|
|
647
|
+
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload || ''), 'utf8');
|
|
648
|
+
let header = null;
|
|
649
|
+
if (body.length < 126) {
|
|
650
|
+
header = Buffer.allocUnsafe(2);
|
|
651
|
+
header[0] = 0x80 | opcode;
|
|
652
|
+
header[1] = body.length;
|
|
653
|
+
} else if (body.length <= 0xffff) {
|
|
654
|
+
header = Buffer.allocUnsafe(4);
|
|
655
|
+
header[0] = 0x80 | opcode;
|
|
656
|
+
header[1] = 126;
|
|
657
|
+
header.writeUInt16BE(body.length, 2);
|
|
658
|
+
} else {
|
|
659
|
+
header = Buffer.allocUnsafe(10);
|
|
660
|
+
header[0] = 0x80 | opcode;
|
|
661
|
+
header[1] = 127;
|
|
662
|
+
header.writeBigUInt64BE(BigInt(body.length), 2);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return Buffer.concat([header, body], header.length + body.length);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function parseRemoteHubWebSocketBinaryFrame(buffer) {
|
|
669
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 5) {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const metaLength = buffer.readUInt32BE(0);
|
|
674
|
+
if (!Number.isFinite(metaLength)
|
|
675
|
+
|| metaLength <= 0
|
|
676
|
+
|| metaLength > 64 * 1024
|
|
677
|
+
|| 4 + metaLength >= buffer.length) {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const header = JSON.parse(buffer.subarray(4, 4 + metaLength).toString('utf8'));
|
|
682
|
+
const payload = buffer.subarray(4 + metaLength);
|
|
683
|
+
return { header, payload };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
class RemoteHubWebSocketAgentSocket {
|
|
687
|
+
constructor(socket) {
|
|
688
|
+
this.__remoteHubWebSocket = true;
|
|
689
|
+
this.socket = socket;
|
|
690
|
+
this.remoteAddress = socket.remoteAddress;
|
|
691
|
+
this.remotePort = socket.remotePort;
|
|
692
|
+
this.destroyed = false;
|
|
693
|
+
this.buffer = Buffer.alloc(0);
|
|
694
|
+
this.fragmentOpcode = 0;
|
|
695
|
+
this.fragments = [];
|
|
696
|
+
this.onTextMessage = () => {};
|
|
697
|
+
this.onBinaryMessage = () => {};
|
|
698
|
+
this.onCloseMessage = () => {};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
setNoDelay() {}
|
|
702
|
+
|
|
703
|
+
setKeepAlive() {}
|
|
704
|
+
|
|
705
|
+
sendJson(payload) {
|
|
706
|
+
return this.sendText(JSON.stringify(payload));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
write(payload) {
|
|
710
|
+
if (this.destroyed) {
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const text = (Buffer.isBuffer(payload) ? payload.toString('utf8') : String(payload || '')).replace(/\n$/, '');
|
|
715
|
+
return this.sendText(text);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
sendText(text) {
|
|
719
|
+
return this.sendFrame(0x1, Buffer.from(String(text || ''), 'utf8'));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
sendFrame(opcode, payload) {
|
|
723
|
+
if (this.destroyed || this.socket.destroyed) {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
this.socket.write(buildRemoteHubWebSocketFrame(opcode, payload));
|
|
729
|
+
return true;
|
|
730
|
+
} catch {
|
|
731
|
+
this.destroy();
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
destroy() {
|
|
737
|
+
if (this.destroyed) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
this.destroyed = true;
|
|
742
|
+
try {
|
|
743
|
+
if (!this.socket.destroyed) {
|
|
744
|
+
this.socket.write(buildRemoteHubWebSocketFrame(0x8, Buffer.alloc(0)));
|
|
745
|
+
}
|
|
746
|
+
} catch {
|
|
747
|
+
// Ignore close-frame failures.
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
this.socket.destroy();
|
|
751
|
+
} catch {
|
|
752
|
+
// Ignore socket destroy failures.
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
handleData(chunk) {
|
|
757
|
+
if (this.destroyed) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
this.buffer = this.buffer.length > 0
|
|
762
|
+
? Buffer.concat([this.buffer, chunk])
|
|
763
|
+
: chunk;
|
|
764
|
+
|
|
765
|
+
while (!this.destroyed) {
|
|
766
|
+
if (this.buffer.length < 2) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const first = this.buffer[0];
|
|
771
|
+
const second = this.buffer[1];
|
|
772
|
+
const fin = (first & 0x80) !== 0;
|
|
773
|
+
const opcode = first & 0x0f;
|
|
774
|
+
const masked = (second & 0x80) !== 0;
|
|
775
|
+
let payloadLength = second & 0x7f;
|
|
776
|
+
let offset = 2;
|
|
777
|
+
|
|
778
|
+
if (payloadLength === 126) {
|
|
779
|
+
if (this.buffer.length < offset + 2) return;
|
|
780
|
+
payloadLength = this.buffer.readUInt16BE(offset);
|
|
781
|
+
offset += 2;
|
|
782
|
+
} else if (payloadLength === 127) {
|
|
783
|
+
if (this.buffer.length < offset + 8) return;
|
|
784
|
+
const bigLength = this.buffer.readBigUInt64BE(offset);
|
|
785
|
+
if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
786
|
+
this.destroy();
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
payloadLength = Number(bigLength);
|
|
790
|
+
offset += 8;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (!masked) {
|
|
794
|
+
this.destroy();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (this.buffer.length < offset + 4 + payloadLength) {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const mask = this.buffer.subarray(offset, offset + 4);
|
|
803
|
+
offset += 4;
|
|
804
|
+
const payload = Buffer.from(this.buffer.subarray(offset, offset + payloadLength));
|
|
805
|
+
this.buffer = this.buffer.subarray(offset + payloadLength);
|
|
806
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
807
|
+
payload[index] ^= mask[index & 3];
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (opcode === 0x8) {
|
|
811
|
+
this.destroyed = true;
|
|
812
|
+
this.onCloseMessage('websocket-close');
|
|
813
|
+
try {
|
|
814
|
+
this.socket.destroy();
|
|
815
|
+
} catch {
|
|
816
|
+
// Ignore socket destroy failures.
|
|
817
|
+
}
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (opcode === 0x9) {
|
|
822
|
+
this.sendFrame(0xA, payload);
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (opcode === 0xA) {
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
let messageOpcode = opcode;
|
|
831
|
+
let messagePayload = payload;
|
|
832
|
+
if (!fin) {
|
|
833
|
+
this.fragmentOpcode = opcode;
|
|
834
|
+
this.fragments = [payload];
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (opcode === 0x0) {
|
|
839
|
+
this.fragments.push(payload);
|
|
840
|
+
messageOpcode = this.fragmentOpcode;
|
|
841
|
+
messagePayload = Buffer.concat(this.fragments);
|
|
842
|
+
this.fragmentOpcode = 0;
|
|
843
|
+
this.fragments = [];
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (messageOpcode === 0x1) {
|
|
847
|
+
this.onTextMessage(messagePayload.toString('utf8'));
|
|
848
|
+
} else if (messageOpcode === 0x2) {
|
|
849
|
+
this.onBinaryMessage(messagePayload);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export function createRemoteHub(options = {}) {
|
|
856
|
+
const env = options.env || process.env;
|
|
857
|
+
const logEvent = options.logEvent || (() => {});
|
|
858
|
+
const logWarn = options.logWarn || (() => {});
|
|
859
|
+
const emitEvent = options.emitEvent || (() => {});
|
|
860
|
+
const emitFrame = typeof options.emitFrame === 'function' ? options.emitFrame : (() => {});
|
|
861
|
+
|
|
862
|
+
const enabled = isEnabledValue(env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
|
|
863
|
+
&& !isDisabledValue(env.REMOTE_HUB_DISABLED);
|
|
864
|
+
const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
|
|
865
|
+
const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
|
|
866
|
+
const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
|
|
867
|
+
const taskTimeoutMs = clampNumber(
|
|
868
|
+
options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
|
|
869
|
+
50,
|
|
870
|
+
30 * 60 * 1000,
|
|
871
|
+
DEFAULT_AGENT_TASK_TIMEOUT_MS);
|
|
872
|
+
const managerPackage = safeString(options.managerPackage ?? env.MINDEXEC_MANAGER_PACKAGE ?? 'mindexec-ai', 128);
|
|
873
|
+
const managerVersion = safeString(options.managerVersion ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
|
|
874
|
+
const hostInstanceId = safeString(options.hostInstanceId ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
|
|
875
|
+
const publicEndpoint = safeString(env.MINDEXEC_REMOTE_PUBLIC_ENDPOINT || env.REMOTE_HUB_PUBLIC_ENDPOINT, 256);
|
|
876
|
+
const publicHost = safeString(env.MINDEXEC_REMOTE_PUBLIC_HOST || env.REMOTE_HUB_PUBLIC_HOST, 128);
|
|
877
|
+
const pairToken = safeString(
|
|
878
|
+
options.pairToken || env.REMOTE_HUB_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN || crypto.randomBytes(6).toString('hex'),
|
|
879
|
+
256);
|
|
880
|
+
|
|
881
|
+
const devices = new Map();
|
|
882
|
+
const taskBatches = new Map();
|
|
883
|
+
const sockets = new Map();
|
|
884
|
+
const allSockets = new Set();
|
|
885
|
+
let server = null;
|
|
886
|
+
let started = false;
|
|
887
|
+
let boundPort = requestedPort;
|
|
888
|
+
let lastError = '';
|
|
889
|
+
let hostTarget = null;
|
|
890
|
+
|
|
891
|
+
function getAnnouncedHost() {
|
|
892
|
+
if (publicHost) {
|
|
893
|
+
return publicHost;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (isWildcardHost(host)) {
|
|
897
|
+
return getReachableIPv4Address();
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return host;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function getAgentEndpoint() {
|
|
904
|
+
if (publicEndpoint) {
|
|
905
|
+
return publicEndpoint;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return `${getAnnouncedHost()}:${boundPort || requestedPort}`;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function getAgentEndpointRouteInfo() {
|
|
912
|
+
const includeInterfaceCandidates = !publicEndpoint && isWildcardHost(host);
|
|
913
|
+
const candidateDetails = includeInterfaceCandidates
|
|
914
|
+
? getReachableIPv4CandidateEntries()
|
|
915
|
+
: [];
|
|
916
|
+
const candidateEndpoints = candidateDetails
|
|
917
|
+
.map(candidate => ({
|
|
918
|
+
...candidate,
|
|
919
|
+
endpoint: buildEndpoint(candidate.address, boundPort || requestedPort)
|
|
920
|
+
}))
|
|
921
|
+
.filter(candidate => candidate.endpoint);
|
|
922
|
+
const endpoint = getAgentEndpoint();
|
|
923
|
+
const endpointCandidates = [
|
|
924
|
+
endpoint,
|
|
925
|
+
...candidateEndpoints.map(candidate => candidate.endpoint)
|
|
926
|
+
].filter((value, index, all) => value && all.indexOf(value) === index);
|
|
927
|
+
const wildcardWithoutReachableAddress =
|
|
928
|
+
!publicEndpoint
|
|
929
|
+
&& !publicHost
|
|
930
|
+
&& isWildcardHost(host)
|
|
931
|
+
&& candidateDetails.length === 0;
|
|
932
|
+
const reason = getAccountRouteEndpointReason(endpoint, { wildcardWithoutReachableAddress });
|
|
933
|
+
const parsed = parseManagerEndpoint(endpoint);
|
|
934
|
+
return {
|
|
935
|
+
endpoint,
|
|
936
|
+
host: parsed?.host || '',
|
|
937
|
+
port: parsed?.port || 0,
|
|
938
|
+
accountRouteReady: reason === 'ok',
|
|
939
|
+
reason,
|
|
940
|
+
candidates: endpointCandidates,
|
|
941
|
+
candidateDetails: candidateEndpoints
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function getActiveHostTarget() {
|
|
946
|
+
if (!hostTarget) {
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const expiresMs = Date.parse(hostTarget.expiresAt || '');
|
|
951
|
+
if (Number.isFinite(expiresMs) && expiresMs <= Date.now()) {
|
|
952
|
+
const expiredTarget = hostTarget;
|
|
953
|
+
hostTarget = null;
|
|
954
|
+
emitRemoteEvent('RemoteHostTargetExpired', null, {
|
|
955
|
+
hostTarget: {
|
|
956
|
+
...expiredTarget,
|
|
957
|
+
active: false
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
return hostTarget;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function serializeHostTarget(target = getActiveHostTarget()) {
|
|
967
|
+
if (!target) {
|
|
968
|
+
return {
|
|
969
|
+
active: false,
|
|
970
|
+
nodeId: '',
|
|
971
|
+
leaseId: '',
|
|
972
|
+
hostInstanceId: '',
|
|
973
|
+
endpoint: '',
|
|
974
|
+
endpointCandidates: [],
|
|
975
|
+
activatedAt: '',
|
|
976
|
+
updatedAt: '',
|
|
977
|
+
expiresAt: ''
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return {
|
|
982
|
+
active: true,
|
|
983
|
+
nodeId: target.nodeId,
|
|
984
|
+
leaseId: target.leaseId,
|
|
985
|
+
hostInstanceId: target.hostInstanceId || hostInstanceId,
|
|
986
|
+
endpoint: target.endpoint,
|
|
987
|
+
endpointCandidates: Array.isArray(target.endpointCandidates) ? [...target.endpointCandidates] : [target.endpoint].filter(Boolean),
|
|
988
|
+
activatedAt: target.activatedAt,
|
|
989
|
+
updatedAt: target.updatedAt,
|
|
990
|
+
expiresAt: target.expiresAt
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function setHostTarget(options = {}) {
|
|
995
|
+
const enabled = options.enabled !== false;
|
|
996
|
+
const nodeId = safeString(options.nodeId, 128);
|
|
997
|
+
if (!enabled) {
|
|
998
|
+
const previous = getActiveHostTarget();
|
|
999
|
+
if (!previous) {
|
|
1000
|
+
return {
|
|
1001
|
+
ok: true,
|
|
1002
|
+
active: false,
|
|
1003
|
+
hostTarget: serializeHostTarget(null)
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (nodeId && previous.nodeId && nodeId !== previous.nodeId) {
|
|
1008
|
+
return {
|
|
1009
|
+
ok: false,
|
|
1010
|
+
error: 'host-target-node-mismatch',
|
|
1011
|
+
active: true,
|
|
1012
|
+
hostTarget: serializeHostTarget(previous)
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
hostTarget = null;
|
|
1017
|
+
emitRemoteEvent('RemoteHostTargetCleared', null, {
|
|
1018
|
+
hostTarget: {
|
|
1019
|
+
...serializeHostTarget(previous),
|
|
1020
|
+
active: false
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
return {
|
|
1024
|
+
ok: true,
|
|
1025
|
+
active: false,
|
|
1026
|
+
hostTarget: serializeHostTarget(null)
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (!nodeId) {
|
|
1031
|
+
return {
|
|
1032
|
+
ok: false,
|
|
1033
|
+
error: 'missing-node-id',
|
|
1034
|
+
active: false,
|
|
1035
|
+
hostTarget: serializeHostTarget()
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const now = new Date();
|
|
1040
|
+
const leaseMs = clampNumber(options.leaseMs, 5000, 10 * 60 * 1000, DEFAULT_HOST_TARGET_LEASE_MS);
|
|
1041
|
+
const previous = getActiveHostTarget();
|
|
1042
|
+
const activeSameNode = previous?.nodeId === nodeId;
|
|
1043
|
+
const routeInfo = getAgentEndpointRouteInfo();
|
|
1044
|
+
hostTarget = {
|
|
1045
|
+
nodeId,
|
|
1046
|
+
leaseId: activeSameNode && previous?.leaseId
|
|
1047
|
+
? previous.leaseId
|
|
1048
|
+
: (safeString(options.leaseId, 128) || crypto.randomUUID()),
|
|
1049
|
+
hostInstanceId,
|
|
1050
|
+
endpoint: routeInfo.endpoint,
|
|
1051
|
+
endpointCandidates: routeInfo.candidates,
|
|
1052
|
+
activatedAt: activeSameNode && previous?.activatedAt ? previous.activatedAt : now.toISOString(),
|
|
1053
|
+
updatedAt: now.toISOString(),
|
|
1054
|
+
expiresAt: new Date(now.getTime() + leaseMs).toISOString()
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
emitRemoteEvent('RemoteHostTargetSet', null, {
|
|
1058
|
+
hostTarget: serializeHostTarget(hostTarget),
|
|
1059
|
+
replacedNodeId: previous && previous.nodeId !== nodeId ? previous.nodeId : ''
|
|
1060
|
+
});
|
|
1061
|
+
if (!activeSameNode || !routeInfo.accountRouteReady) {
|
|
1062
|
+
logEvent(
|
|
1063
|
+
'remote',
|
|
1064
|
+
`host target ${routeInfo.accountRouteReady ? 'account-ready' : 'local-only'} endpoint=${routeInfo.endpoint} candidates=${routeInfo.candidates.length} reason=${routeInfo.reason}`,
|
|
1065
|
+
'remote');
|
|
1066
|
+
}
|
|
1067
|
+
return {
|
|
1068
|
+
ok: true,
|
|
1069
|
+
active: true,
|
|
1070
|
+
hostTarget: serializeHostTarget(hostTarget)
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function getStatus({ includeSecrets = false } = {}) {
|
|
1075
|
+
const connectedDevices = [...devices.values()].filter(device => device.connected).length;
|
|
1076
|
+
const activeHostTarget = serializeHostTarget();
|
|
1077
|
+
const routeInfo = getAgentEndpointRouteInfo();
|
|
1078
|
+
return {
|
|
1079
|
+
enabled,
|
|
1080
|
+
started,
|
|
1081
|
+
host,
|
|
1082
|
+
agentHost: routeInfo.host || getAnnouncedHost(),
|
|
1083
|
+
port: boundPort || requestedPort,
|
|
1084
|
+
protocol: 'tcp-jsonl',
|
|
1085
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
1086
|
+
heartbeatMs,
|
|
1087
|
+
taskTimeoutMs,
|
|
1088
|
+
managerPackage,
|
|
1089
|
+
managerVersion,
|
|
1090
|
+
hostInstanceId,
|
|
1091
|
+
agentPackage: '@mindexec/remote',
|
|
1092
|
+
agentEndpoint: routeInfo.endpoint,
|
|
1093
|
+
agentEndpointAccountRouteReady: routeInfo.accountRouteReady,
|
|
1094
|
+
agentEndpointRouteReason: routeInfo.reason,
|
|
1095
|
+
agentEndpointCandidates: routeInfo.candidates,
|
|
1096
|
+
agentEndpointCandidateDetails: routeInfo.candidateDetails,
|
|
1097
|
+
pairToken: includeSecrets ? pairToken : undefined,
|
|
1098
|
+
pairTokenPreview: maskToken(pairToken),
|
|
1099
|
+
deviceCount: devices.size,
|
|
1100
|
+
connectedDeviceCount: connectedDevices,
|
|
1101
|
+
canvasDeviceListMode: 'all-devices',
|
|
1102
|
+
canvasPagination: 'none',
|
|
1103
|
+
hostTargetActive: activeHostTarget.active,
|
|
1104
|
+
hostTargetNodeId: activeHostTarget.nodeId,
|
|
1105
|
+
hostTargetLeaseId: activeHostTarget.leaseId,
|
|
1106
|
+
hostTargetHostInstanceId: activeHostTarget.hostInstanceId,
|
|
1107
|
+
hostTargetEndpoint: activeHostTarget.endpoint,
|
|
1108
|
+
hostTargetEndpointCandidates: activeHostTarget.endpointCandidates,
|
|
1109
|
+
hostTargetActivatedAt: activeHostTarget.activatedAt,
|
|
1110
|
+
hostTargetUpdatedAt: activeHostTarget.updatedAt,
|
|
1111
|
+
hostTargetExpiresAt: activeHostTarget.expiresAt,
|
|
1112
|
+
externalExposure: isWildcardHost(host) || !isLoopbackHost(host),
|
|
1113
|
+
lastError
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function listDevices(options = {}) {
|
|
1118
|
+
const serializeOptions = {
|
|
1119
|
+
includeDataUrl: options.includeDataUrl === true
|
|
1120
|
+
};
|
|
1121
|
+
return [...devices.values()]
|
|
1122
|
+
.map(device => serializeDevice(device, serializeOptions))
|
|
1123
|
+
.filter(Boolean)
|
|
1124
|
+
.sort((a, b) => {
|
|
1125
|
+
const nameCompare = String(a.deviceName || '').localeCompare(String(b.deviceName || ''));
|
|
1126
|
+
if (nameCompare !== 0) {
|
|
1127
|
+
return nameCompare;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
return String(a.deviceId).localeCompare(String(b.deviceId));
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function listDeviceFrames(options = {}) {
|
|
1135
|
+
const ids = Array.isArray(options.deviceIds)
|
|
1136
|
+
? options.deviceIds.map(value => safeString(value, 160)).filter(Boolean)
|
|
1137
|
+
: [];
|
|
1138
|
+
const idSet = ids.length > 0 ? new Set(ids) : null;
|
|
1139
|
+
const includeThumbnail = options.includeThumbnail !== false;
|
|
1140
|
+
const includeLive = options.includeLive !== false;
|
|
1141
|
+
|
|
1142
|
+
return [...devices.values()]
|
|
1143
|
+
.filter(device => !idSet || idSet.has(device.deviceId))
|
|
1144
|
+
.map(device => ({
|
|
1145
|
+
deviceId: device.deviceId,
|
|
1146
|
+
connected: device.connected === true,
|
|
1147
|
+
thumbnailEnabled: readCapabilityFlag(device.capabilities, 'thumbnail'),
|
|
1148
|
+
liveStreamActive: device.activeLiveStream?.active === true,
|
|
1149
|
+
liveStreamId: device.activeLiveStream?.streamId || '',
|
|
1150
|
+
liveStreamFps: Number(device.activeLiveStream?.fps || 0),
|
|
1151
|
+
liveStreamLastFrameAt: device.activeLiveStream?.lastFrameAt || '',
|
|
1152
|
+
thumbnail: includeThumbnail
|
|
1153
|
+
? serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', { includeDataUrl: false })
|
|
1154
|
+
: null,
|
|
1155
|
+
live: includeLive
|
|
1156
|
+
? serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', { includeDataUrl: false })
|
|
1157
|
+
: null
|
|
1158
|
+
}))
|
|
1159
|
+
.sort((a, b) => String(a.deviceId).localeCompare(String(b.deviceId)));
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function listTaskBatches() {
|
|
1163
|
+
return [...taskBatches.values()]
|
|
1164
|
+
.map(serializeTaskBatch)
|
|
1165
|
+
.filter(Boolean)
|
|
1166
|
+
.sort((left, right) => String(right.requestedAt || '').localeCompare(String(left.requestedAt || '')));
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function getLatestTaskBatch() {
|
|
1170
|
+
return listTaskBatches()[0] || null;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function emitRemoteEvent(type, device = null, extra = {}) {
|
|
1174
|
+
emitEvent(type, {
|
|
1175
|
+
...extra,
|
|
1176
|
+
remoteHub: getStatus({ includeSecrets: false }),
|
|
1177
|
+
device: serializeDevice(device)
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
|
|
1182
|
+
const now = new Date().toISOString();
|
|
1183
|
+
const frameSeq = Number(device?.counters?.thumbnailFramesReceived || 0)
|
|
1184
|
+
+ Number(device?.counters?.liveFramesReceived || 0)
|
|
1185
|
+
+ 1;
|
|
1186
|
+
return {
|
|
1187
|
+
streamId: safeString(streamId, 128) || `${mode}-${Date.now()}`,
|
|
1188
|
+
frameSeq,
|
|
1189
|
+
commandId: safeString(options.commandId, 128),
|
|
1190
|
+
width: clampNumber(options.width, 2, 2560, mode === 'remote-fast' ? 960 : 360),
|
|
1191
|
+
height: clampNumber(options.height, 1, 1440, mode === 'remote-fast' ? 540 : 220),
|
|
1192
|
+
mimeType: 'image/png',
|
|
1193
|
+
format: 'image/png',
|
|
1194
|
+
mode,
|
|
1195
|
+
fps: clampNumber(options.fps, 1, 24, mode === 'remote-fast' ? 20 : 1),
|
|
1196
|
+
capturedAt: now,
|
|
1197
|
+
receivedAt: now,
|
|
1198
|
+
byteLength: 68,
|
|
1199
|
+
transport: 'synthetic',
|
|
1200
|
+
contentHash: SYNTHETIC_FRAME_HASH,
|
|
1201
|
+
captureMs: 0,
|
|
1202
|
+
sameContentStreak: 0,
|
|
1203
|
+
dataUrl: SYNTHETIC_FRAME_DATA_URL,
|
|
1204
|
+
payload: SYNTHETIC_FRAME_PAYLOAD,
|
|
1205
|
+
accessToken: createFrameAccessToken()
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function applySyntheticThumbnail(device, command) {
|
|
1210
|
+
if (!device) {
|
|
1211
|
+
return null;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const payload = command?.payload || {};
|
|
1215
|
+
const frame = makeSyntheticFrame(device, payload.streamId || 'synthetic-thumb', 'thumbnail', {
|
|
1216
|
+
commandId: command?.commandId,
|
|
1217
|
+
width: payload.maxWidth || 360,
|
|
1218
|
+
height: payload.maxHeight || 220
|
|
1219
|
+
});
|
|
1220
|
+
delete frame.mode;
|
|
1221
|
+
delete frame.fps;
|
|
1222
|
+
device.latestThumbnail = frame;
|
|
1223
|
+
rememberRecentFramePayload(device, 'thumbnail', frame);
|
|
1224
|
+
device.lastSeenAt = frame.receivedAt;
|
|
1225
|
+
device.counters.thumbnailFramesReceived += 1;
|
|
1226
|
+
emitRemoteEvent('RemoteFrameReceived', device, {
|
|
1227
|
+
streamId: frame.streamId,
|
|
1228
|
+
frameSeq: frame.frameSeq,
|
|
1229
|
+
synthetic: true
|
|
1230
|
+
});
|
|
1231
|
+
return frame;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function applySyntheticLiveFrame(device, streamId, options = {}) {
|
|
1235
|
+
if (!device?.activeLiveStream?.active) {
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const frame = makeSyntheticFrame(device, streamId || device.activeLiveStream.streamId, 'remote-fast', {
|
|
1240
|
+
commandId: options.commandId || device.activeLiveStream.commandId,
|
|
1241
|
+
width: options.maxWidth || 960,
|
|
1242
|
+
height: options.maxHeight || 540,
|
|
1243
|
+
fps: options.fps || device.activeLiveStream.fps
|
|
1244
|
+
});
|
|
1245
|
+
device.latestLiveFrame = frame;
|
|
1246
|
+
rememberRecentFramePayload(device, 'live', frame);
|
|
1247
|
+
emitFrame({
|
|
1248
|
+
kind: 'live',
|
|
1249
|
+
deviceId: device.deviceId,
|
|
1250
|
+
frame: serializeRemoteFrame(frame, device.deviceId, 'live', { includeDataUrl: false }),
|
|
1251
|
+
payload: frame.payload,
|
|
1252
|
+
mimeType: frame.mimeType,
|
|
1253
|
+
byteLength: frame.byteLength
|
|
1254
|
+
});
|
|
1255
|
+
device.activeLiveStream.lastFrameAt = frame.receivedAt;
|
|
1256
|
+
device.activeLiveStream.lastFrameSeq = frame.frameSeq;
|
|
1257
|
+
device.activeLiveStream.framesReceived = (device.activeLiveStream.framesReceived || 0) + 1;
|
|
1258
|
+
device.lastSeenAt = frame.receivedAt;
|
|
1259
|
+
device.counters.liveFramesReceived += 1;
|
|
1260
|
+
emitRemoteEvent('RemoteFrameReceived', device, {
|
|
1261
|
+
streamId: frame.streamId,
|
|
1262
|
+
frameSeq: frame.frameSeq,
|
|
1263
|
+
synthetic: true
|
|
1264
|
+
});
|
|
1265
|
+
return frame;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function closeExistingDeviceSocket(deviceId, nextSessionId) {
|
|
1269
|
+
const existing = devices.get(deviceId);
|
|
1270
|
+
if (!existing?.socket || existing.socket.destroyed) {
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
existing.lastDisconnectReason = 'replaced-by-new-session';
|
|
1275
|
+
failAllPendingTasks(existing, 'replaced-by-new-session');
|
|
1276
|
+
writeJsonLine(existing.socket, {
|
|
1277
|
+
type: 'disconnect',
|
|
1278
|
+
reason: 'replaced-by-new-session',
|
|
1279
|
+
nextSessionId
|
|
1280
|
+
});
|
|
1281
|
+
existing.socket.destroy();
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function clearSyntheticFleet() {
|
|
1285
|
+
let removed = 0;
|
|
1286
|
+
for (const [deviceId, device] of devices.entries()) {
|
|
1287
|
+
if (device.synthetic === true) {
|
|
1288
|
+
devices.delete(deviceId);
|
|
1289
|
+
removed += 1;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
return { ok: true, removed, total: devices.size };
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function seedSyntheticFleet(options = {}) {
|
|
1297
|
+
const count = clampNumber(options.count, 1, MAX_SYNTHETIC_DEVICES, 250);
|
|
1298
|
+
const connectedRatio = Math.max(0, Math.min(1, Number(options.connectedRatio ?? 0.88)));
|
|
1299
|
+
const thumbnailRatio = Math.max(0, Math.min(1, Number(options.thumbnailRatio ?? 0.7)));
|
|
1300
|
+
const aiAssistRatio = Math.max(0, Math.min(1, Number(options.aiAssistRatio ?? 0.35)));
|
|
1301
|
+
const liveCount = clampNumber(options.liveCount, 0, Math.min(count, 24), Math.min(6, count));
|
|
1302
|
+
const replace = options.replace !== false;
|
|
1303
|
+
const now = new Date();
|
|
1304
|
+
const platforms = ['win32', 'linux', 'darwin'];
|
|
1305
|
+
|
|
1306
|
+
if (replace) {
|
|
1307
|
+
clearSyntheticFleet();
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
let connected = 0;
|
|
1311
|
+
let aiAssist = 0;
|
|
1312
|
+
let live = 0;
|
|
1313
|
+
for (let index = 0; index < count; index += 1) {
|
|
1314
|
+
const ordinal = index + 1;
|
|
1315
|
+
const deviceId = `synthetic-${String(ordinal).padStart(4, '0')}`;
|
|
1316
|
+
const isConnected = index / count < connectedRatio;
|
|
1317
|
+
const hasThumbnail = index / count < thumbnailRatio;
|
|
1318
|
+
const hasAiAssist = index / count < aiAssistRatio;
|
|
1319
|
+
const isLive = isConnected && index < liveCount;
|
|
1320
|
+
const seenAt = new Date(now.getTime() - index * 1350).toISOString();
|
|
1321
|
+
const connectedAt = new Date(now.getTime() - (index + 8) * 60000).toISOString();
|
|
1322
|
+
const platform = platforms[index % platforms.length];
|
|
1323
|
+
const usedMemRatio = Number((0.18 + (index % 71) / 100).toFixed(2));
|
|
1324
|
+
const load1 = Number(((index % 19) / 3).toFixed(2));
|
|
1325
|
+
const taskStatus = index % 17 === 0
|
|
1326
|
+
? 'failed'
|
|
1327
|
+
: index % 5 === 0
|
|
1328
|
+
? 'completed'
|
|
1329
|
+
: index % 7 === 0
|
|
1330
|
+
? 'queued'
|
|
1331
|
+
: '';
|
|
1332
|
+
const latestTask = taskStatus
|
|
1333
|
+
? {
|
|
1334
|
+
taskId: `synthetic-task-${ordinal}`,
|
|
1335
|
+
commandId: `synthetic-command-${ordinal}`,
|
|
1336
|
+
title: taskStatus === 'failed' ? 'Synthetic issue check' : 'Synthetic status task',
|
|
1337
|
+
instructionPreview: 'Synthetic fleet scale validation task.',
|
|
1338
|
+
status: taskStatus,
|
|
1339
|
+
approvalLevel: hasAiAssist ? 'ai-assist' : 'task-only',
|
|
1340
|
+
requestedAt: seenAt,
|
|
1341
|
+
sentAt: seenAt,
|
|
1342
|
+
updatedAt: seenAt,
|
|
1343
|
+
completedAt: taskStatus === 'completed' || taskStatus === 'failed' ? seenAt : '',
|
|
1344
|
+
error: taskStatus === 'failed' ? 'Synthetic task failure sample.' : '',
|
|
1345
|
+
resultKind: 'synthetic-agent-task',
|
|
1346
|
+
resultModel: hasAiAssist ? 'synthetic-ai' : '',
|
|
1347
|
+
resultResponseId: hasAiAssist ? `synthetic-response-${ordinal}` : '',
|
|
1348
|
+
resultSummary: taskStatus === 'failed'
|
|
1349
|
+
? 'Synthetic task reported a sample issue.'
|
|
1350
|
+
: 'Synthetic task completed for scale validation.'
|
|
1351
|
+
}
|
|
1352
|
+
: null;
|
|
1353
|
+
const device = {
|
|
1354
|
+
socket: null,
|
|
1355
|
+
synthetic: true,
|
|
1356
|
+
deviceId,
|
|
1357
|
+
sessionId: `synthetic-session-${ordinal}`,
|
|
1358
|
+
deviceName: `Synthetic PC ${String(ordinal).padStart(4, '0')}`,
|
|
1359
|
+
hostname: `synthetic-host-${String(ordinal).padStart(4, '0')}`,
|
|
1360
|
+
platform,
|
|
1361
|
+
arch: index % 4 === 0 ? 'arm64' : 'x64',
|
|
1362
|
+
pid: 0,
|
|
1363
|
+
agentVersion: 'synthetic-scale',
|
|
1364
|
+
capabilities: {
|
|
1365
|
+
status: true,
|
|
1366
|
+
thumbnail: hasThumbnail,
|
|
1367
|
+
control: false,
|
|
1368
|
+
liveStream: true,
|
|
1369
|
+
computerAgent: true,
|
|
1370
|
+
taskDispatch: true,
|
|
1371
|
+
aiAssist: hasAiAssist,
|
|
1372
|
+
aiModel: hasAiAssist ? 'synthetic-ai' : '',
|
|
1373
|
+
aiProvider: hasAiAssist ? 'synthetic' : ''
|
|
1374
|
+
},
|
|
1375
|
+
connected: isConnected,
|
|
1376
|
+
connectedAt: isConnected ? connectedAt : '',
|
|
1377
|
+
disconnectedAt: isConnected ? '' : seenAt,
|
|
1378
|
+
lastSeenAt: seenAt,
|
|
1379
|
+
lastStatusAt: seenAt,
|
|
1380
|
+
lastDisconnectReason: isConnected ? '' : 'synthetic-offline',
|
|
1381
|
+
remoteAddress: 'synthetic.local',
|
|
1382
|
+
remotePort: 0,
|
|
1383
|
+
status: {
|
|
1384
|
+
platform,
|
|
1385
|
+
release: platform === 'win32' ? 'Windows 11' : platform === 'darwin' ? 'macOS' : 'Linux',
|
|
1386
|
+
uptimeSec: (ordinal * 791) % 1209600,
|
|
1387
|
+
usedMemRatio,
|
|
1388
|
+
loadavg: [load1, Math.max(0, load1 - 0.2), Math.max(0, load1 - 0.4)]
|
|
1389
|
+
},
|
|
1390
|
+
latestThumbnail: null,
|
|
1391
|
+
latestLiveFrame: null,
|
|
1392
|
+
recentFramePayloads: {
|
|
1393
|
+
thumbnail: [],
|
|
1394
|
+
live: []
|
|
1395
|
+
},
|
|
1396
|
+
activeLiveStream: null,
|
|
1397
|
+
latestTask,
|
|
1398
|
+
recentTasks: latestTask ? [latestTask] : [],
|
|
1399
|
+
pendingTaskCommands: new Map(),
|
|
1400
|
+
pendingTaskTimers: new Map(),
|
|
1401
|
+
counters: createDeviceCounters({
|
|
1402
|
+
messagesReceived: 1,
|
|
1403
|
+
statusReceived: 1,
|
|
1404
|
+
tasksQueued: latestTask ? 1 : 0,
|
|
1405
|
+
taskResultsReceived: latestTask && ['completed', 'failed'].includes(latestTask.status) ? 1 : 0,
|
|
1406
|
+
taskResultsFailed: latestTask?.status === 'failed' ? 1 : 0
|
|
1407
|
+
})
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
if (hasThumbnail) {
|
|
1411
|
+
const frame = makeSyntheticFrame(device, `synthetic-thumb-${ordinal}`, 'thumbnail', {
|
|
1412
|
+
width: 360,
|
|
1413
|
+
height: 220
|
|
1414
|
+
});
|
|
1415
|
+
delete frame.mode;
|
|
1416
|
+
delete frame.fps;
|
|
1417
|
+
frame.receivedAt = seenAt;
|
|
1418
|
+
frame.capturedAt = seenAt;
|
|
1419
|
+
device.latestThumbnail = frame;
|
|
1420
|
+
rememberRecentFramePayload(device, 'thumbnail', frame);
|
|
1421
|
+
device.counters.thumbnailFramesReceived = 1;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
if (isLive) {
|
|
1425
|
+
device.activeLiveStream = {
|
|
1426
|
+
streamId: `synthetic-live-${ordinal}`,
|
|
1427
|
+
commandId: `synthetic-live-command-${ordinal}`,
|
|
1428
|
+
active: true,
|
|
1429
|
+
mode: 'remote-fast',
|
|
1430
|
+
fps: 20,
|
|
1431
|
+
startedAt: seenAt,
|
|
1432
|
+
stoppedAt: '',
|
|
1433
|
+
stopReason: '',
|
|
1434
|
+
lastFrameAt: '',
|
|
1435
|
+
lastFrameSeq: 0,
|
|
1436
|
+
framesReceived: 0
|
|
1437
|
+
};
|
|
1438
|
+
applySyntheticLiveFrame(device, device.activeLiveStream.streamId, {
|
|
1439
|
+
commandId: device.activeLiveStream.commandId,
|
|
1440
|
+
fps: 20
|
|
1441
|
+
});
|
|
1442
|
+
live += 1;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
if (isConnected) {
|
|
1446
|
+
connected += 1;
|
|
1447
|
+
}
|
|
1448
|
+
if (hasAiAssist) {
|
|
1449
|
+
aiAssist += 1;
|
|
1450
|
+
}
|
|
1451
|
+
devices.set(deviceId, device);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
emitRemoteEvent('RemoteSyntheticFleetSeeded', null, {
|
|
1455
|
+
count,
|
|
1456
|
+
connected,
|
|
1457
|
+
aiAssist,
|
|
1458
|
+
live
|
|
1459
|
+
});
|
|
1460
|
+
return {
|
|
1461
|
+
ok: true,
|
|
1462
|
+
synthetic: true,
|
|
1463
|
+
seeded: count,
|
|
1464
|
+
connected,
|
|
1465
|
+
aiAssist,
|
|
1466
|
+
live,
|
|
1467
|
+
total: devices.size,
|
|
1468
|
+
max: MAX_SYNTHETIC_DEVICES
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function attachDevice(socket, hello) {
|
|
1473
|
+
const now = new Date().toISOString();
|
|
1474
|
+
const sessionId = crypto.randomUUID();
|
|
1475
|
+
const deviceId = normalizeDeviceId(hello.deviceId);
|
|
1476
|
+
|
|
1477
|
+
closeExistingDeviceSocket(deviceId, sessionId);
|
|
1478
|
+
|
|
1479
|
+
const device = {
|
|
1480
|
+
socket,
|
|
1481
|
+
deviceId,
|
|
1482
|
+
sessionId,
|
|
1483
|
+
deviceName: safeString(hello.deviceName || hello.hostname || deviceId, 120),
|
|
1484
|
+
hostname: safeString(hello.hostname, 120),
|
|
1485
|
+
platform: safeString(hello.platform, 80),
|
|
1486
|
+
arch: safeString(hello.arch, 40),
|
|
1487
|
+
pid: Number.isFinite(Number(hello.pid)) ? Number(hello.pid) : 0,
|
|
1488
|
+
agentVersion: safeString(hello.agentVersion, 40),
|
|
1489
|
+
capabilities: typeof hello.capabilities === 'object' && hello.capabilities
|
|
1490
|
+
? { ...hello.capabilities }
|
|
1491
|
+
: {},
|
|
1492
|
+
connected: true,
|
|
1493
|
+
connectedAt: now,
|
|
1494
|
+
disconnectedAt: '',
|
|
1495
|
+
lastSeenAt: now,
|
|
1496
|
+
lastStatusAt: '',
|
|
1497
|
+
lastDisconnectReason: '',
|
|
1498
|
+
remoteAddress: socket.remoteAddress || '',
|
|
1499
|
+
remotePort: socket.remotePort || 0,
|
|
1500
|
+
status: {},
|
|
1501
|
+
latestThumbnail: null,
|
|
1502
|
+
latestLiveFrame: null,
|
|
1503
|
+
recentFramePayloads: {
|
|
1504
|
+
thumbnail: [],
|
|
1505
|
+
live: []
|
|
1506
|
+
},
|
|
1507
|
+
activeLiveStream: null,
|
|
1508
|
+
latestTask: null,
|
|
1509
|
+
recentTasks: [],
|
|
1510
|
+
pendingTaskCommands: new Map(),
|
|
1511
|
+
pendingTaskTimers: new Map(),
|
|
1512
|
+
synthetic: false,
|
|
1513
|
+
counters: createDeviceCounters({ messagesReceived: 1 })
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
devices.set(deviceId, device);
|
|
1517
|
+
sockets.set(socket, deviceId);
|
|
1518
|
+
writeJsonLine(socket, {
|
|
1519
|
+
type: 'welcome',
|
|
1520
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
1521
|
+
sessionId,
|
|
1522
|
+
deviceId,
|
|
1523
|
+
heartbeatMs,
|
|
1524
|
+
serverTime: now
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
logEvent('remote', `device connected ${device.deviceName} (${deviceId})`, 'success');
|
|
1528
|
+
emitRemoteEvent('RemoteDeviceConnected', device);
|
|
1529
|
+
return device;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function detachSocket(socket, reason = 'socket-closed') {
|
|
1533
|
+
const deviceId = sockets.get(socket);
|
|
1534
|
+
sockets.delete(socket);
|
|
1535
|
+
if (!deviceId) {
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const device = devices.get(deviceId);
|
|
1540
|
+
if (!device || device.socket !== socket) {
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
device.connected = false;
|
|
1545
|
+
device.socket = null;
|
|
1546
|
+
device.disconnectedAt = new Date().toISOString();
|
|
1547
|
+
device.lastDisconnectReason = reason;
|
|
1548
|
+
if (device.activeLiveStream) {
|
|
1549
|
+
device.activeLiveStream.active = false;
|
|
1550
|
+
device.activeLiveStream.stoppedAt = device.disconnectedAt;
|
|
1551
|
+
device.activeLiveStream.stopReason = reason;
|
|
1552
|
+
}
|
|
1553
|
+
failAllPendingTasks(device, 'device-disconnected');
|
|
1554
|
+
logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
|
|
1555
|
+
emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function rememberDeviceTask(device, task) {
|
|
1559
|
+
if (!device || !task) {
|
|
1560
|
+
return null;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const existingIndex = device.recentTasks.findIndex(item =>
|
|
1564
|
+
item.taskId === task.taskId || item.commandId === task.commandId);
|
|
1565
|
+
if (existingIndex >= 0) {
|
|
1566
|
+
device.recentTasks.splice(existingIndex, 1);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
device.recentTasks.unshift(task);
|
|
1570
|
+
if (device.recentTasks.length > RECENT_TASK_LIMIT) {
|
|
1571
|
+
device.recentTasks.length = RECENT_TASK_LIMIT;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
device.latestTask = task;
|
|
1575
|
+
if (task.commandId) {
|
|
1576
|
+
device.pendingTaskCommands.set(task.commandId, task);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
return task;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function trimTaskBatches() {
|
|
1583
|
+
const ordered = [...taskBatches.values()]
|
|
1584
|
+
.sort((left, right) => String(right.requestedAt || '').localeCompare(String(left.requestedAt || '')));
|
|
1585
|
+
for (const batch of ordered.slice(RECENT_TASK_BATCH_LIMIT)) {
|
|
1586
|
+
taskBatches.delete(batch.batchId);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function beginTaskBatch(deviceIds, options = {}) {
|
|
1591
|
+
const targets = [...new Set((deviceIds || [])
|
|
1592
|
+
.map(deviceId => safeString(deviceId, 128))
|
|
1593
|
+
.filter(Boolean))]
|
|
1594
|
+
.slice(0, 500);
|
|
1595
|
+
const now = new Date().toISOString();
|
|
1596
|
+
const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
|
|
1597
|
+
const batchId = safeString(options.batchId, 128) || crypto.randomUUID();
|
|
1598
|
+
const title = safeString(options.title, 120)
|
|
1599
|
+
|| safeString(instruction.split(/\r?\n/)[0], 120)
|
|
1600
|
+
|| 'Remote task batch';
|
|
1601
|
+
const batch = {
|
|
1602
|
+
batchId,
|
|
1603
|
+
title,
|
|
1604
|
+
instructionPreview: safeText(instruction, 320),
|
|
1605
|
+
approvalLevel: normalizeApprovalLevel(options.approvalLevel),
|
|
1606
|
+
status: targets.length > 0 ? 'running' : 'failed',
|
|
1607
|
+
requestedAt: now,
|
|
1608
|
+
updatedAt: now,
|
|
1609
|
+
total: targets.length,
|
|
1610
|
+
queued: 0,
|
|
1611
|
+
pending: 0,
|
|
1612
|
+
completed: 0,
|
|
1613
|
+
failed: targets.length > 0 ? 0 : 1,
|
|
1614
|
+
timedOut: 0,
|
|
1615
|
+
results: targets.map(deviceId => ({
|
|
1616
|
+
deviceId,
|
|
1617
|
+
ok: false,
|
|
1618
|
+
status: 'targeted',
|
|
1619
|
+
error: '',
|
|
1620
|
+
commandId: '',
|
|
1621
|
+
taskId: '',
|
|
1622
|
+
approvalLevel: normalizeApprovalLevel(options.approvalLevel),
|
|
1623
|
+
updatedAt: now
|
|
1624
|
+
}))
|
|
1625
|
+
};
|
|
1626
|
+
|
|
1627
|
+
taskBatches.set(batchId, batch);
|
|
1628
|
+
trimTaskBatches();
|
|
1629
|
+
return batch;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function getTaskBatchItem(batch, deviceId) {
|
|
1633
|
+
if (!batch) {
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const id = safeString(deviceId, 128);
|
|
1638
|
+
if (!id) {
|
|
1639
|
+
return null;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
let item = batch.results.find(result => result.deviceId === id);
|
|
1643
|
+
if (!item) {
|
|
1644
|
+
item = {
|
|
1645
|
+
deviceId: id,
|
|
1646
|
+
ok: false,
|
|
1647
|
+
status: 'targeted',
|
|
1648
|
+
error: '',
|
|
1649
|
+
commandId: '',
|
|
1650
|
+
taskId: '',
|
|
1651
|
+
approvalLevel: batch.approvalLevel,
|
|
1652
|
+
updatedAt: new Date().toISOString()
|
|
1653
|
+
};
|
|
1654
|
+
batch.results.push(item);
|
|
1655
|
+
batch.total = batch.results.length;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
return item;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function recalculateTaskBatch(batch) {
|
|
1662
|
+
if (!batch) {
|
|
1663
|
+
return null;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
1667
|
+
batch.total = results.length;
|
|
1668
|
+
batch.queued = results.filter(item => item.ok === true).length;
|
|
1669
|
+
batch.completed = results.filter(item => item.status === 'completed').length;
|
|
1670
|
+
batch.failed = results.filter(item => item.status === 'failed' || (item.ok === false && !!item.error)).length;
|
|
1671
|
+
batch.timedOut = results.filter(item => item.error === 'task-timeout').length;
|
|
1672
|
+
batch.pending = results.filter(item =>
|
|
1673
|
+
item.ok === true
|
|
1674
|
+
&& item.status !== 'completed'
|
|
1675
|
+
&& item.status !== 'failed').length;
|
|
1676
|
+
batch.updatedAt = new Date().toISOString();
|
|
1677
|
+
|
|
1678
|
+
if (batch.total === 0) {
|
|
1679
|
+
batch.status = 'failed';
|
|
1680
|
+
} else if (batch.completed + batch.failed >= batch.total) {
|
|
1681
|
+
batch.status = batch.failed > 0 ? 'completed-with-failures' : 'completed';
|
|
1682
|
+
} else if (batch.queued > 0) {
|
|
1683
|
+
batch.status = 'running';
|
|
1684
|
+
} else {
|
|
1685
|
+
batch.status = 'failed';
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
return batch;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function syncTaskBatchFromTask(device, task) {
|
|
1692
|
+
const batchId = safeString(task?.batchId, 128);
|
|
1693
|
+
if (!batchId) {
|
|
1694
|
+
return null;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
const batch = taskBatches.get(batchId);
|
|
1698
|
+
if (!batch) {
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
const item = getTaskBatchItem(batch, device?.deviceId);
|
|
1703
|
+
if (!item) {
|
|
1704
|
+
return null;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
item.ok = true;
|
|
1708
|
+
item.status = safeString(task.status, 40) || 'queued';
|
|
1709
|
+
item.error = safeString(task.error, 500);
|
|
1710
|
+
item.commandId = safeString(task.commandId, 128);
|
|
1711
|
+
item.taskId = safeString(task.taskId, 128);
|
|
1712
|
+
item.approvalLevel = normalizeApprovalLevel(task.approvalLevel || batch.approvalLevel);
|
|
1713
|
+
item.updatedAt = task.updatedAt || task.completedAt || task.sentAt || new Date().toISOString();
|
|
1714
|
+
return recalculateTaskBatch(batch);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function syncTaskBatchDispatchFailure(batchId, deviceId, error, extra = {}) {
|
|
1718
|
+
const batch = taskBatches.get(safeString(batchId, 128));
|
|
1719
|
+
if (!batch) {
|
|
1720
|
+
return null;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const item = getTaskBatchItem(batch, deviceId);
|
|
1724
|
+
if (!item) {
|
|
1725
|
+
return null;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
item.ok = false;
|
|
1729
|
+
item.status = 'failed';
|
|
1730
|
+
item.error = safeString(error, 500) || 'task-dispatch-failed';
|
|
1731
|
+
item.commandId = safeString(extra.commandId, 128);
|
|
1732
|
+
item.taskId = safeString(extra.taskId, 128);
|
|
1733
|
+
item.approvalLevel = normalizeApprovalLevel(extra.approvalLevel || batch.approvalLevel);
|
|
1734
|
+
item.updatedAt = new Date().toISOString();
|
|
1735
|
+
return recalculateTaskBatch(batch);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
function clearTaskTimeout(device, commandId) {
|
|
1739
|
+
const key = safeString(commandId, 128);
|
|
1740
|
+
if (!device?.pendingTaskTimers || !key) {
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
const timer = device.pendingTaskTimers.get(key);
|
|
1745
|
+
if (timer) {
|
|
1746
|
+
clearTimeout(timer);
|
|
1747
|
+
}
|
|
1748
|
+
device.pendingTaskTimers.delete(key);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function failPendingTask(device, commandId, error = 'task-timeout') {
|
|
1752
|
+
const key = safeString(commandId, 128);
|
|
1753
|
+
if (!device?.pendingTaskCommands || !key) {
|
|
1754
|
+
return null;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
const task = device.pendingTaskCommands.get(key);
|
|
1758
|
+
if (!task) {
|
|
1759
|
+
clearTaskTimeout(device, key);
|
|
1760
|
+
return null;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const now = new Date().toISOString();
|
|
1764
|
+
clearTaskTimeout(device, key);
|
|
1765
|
+
task.status = 'failed';
|
|
1766
|
+
task.updatedAt = now;
|
|
1767
|
+
task.completedAt = now;
|
|
1768
|
+
task.error = safeString(error, 500) || 'task-timeout';
|
|
1769
|
+
task.resultSummary = task.error;
|
|
1770
|
+
task.resultKind = task.resultKind || 'agent-task';
|
|
1771
|
+
device.latestTask = task;
|
|
1772
|
+
device.pendingTaskCommands.delete(key);
|
|
1773
|
+
device.counters.taskResultsReceived += 1;
|
|
1774
|
+
device.counters.taskResultsFailed += 1;
|
|
1775
|
+
if (task.error === 'task-timeout') {
|
|
1776
|
+
device.counters.taskResultsTimedOut += 1;
|
|
1777
|
+
}
|
|
1778
|
+
syncTaskBatchFromTask(device, task);
|
|
1779
|
+
emitRemoteEvent('RemoteTaskResult', device, {
|
|
1780
|
+
commandId: key,
|
|
1781
|
+
taskId: task.taskId,
|
|
1782
|
+
status: task.status,
|
|
1783
|
+
error: task.error
|
|
1784
|
+
});
|
|
1785
|
+
return task;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function failAllPendingTasks(device, error = 'device-disconnected') {
|
|
1789
|
+
if (!device?.pendingTaskCommands) {
|
|
1790
|
+
return 0;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
const commandIds = [...device.pendingTaskCommands.keys()];
|
|
1794
|
+
let failed = 0;
|
|
1795
|
+
for (const commandId of commandIds) {
|
|
1796
|
+
if (failPendingTask(device, commandId, error)) {
|
|
1797
|
+
failed += 1;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
return failed;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function scheduleTaskTimeout(device, task) {
|
|
1804
|
+
const commandId = safeString(task?.commandId, 128);
|
|
1805
|
+
if (!device?.pendingTaskTimers || !commandId) {
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
clearTaskTimeout(device, commandId);
|
|
1810
|
+
const timer = setTimeout(() => {
|
|
1811
|
+
failPendingTask(device, commandId, 'task-timeout');
|
|
1812
|
+
}, taskTimeoutMs);
|
|
1813
|
+
timer.unref?.();
|
|
1814
|
+
device.pendingTaskTimers.set(commandId, timer);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function summarizeTaskResult(result) {
|
|
1818
|
+
if (result && typeof result === 'object') {
|
|
1819
|
+
return safeText(
|
|
1820
|
+
result.summary
|
|
1821
|
+
|| result.message
|
|
1822
|
+
|| result.output
|
|
1823
|
+
|| result.result
|
|
1824
|
+
|| JSON.stringify(result),
|
|
1825
|
+
MAX_AGENT_TASK_RESULT_CHARS);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
return safeText(result ?? '', MAX_AGENT_TASK_RESULT_CHARS);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
function applyTaskResult(device, commandId, result, error) {
|
|
1832
|
+
if (!device || !commandId) {
|
|
1833
|
+
return null;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const resultTaskId = result && typeof result === 'object'
|
|
1837
|
+
? safeString(result.taskId, 128)
|
|
1838
|
+
: '';
|
|
1839
|
+
const task = resultTaskId
|
|
1840
|
+
? device.recentTasks.find(item => item.taskId === resultTaskId)
|
|
1841
|
+
: device.pendingTaskCommands.get(commandId);
|
|
1842
|
+
if (!task) {
|
|
1843
|
+
return null;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
if (device.pendingTaskCommands.get(commandId) !== task) {
|
|
1847
|
+
return null;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
const now = device.lastSeenAt || new Date().toISOString();
|
|
1851
|
+
const status = error
|
|
1852
|
+
? 'failed'
|
|
1853
|
+
: safeString(result?.status, 40) || 'completed';
|
|
1854
|
+
clearTaskTimeout(device, commandId);
|
|
1855
|
+
task.status = status;
|
|
1856
|
+
task.updatedAt = now;
|
|
1857
|
+
task.completedAt = safeString(result?.completedAt, 80) || now;
|
|
1858
|
+
task.error = error;
|
|
1859
|
+
task.resultSummary = error || summarizeTaskResult(result);
|
|
1860
|
+
task.resultKind = safeString(result?.kind || result?.mode || 'agent-task', 80);
|
|
1861
|
+
task.resultModel = safeString(result?.model || result?.aiModel || '', 120);
|
|
1862
|
+
task.resultResponseId = safeString(result?.responseId || result?.id || '', 160);
|
|
1863
|
+
device.latestTask = task;
|
|
1864
|
+
device.pendingTaskCommands.delete(commandId);
|
|
1865
|
+
device.counters.taskResultsReceived += 1;
|
|
1866
|
+
if (error) {
|
|
1867
|
+
device.counters.taskResultsFailed += 1;
|
|
1868
|
+
}
|
|
1869
|
+
syncTaskBatchFromTask(device, task);
|
|
1870
|
+
|
|
1871
|
+
return task;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
function normalizeFrameByteLength(framePayload, frameData = '') {
|
|
1875
|
+
if (Buffer.isBuffer(framePayload)) {
|
|
1876
|
+
return framePayload.length;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
return Math.floor(String(frameData || '').length * 3 / 4);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function buildFrameDataUrl(framePayload, frameData, mimeType) {
|
|
1883
|
+
if (Buffer.isBuffer(framePayload)) {
|
|
1884
|
+
return `data:${mimeType};base64,${framePayload.toString('base64')}`;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
return String(frameData || '').startsWith('data:')
|
|
1888
|
+
? String(frameData || '')
|
|
1889
|
+
: `data:${mimeType};base64,${frameData}`;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function buildFramePayloadBuffer(framePayload, frameData) {
|
|
1893
|
+
if (Buffer.isBuffer(framePayload)) {
|
|
1894
|
+
return Buffer.from(framePayload);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
const raw = String(frameData || '');
|
|
1898
|
+
const commaIndex = raw.indexOf(',');
|
|
1899
|
+
const base64 = raw.startsWith('data:') && commaIndex >= 0
|
|
1900
|
+
? raw.slice(commaIndex + 1)
|
|
1901
|
+
: raw;
|
|
1902
|
+
return Buffer.from(base64, 'base64');
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function buildFrameContentHash(message, payload) {
|
|
1906
|
+
const provided = safeString(message.contentHash || message.frameHash || message.hash, 96)
|
|
1907
|
+
.toLowerCase()
|
|
1908
|
+
.replace(/[^a-z0-9:_-]/g, '')
|
|
1909
|
+
.slice(0, 64);
|
|
1910
|
+
if (provided) {
|
|
1911
|
+
return provided;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
if (!Buffer.isBuffer(payload) || payload.length === 0) {
|
|
1915
|
+
return '';
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 16);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function readFrameCaptureMs(message) {
|
|
1922
|
+
const value = Number(message.captureMs);
|
|
1923
|
+
return Number.isFinite(value) && value >= 0
|
|
1924
|
+
? Math.round(value)
|
|
1925
|
+
: 0;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function readFrameDroppedByAgent(message) {
|
|
1929
|
+
const value = Number(message.droppedByAgent ?? message.agentDrops ?? message.drops);
|
|
1930
|
+
return Number.isFinite(value) && value >= 0
|
|
1931
|
+
? Math.round(value)
|
|
1932
|
+
: 0;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function readFrameDirtyRectCount(message) {
|
|
1936
|
+
if (Array.isArray(message.dirtyRects)) {
|
|
1937
|
+
return message.dirtyRects.length;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const value = Number(message.dirtyRectCount ?? message.dirtyRegionCount ?? message.changedRegionCount);
|
|
1941
|
+
return Number.isFinite(value) && value >= 0
|
|
1942
|
+
? Math.round(value)
|
|
1943
|
+
: 0;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function readFrameDirtyPixelRatio(message) {
|
|
1947
|
+
const value = Number(message.dirtyPixelRatio ?? message.dirtyRatio ?? message.dirtyAreaRatio ?? message.changedPixelRatio);
|
|
1948
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1949
|
+
return 0;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
if (value <= 1) {
|
|
1953
|
+
return value;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
if (value <= 100) {
|
|
1957
|
+
return value / 100;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
return 1;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function readFrameDeltaEncoded(message) {
|
|
1964
|
+
const value = message.deltaEncoded ?? message.isDeltaEncoded ?? message.deltaFrame;
|
|
1965
|
+
if (typeof value === 'boolean') {
|
|
1966
|
+
return value;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
if (typeof value === 'number') {
|
|
1970
|
+
return value > 0;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
return /^(1|true|yes|on|delta)$/i.test(String(value || '').trim());
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function computeSameContentStreak(previousFrame, contentHash) {
|
|
1977
|
+
if (!contentHash || !previousFrame?.contentHash || previousFrame.contentHash !== contentHash) {
|
|
1978
|
+
return 0;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
return clampNumber(Number(previousFrame.sameContentStreak || 0) + 1, 0, 1000000, 1);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
function applyThumbnailFrame(device, message, framePayload, transport = 'json-base64') {
|
|
1985
|
+
const frameData = Buffer.isBuffer(framePayload)
|
|
1986
|
+
? ''
|
|
1987
|
+
: safeString(framePayload, MAX_THUMBNAIL_BASE64_CHARS + 1);
|
|
1988
|
+
const frameSeq = Number(message.frameSeq);
|
|
1989
|
+
const byteLength = normalizeFrameByteLength(framePayload, frameData);
|
|
1990
|
+
if ((!Buffer.isBuffer(framePayload) && !frameData)
|
|
1991
|
+
|| byteLength > MAX_THUMBNAIL_BINARY_BYTES
|
|
1992
|
+
|| !Number.isFinite(frameSeq)) {
|
|
1993
|
+
device.counters.thumbnailFramesDropped += 1;
|
|
1994
|
+
emitRemoteEvent('RemoteFrameDropped', device, {
|
|
1995
|
+
reason: 'invalid-thumbnail-frame',
|
|
1996
|
+
frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
|
|
1997
|
+
transport
|
|
1998
|
+
});
|
|
1999
|
+
return false;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const mimeType = safeString(message.mimeType || message.format || 'image/jpeg', 80) || 'image/jpeg';
|
|
2003
|
+
const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
|
|
2004
|
+
const payload = buildFramePayloadBuffer(framePayload, frameData);
|
|
2005
|
+
const contentHash = buildFrameContentHash(message, payload);
|
|
2006
|
+
const sameContentStreak = computeSameContentStreak(device.latestThumbnail, contentHash);
|
|
2007
|
+
const droppedByAgent = readFrameDroppedByAgent(message);
|
|
2008
|
+
const dirtyRectCount = readFrameDirtyRectCount(message);
|
|
2009
|
+
const dirtyPixelRatio = readFrameDirtyPixelRatio(message);
|
|
2010
|
+
const codec = safeString(message.codec || message.encoder || message.encoding, 80);
|
|
2011
|
+
const deltaEncoded = readFrameDeltaEncoded(message);
|
|
2012
|
+
device.latestThumbnail = {
|
|
2013
|
+
streamId: safeString(message.streamId, 128) || 'thumbnail',
|
|
2014
|
+
frameSeq,
|
|
2015
|
+
commandId: safeString(message.commandId, 128),
|
|
2016
|
+
width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
|
|
2017
|
+
height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
|
|
2018
|
+
mimeType,
|
|
2019
|
+
format: mimeType,
|
|
2020
|
+
capturedAt,
|
|
2021
|
+
receivedAt: device.lastSeenAt,
|
|
2022
|
+
byteLength,
|
|
2023
|
+
transport,
|
|
2024
|
+
contentHash,
|
|
2025
|
+
captureMs: readFrameCaptureMs(message),
|
|
2026
|
+
sameContentStreak,
|
|
2027
|
+
droppedByAgent,
|
|
2028
|
+
dirtyRectCount,
|
|
2029
|
+
dirtyPixelRatio,
|
|
2030
|
+
codec,
|
|
2031
|
+
deltaEncoded,
|
|
2032
|
+
payload,
|
|
2033
|
+
accessToken: createFrameAccessToken()
|
|
2034
|
+
};
|
|
2035
|
+
rememberRecentFramePayload(device, 'thumbnail', device.latestThumbnail);
|
|
2036
|
+
device.counters.thumbnailFramesReceived += 1;
|
|
2037
|
+
emitRemoteEvent('RemoteFrameReceived', device, {
|
|
2038
|
+
streamId: device.latestThumbnail.streamId,
|
|
2039
|
+
frameSeq,
|
|
2040
|
+
width: device.latestThumbnail.width,
|
|
2041
|
+
height: device.latestThumbnail.height,
|
|
2042
|
+
transport,
|
|
2043
|
+
contentHash,
|
|
2044
|
+
sameContentStreak,
|
|
2045
|
+
droppedByAgent,
|
|
2046
|
+
dirtyRectCount,
|
|
2047
|
+
dirtyPixelRatio,
|
|
2048
|
+
codec,
|
|
2049
|
+
deltaEncoded
|
|
2050
|
+
});
|
|
2051
|
+
return true;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function applyLiveFrame(device, message, framePayload, transport = 'json-base64') {
|
|
2055
|
+
const frameData = Buffer.isBuffer(framePayload)
|
|
2056
|
+
? ''
|
|
2057
|
+
: safeString(framePayload, MAX_STREAM_BASE64_CHARS + 1);
|
|
2058
|
+
const frameSeq = Number(message.frameSeq);
|
|
2059
|
+
const streamId = safeString(message.streamId, 128) || 'live';
|
|
2060
|
+
if (!device.activeLiveStream?.active || device.activeLiveStream.streamId !== streamId) {
|
|
2061
|
+
device.counters.liveFramesDropped += 1;
|
|
2062
|
+
emitRemoteEvent('RemoteFrameDropped', device, {
|
|
2063
|
+
reason: 'stale-live-stream-frame',
|
|
2064
|
+
streamId,
|
|
2065
|
+
frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
|
|
2066
|
+
transport
|
|
2067
|
+
});
|
|
2068
|
+
return false;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
const byteLength = normalizeFrameByteLength(framePayload, frameData);
|
|
2072
|
+
if ((!Buffer.isBuffer(framePayload) && !frameData)
|
|
2073
|
+
|| byteLength > MAX_STREAM_BINARY_BYTES
|
|
2074
|
+
|| !Number.isFinite(frameSeq)) {
|
|
2075
|
+
device.counters.liveFramesDropped += 1;
|
|
2076
|
+
emitRemoteEvent('RemoteFrameDropped', device, {
|
|
2077
|
+
reason: 'invalid-live-frame',
|
|
2078
|
+
streamId,
|
|
2079
|
+
frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
|
|
2080
|
+
transport
|
|
2081
|
+
});
|
|
2082
|
+
return false;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
const mimeType = safeString(message.mimeType || message.format || 'image/jpeg', 80) || 'image/jpeg';
|
|
2086
|
+
const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
|
|
2087
|
+
const payload = buildFramePayloadBuffer(framePayload, frameData);
|
|
2088
|
+
const contentHash = buildFrameContentHash(message, payload);
|
|
2089
|
+
const sameContentStreak = computeSameContentStreak(device.latestLiveFrame, contentHash);
|
|
2090
|
+
const droppedByAgent = readFrameDroppedByAgent(message);
|
|
2091
|
+
const dirtyRectCount = readFrameDirtyRectCount(message);
|
|
2092
|
+
const dirtyPixelRatio = readFrameDirtyPixelRatio(message);
|
|
2093
|
+
const codec = safeString(message.codec || message.encoder || message.encoding, 80);
|
|
2094
|
+
const deltaEncoded = readFrameDeltaEncoded(message);
|
|
2095
|
+
device.latestLiveFrame = {
|
|
2096
|
+
streamId,
|
|
2097
|
+
frameSeq,
|
|
2098
|
+
commandId: safeString(message.commandId, 128),
|
|
2099
|
+
width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
|
|
2100
|
+
height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
|
|
2101
|
+
mimeType,
|
|
2102
|
+
format: mimeType,
|
|
2103
|
+
mode: safeString(message.mode || device.activeLiveStream.mode || 'remote-fast', 80),
|
|
2104
|
+
fps: Number.isFinite(Number(message.fps)) ? Number(message.fps) : device.activeLiveStream.fps,
|
|
2105
|
+
capturedAt,
|
|
2106
|
+
receivedAt: device.lastSeenAt,
|
|
2107
|
+
byteLength,
|
|
2108
|
+
transport,
|
|
2109
|
+
contentHash,
|
|
2110
|
+
captureMs: readFrameCaptureMs(message),
|
|
2111
|
+
sameContentStreak,
|
|
2112
|
+
droppedByAgent,
|
|
2113
|
+
dirtyRectCount,
|
|
2114
|
+
dirtyPixelRatio,
|
|
2115
|
+
codec,
|
|
2116
|
+
deltaEncoded,
|
|
2117
|
+
payload,
|
|
2118
|
+
accessToken: createFrameAccessToken()
|
|
2119
|
+
};
|
|
2120
|
+
rememberRecentFramePayload(device, 'live', device.latestLiveFrame);
|
|
2121
|
+
emitFrame({
|
|
2122
|
+
kind: 'live',
|
|
2123
|
+
deviceId: device.deviceId,
|
|
2124
|
+
frame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', { includeDataUrl: false }),
|
|
2125
|
+
payload: device.latestLiveFrame.payload,
|
|
2126
|
+
mimeType: device.latestLiveFrame.mimeType,
|
|
2127
|
+
byteLength: device.latestLiveFrame.byteLength
|
|
2128
|
+
});
|
|
2129
|
+
device.activeLiveStream.lastFrameAt = device.lastSeenAt;
|
|
2130
|
+
device.activeLiveStream.lastFrameSeq = frameSeq;
|
|
2131
|
+
device.activeLiveStream.framesReceived = (device.activeLiveStream.framesReceived || 0) + 1;
|
|
2132
|
+
device.counters.liveFramesReceived += 1;
|
|
2133
|
+
emitRemoteEvent('RemoteFrameReceived', device, {
|
|
2134
|
+
streamId,
|
|
2135
|
+
frameSeq,
|
|
2136
|
+
width: device.latestLiveFrame.width,
|
|
2137
|
+
height: device.latestLiveFrame.height,
|
|
2138
|
+
mode: device.latestLiveFrame.mode,
|
|
2139
|
+
transport,
|
|
2140
|
+
contentHash,
|
|
2141
|
+
sameContentStreak,
|
|
2142
|
+
droppedByAgent,
|
|
2143
|
+
dirtyRectCount,
|
|
2144
|
+
dirtyPixelRatio,
|
|
2145
|
+
codec,
|
|
2146
|
+
deltaEncoded
|
|
2147
|
+
});
|
|
2148
|
+
return true;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function handleAgentBinaryFrame(socket, state, header, framePayload) {
|
|
2152
|
+
if (!state.authenticated || !state.device) {
|
|
2153
|
+
writeJsonLine(socket, { type: 'error', error: 'hello-required' });
|
|
2154
|
+
socket.destroy();
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
const device = state.device;
|
|
2159
|
+
device.counters.messagesReceived += 1;
|
|
2160
|
+
device.lastSeenAt = new Date().toISOString();
|
|
2161
|
+
|
|
2162
|
+
const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
|
|
2163
|
+
if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
|
|
2164
|
+
applyThumbnailFrame(device, header, framePayload, 'binary');
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
|
|
2169
|
+
applyLiveFrame(device, header, framePayload, 'binary');
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
emitRemoteEvent('RemoteAgentMessageIgnored', device, {
|
|
2174
|
+
messageType: safeString(header.type, 80),
|
|
2175
|
+
frameKind
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
function handleAgentMessage(socket, state, message) {
|
|
2180
|
+
if (!message || typeof message !== 'object') {
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
if (!state.authenticated) {
|
|
2185
|
+
if (message.type !== 'hello') {
|
|
2186
|
+
writeJsonLine(socket, { type: 'error', error: 'hello-required' });
|
|
2187
|
+
socket.destroy();
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
if (!timingSafeStringEqual(message.pairToken, pairToken)) {
|
|
2192
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-pair-token' });
|
|
2193
|
+
socket.destroy();
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
state.authenticated = true;
|
|
2198
|
+
state.device = attachDevice(socket, message);
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
const device = state.device;
|
|
2203
|
+
if (!device) {
|
|
2204
|
+
socket.destroy();
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
device.counters.messagesReceived += 1;
|
|
2209
|
+
device.lastSeenAt = new Date().toISOString();
|
|
2210
|
+
|
|
2211
|
+
switch (message.type) {
|
|
2212
|
+
case 'heartbeat':
|
|
2213
|
+
case 'status':
|
|
2214
|
+
device.status = typeof message.status === 'object' && message.status
|
|
2215
|
+
? { ...message.status }
|
|
2216
|
+
: {};
|
|
2217
|
+
device.lastStatusAt = device.lastSeenAt;
|
|
2218
|
+
device.counters.statusReceived += 1;
|
|
2219
|
+
emitRemoteEvent('RemoteDeviceStatus', device);
|
|
2220
|
+
break;
|
|
2221
|
+
case 'command.result':
|
|
2222
|
+
device.counters.commandResultsReceived += 1;
|
|
2223
|
+
{
|
|
2224
|
+
const commandId = safeString(message.commandId, 128);
|
|
2225
|
+
const error = safeString(message.error, 500);
|
|
2226
|
+
const task = applyTaskResult(device, commandId, message.result ?? null, error);
|
|
2227
|
+
if (task) {
|
|
2228
|
+
emitRemoteEvent('RemoteTaskResult', device, {
|
|
2229
|
+
commandId,
|
|
2230
|
+
taskId: task.taskId,
|
|
2231
|
+
status: task.status,
|
|
2232
|
+
error: task.error
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
emitRemoteEvent('RemoteCommandResult', device, {
|
|
2237
|
+
commandId: safeString(message.commandId, 128),
|
|
2238
|
+
result: message.result ?? null,
|
|
2239
|
+
error: safeString(message.error, 500)
|
|
2240
|
+
});
|
|
2241
|
+
break;
|
|
2242
|
+
case 'thumbnail.frame': {
|
|
2243
|
+
applyThumbnailFrame(device, message, message.data, 'json-base64');
|
|
2244
|
+
break;
|
|
2245
|
+
}
|
|
2246
|
+
case 'stream.frame': {
|
|
2247
|
+
applyLiveFrame(device, message, message.data, 'json-base64');
|
|
2248
|
+
break;
|
|
2249
|
+
}
|
|
2250
|
+
default:
|
|
2251
|
+
emitRemoteEvent('RemoteAgentMessageIgnored', device, {
|
|
2252
|
+
messageType: safeString(message.type, 80)
|
|
2253
|
+
});
|
|
2254
|
+
break;
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function handleWebSocketAgentSocket(socket) {
|
|
2259
|
+
allSockets.add(socket);
|
|
2260
|
+
socket.setNoDelay(true);
|
|
2261
|
+
socket.setKeepAlive(true, heartbeatMs);
|
|
2262
|
+
|
|
2263
|
+
const state = {
|
|
2264
|
+
authenticated: false,
|
|
2265
|
+
device: null
|
|
2266
|
+
};
|
|
2267
|
+
|
|
2268
|
+
const helloTimer = setTimeout(() => {
|
|
2269
|
+
if (!state.authenticated) {
|
|
2270
|
+
writeJsonLine(socket, { type: 'error', error: 'hello-timeout' });
|
|
2271
|
+
socket.destroy();
|
|
2272
|
+
}
|
|
2273
|
+
}, 10000);
|
|
2274
|
+
|
|
2275
|
+
socket.onTextMessage = text => {
|
|
2276
|
+
try {
|
|
2277
|
+
handleAgentMessage(socket, state, parseJsonLine(String(text || '')));
|
|
2278
|
+
} catch (err) {
|
|
2279
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
|
|
2280
|
+
logWarn('remote', `invalid websocket agent message: ${err?.message || err}`);
|
|
2281
|
+
}
|
|
2282
|
+
};
|
|
2283
|
+
|
|
2284
|
+
socket.onBinaryMessage = payload => {
|
|
2285
|
+
try {
|
|
2286
|
+
const packet = parseRemoteHubWebSocketBinaryFrame(payload);
|
|
2287
|
+
if (!packet?.header || !Buffer.isBuffer(packet.payload)) {
|
|
2288
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
const frameKind = safeString(packet.header.frameKind || packet.header.kind || packet.header.frameType, 40).toLowerCase();
|
|
2293
|
+
const maxBytes = frameKind === 'thumbnail'
|
|
2294
|
+
? MAX_THUMBNAIL_BINARY_BYTES
|
|
2295
|
+
: MAX_STREAM_BINARY_BYTES;
|
|
2296
|
+
const byteLength = Number(packet.header.byteLength ?? packet.header.payloadBytes ?? packet.payload.length);
|
|
2297
|
+
if (!Number.isFinite(byteLength)
|
|
2298
|
+
|| byteLength < 1
|
|
2299
|
+
|| byteLength > maxBytes
|
|
2300
|
+
|| byteLength !== packet.payload.length) {
|
|
2301
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
|
|
2302
|
+
logWarn('remote', 'invalid websocket binary frame from agent.');
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
handleAgentBinaryFrame(socket, state, packet.header, packet.payload);
|
|
2307
|
+
} catch (err) {
|
|
2308
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
|
|
2309
|
+
logWarn('remote', `invalid websocket binary frame: ${err?.message || err}`);
|
|
2310
|
+
}
|
|
2311
|
+
};
|
|
2312
|
+
|
|
2313
|
+
const detach = reason => {
|
|
2314
|
+
allSockets.delete(socket);
|
|
2315
|
+
clearTimeout(helloTimer);
|
|
2316
|
+
detachSocket(socket, reason);
|
|
2317
|
+
};
|
|
2318
|
+
|
|
2319
|
+
socket.onCloseMessage = reason => detach(reason || 'websocket-closed');
|
|
2320
|
+
socket.socket.on('close', () => detach('websocket-closed'));
|
|
2321
|
+
socket.socket.on('error', err => detach(err?.message || 'websocket-error'));
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
function handleWebSocketUpgradeSocket(socket, firstChunk) {
|
|
2325
|
+
let upgradeBuffer = Buffer.isBuffer(firstChunk) ? firstChunk : Buffer.from(firstChunk);
|
|
2326
|
+
let onUpgradeData = null;
|
|
2327
|
+
const fail = (status, message) => {
|
|
2328
|
+
try {
|
|
2329
|
+
socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
|
|
2330
|
+
} catch {
|
|
2331
|
+
// Ignore write failures while rejecting the upgrade.
|
|
2332
|
+
}
|
|
2333
|
+
socket.destroy();
|
|
2334
|
+
};
|
|
2335
|
+
|
|
2336
|
+
const completeUpgrade = () => {
|
|
2337
|
+
const request = parseRemoteHubWebSocketUpgrade(upgradeBuffer);
|
|
2338
|
+
if (!request) {
|
|
2339
|
+
return false;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
const upgrade = String(request.headers.upgrade || '').toLowerCase();
|
|
2343
|
+
const connection = String(request.headers.connection || '').toLowerCase();
|
|
2344
|
+
const key = safeString(request.headers['sec-websocket-key'], 256);
|
|
2345
|
+
const pathName = safeString(String(request.path || '').split('?')[0], 128) || '/';
|
|
2346
|
+
const allowedPath = pathName === '/'
|
|
2347
|
+
|| pathName === '/remote-agent'
|
|
2348
|
+
|| pathName === '/api/remote/agent/ws';
|
|
2349
|
+
if (request.method !== 'GET'
|
|
2350
|
+
|| upgrade !== 'websocket'
|
|
2351
|
+
|| !connection.includes('upgrade')
|
|
2352
|
+
|| !key
|
|
2353
|
+
|| !allowedPath) {
|
|
2354
|
+
fail(400, 'Bad Request');
|
|
2355
|
+
return true;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
const acceptKey = buildRemoteHubWebSocketAcceptKey(key);
|
|
2359
|
+
socket.write([
|
|
2360
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
2361
|
+
'Upgrade: websocket',
|
|
2362
|
+
'Connection: Upgrade',
|
|
2363
|
+
`Sec-WebSocket-Accept: ${acceptKey}`,
|
|
2364
|
+
'\r\n'
|
|
2365
|
+
].join('\r\n'));
|
|
2366
|
+
|
|
2367
|
+
if (onUpgradeData) {
|
|
2368
|
+
socket.removeListener('data', onUpgradeData);
|
|
2369
|
+
}
|
|
2370
|
+
const wsSocket = new RemoteHubWebSocketAgentSocket(socket);
|
|
2371
|
+
const onFrameData = chunk => wsSocket.handleData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2372
|
+
socket.on('data', onFrameData);
|
|
2373
|
+
socket.once('close', () => {
|
|
2374
|
+
socket.removeListener('data', onFrameData);
|
|
2375
|
+
});
|
|
2376
|
+
handleWebSocketAgentSocket(wsSocket);
|
|
2377
|
+
if (request.head.length > 0) {
|
|
2378
|
+
wsSocket.handleData(request.head);
|
|
2379
|
+
}
|
|
2380
|
+
return true;
|
|
2381
|
+
};
|
|
2382
|
+
|
|
2383
|
+
onUpgradeData = chunk => {
|
|
2384
|
+
upgradeBuffer = Buffer.concat([upgradeBuffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
|
2385
|
+
if (upgradeBuffer.length > 16 * 1024) {
|
|
2386
|
+
fail(431, 'Request Header Fields Too Large');
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
completeUpgrade();
|
|
2390
|
+
};
|
|
2391
|
+
|
|
2392
|
+
if (!completeUpgrade()) {
|
|
2393
|
+
socket.on('data', onUpgradeData);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
function handleTcpAgentSocket(socket, firstChunk = null) {
|
|
2398
|
+
allSockets.add(socket);
|
|
2399
|
+
socket.setNoDelay(true);
|
|
2400
|
+
socket.setKeepAlive(true, heartbeatMs);
|
|
2401
|
+
|
|
2402
|
+
const state = {
|
|
2403
|
+
authenticated: false,
|
|
2404
|
+
device: null,
|
|
2405
|
+
buffer: Buffer.alloc(0),
|
|
2406
|
+
pendingBinaryFrame: null
|
|
2407
|
+
};
|
|
2408
|
+
|
|
2409
|
+
const helloTimer = setTimeout(() => {
|
|
2410
|
+
if (!state.authenticated) {
|
|
2411
|
+
writeJsonLine(socket, { type: 'error', error: 'hello-timeout' });
|
|
2412
|
+
socket.destroy();
|
|
2413
|
+
}
|
|
2414
|
+
}, 10000);
|
|
2415
|
+
|
|
2416
|
+
const processData = chunk => {
|
|
2417
|
+
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2418
|
+
state.buffer = state.buffer.length > 0
|
|
2419
|
+
? Buffer.concat([state.buffer, incoming])
|
|
2420
|
+
: incoming;
|
|
2421
|
+
|
|
2422
|
+
while (!socket.destroyed) {
|
|
2423
|
+
if (state.pendingBinaryFrame) {
|
|
2424
|
+
const { header, byteLength } = state.pendingBinaryFrame;
|
|
2425
|
+
if (state.buffer.length < byteLength) {
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
const framePayload = state.buffer.subarray(0, byteLength);
|
|
2430
|
+
state.buffer = state.buffer.subarray(byteLength);
|
|
2431
|
+
state.pendingBinaryFrame = null;
|
|
2432
|
+
handleAgentBinaryFrame(socket, state, header, framePayload);
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
const newlineIndex = state.buffer.indexOf(0x0a);
|
|
2437
|
+
if (newlineIndex < 0) {
|
|
2438
|
+
if (state.buffer.length > MAX_LINE_CHARS) {
|
|
2439
|
+
writeJsonLine(socket, { type: 'error', error: 'message-too-large' });
|
|
2440
|
+
socket.destroy();
|
|
2441
|
+
}
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
const lineBuffer = state.buffer.subarray(0, newlineIndex);
|
|
2446
|
+
state.buffer = state.buffer.subarray(newlineIndex + 1);
|
|
2447
|
+
|
|
2448
|
+
try {
|
|
2449
|
+
const message = parseJsonLine(lineBuffer.toString('utf8'));
|
|
2450
|
+
if (message?.type === 'frame.binary') {
|
|
2451
|
+
const frameKind = safeString(message.frameKind || message.kind || message.frameType, 40).toLowerCase();
|
|
2452
|
+
const maxBytes = frameKind === 'thumbnail'
|
|
2453
|
+
? MAX_THUMBNAIL_BINARY_BYTES
|
|
2454
|
+
: MAX_STREAM_BINARY_BYTES;
|
|
2455
|
+
const byteLength = Number(message.byteLength ?? message.payloadBytes ?? message.dataLength);
|
|
2456
|
+
if (!Number.isFinite(byteLength) || byteLength < 1 || byteLength > maxBytes) {
|
|
2457
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
|
|
2458
|
+
logWarn('remote', 'invalid binary frame header from agent.');
|
|
2459
|
+
continue;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
state.pendingBinaryFrame = {
|
|
2463
|
+
header: message,
|
|
2464
|
+
byteLength: Math.floor(byteLength)
|
|
2465
|
+
};
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
handleAgentMessage(socket, state, message);
|
|
2470
|
+
} catch (err) {
|
|
2471
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
|
|
2472
|
+
logWarn('remote', `invalid agent message: ${err?.message || err}`);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
|
|
2477
|
+
socket.on('data', processData);
|
|
2478
|
+
if (firstChunk) {
|
|
2479
|
+
processData(firstChunk);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
socket.on('close', () => {
|
|
2483
|
+
allSockets.delete(socket);
|
|
2484
|
+
clearTimeout(helloTimer);
|
|
2485
|
+
detachSocket(socket);
|
|
2486
|
+
});
|
|
2487
|
+
socket.on('error', err => {
|
|
2488
|
+
allSockets.delete(socket);
|
|
2489
|
+
clearTimeout(helloTimer);
|
|
2490
|
+
detachSocket(socket, err?.message || 'socket-error');
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
function handleSocket(socket) {
|
|
2495
|
+
socket.once('data', chunk => {
|
|
2496
|
+
const firstChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2497
|
+
if (isRemoteHubWebSocketUpgradeStart(firstChunk)) {
|
|
2498
|
+
handleWebSocketUpgradeSocket(socket, firstChunk);
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
handleTcpAgentSocket(socket, firstChunk);
|
|
2503
|
+
});
|
|
2504
|
+
|
|
2505
|
+
socket.once('error', () => {
|
|
2506
|
+
// The transport-specific handler owns logging after the first byte.
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
async function start() {
|
|
2511
|
+
if (!enabled || started) {
|
|
2512
|
+
return getStatus({ includeSecrets: false });
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
await new Promise((resolve, reject) => {
|
|
2516
|
+
server = net.createServer(handleSocket);
|
|
2517
|
+
server.once('error', err => {
|
|
2518
|
+
lastError = err?.message || String(err);
|
|
2519
|
+
server = null;
|
|
2520
|
+
reject(err);
|
|
2521
|
+
});
|
|
2522
|
+
server.listen(requestedPort, host, () => {
|
|
2523
|
+
started = true;
|
|
2524
|
+
boundPort = server.address()?.port || requestedPort;
|
|
2525
|
+
lastError = '';
|
|
2526
|
+
logEvent('remote', `RemoteHub listening on tcp://${host}:${boundPort}`, 'success');
|
|
2527
|
+
if (host === '0.0.0.0' || host === '::') {
|
|
2528
|
+
logWarn('remote', 'RemoteHub is externally reachable. Use a strong pairing token and trusted network.');
|
|
2529
|
+
}
|
|
2530
|
+
emitRemoteEvent('RemoteHubStarted', null);
|
|
2531
|
+
resolve();
|
|
2532
|
+
});
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
return getStatus({ includeSecrets: false });
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
async function close() {
|
|
2539
|
+
for (const device of devices.values()) {
|
|
2540
|
+
failAllPendingTasks(device, 'hub-shutdown');
|
|
2541
|
+
if (device.socket && !device.socket.destroyed) {
|
|
2542
|
+
writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
|
|
2543
|
+
device.socket.destroy();
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
for (const socket of allSockets) {
|
|
2548
|
+
if (!socket.destroyed) {
|
|
2549
|
+
socket.destroy();
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
sockets.clear();
|
|
2554
|
+
if (!server) {
|
|
2555
|
+
started = false;
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
await new Promise(resolve => {
|
|
2560
|
+
let settled = false;
|
|
2561
|
+
const settle = () => {
|
|
2562
|
+
if (settled) {
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
settled = true;
|
|
2567
|
+
clearTimeout(closeTimer);
|
|
2568
|
+
resolve();
|
|
2569
|
+
};
|
|
2570
|
+
|
|
2571
|
+
const closeTimer = setTimeout(settle, 1000);
|
|
2572
|
+
closeTimer.unref?.();
|
|
2573
|
+
|
|
2574
|
+
try {
|
|
2575
|
+
server.close(settle);
|
|
2576
|
+
} catch {
|
|
2577
|
+
settle();
|
|
2578
|
+
}
|
|
2579
|
+
});
|
|
2580
|
+
|
|
2581
|
+
server = null;
|
|
2582
|
+
started = false;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
function disconnectDevice(deviceId, reason = 'manager-disconnect') {
|
|
2586
|
+
const device = devices.get(String(deviceId || ''));
|
|
2587
|
+
if (device?.synthetic === true) {
|
|
2588
|
+
device.connected = false;
|
|
2589
|
+
device.disconnectedAt = new Date().toISOString();
|
|
2590
|
+
device.lastDisconnectReason = reason;
|
|
2591
|
+
if (device.activeLiveStream) {
|
|
2592
|
+
device.activeLiveStream.active = false;
|
|
2593
|
+
device.activeLiveStream.stoppedAt = device.disconnectedAt;
|
|
2594
|
+
device.activeLiveStream.stopReason = reason;
|
|
2595
|
+
}
|
|
2596
|
+
failAllPendingTasks(device, 'device-disconnected');
|
|
2597
|
+
emitRemoteEvent('RemoteDeviceDisconnected', device, { reason, synthetic: true });
|
|
2598
|
+
return true;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
if (!device?.socket || device.socket.destroyed) {
|
|
2602
|
+
return false;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
writeJsonLine(device.socket, { type: 'disconnect', reason });
|
|
2606
|
+
device.socket.destroy();
|
|
2607
|
+
return true;
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
function sendCommand(deviceId, command) {
|
|
2611
|
+
const device = devices.get(String(deviceId || ''));
|
|
2612
|
+
if (device?.synthetic === true && device.connected) {
|
|
2613
|
+
const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
|
|
2614
|
+
device.counters.commandsSent += 1;
|
|
2615
|
+
device.lastSeenAt = new Date().toISOString();
|
|
2616
|
+
if (command?.command === 'thumbnail.capture') {
|
|
2617
|
+
applySyntheticThumbnail(device, {
|
|
2618
|
+
commandId,
|
|
2619
|
+
payload: command?.payload || {}
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
emitRemoteEvent('RemoteCommandQueued', device, {
|
|
2623
|
+
commandId,
|
|
2624
|
+
command: safeString(command?.command || 'ping', 80),
|
|
2625
|
+
synthetic: true
|
|
2626
|
+
});
|
|
2627
|
+
emitRemoteEvent('RemoteCommandResult', device, {
|
|
2628
|
+
commandId,
|
|
2629
|
+
result: { ok: true, synthetic: true },
|
|
2630
|
+
error: ''
|
|
2631
|
+
});
|
|
2632
|
+
return { ok: true, commandId, synthetic: true };
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
2636
|
+
return { ok: false, error: 'device-not-connected' };
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
|
|
2640
|
+
const payload = {
|
|
2641
|
+
type: 'command',
|
|
2642
|
+
commandId,
|
|
2643
|
+
command: safeString(command?.command || 'ping', 80),
|
|
2644
|
+
payload: command?.payload ?? null,
|
|
2645
|
+
issuedAt: new Date().toISOString()
|
|
2646
|
+
};
|
|
2647
|
+
|
|
2648
|
+
writeJsonLine(device.socket, payload);
|
|
2649
|
+
device.counters.commandsSent += 1;
|
|
2650
|
+
emitRemoteEvent('RemoteCommandQueued', device, {
|
|
2651
|
+
commandId,
|
|
2652
|
+
command: payload.command
|
|
2653
|
+
});
|
|
2654
|
+
return { ok: true, commandId };
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
function sendInputControl(deviceId, input = {}) {
|
|
2658
|
+
const device = devices.get(String(deviceId || ''));
|
|
2659
|
+
if (!device) {
|
|
2660
|
+
return { ok: false, error: 'device-not-found' };
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
if (!device.connected) {
|
|
2664
|
+
return { ok: false, error: 'device-not-connected' };
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
if (!readCapabilityFlag(device.capabilities, 'control')) {
|
|
2668
|
+
return { ok: false, error: 'device-input-control-unavailable' };
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
const normalized = normalizeRemoteInputEvent(input);
|
|
2672
|
+
if (!normalized.type) {
|
|
2673
|
+
return { ok: false, error: 'missing-input-type' };
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
return sendCommand(deviceId, {
|
|
2677
|
+
command: 'input.control',
|
|
2678
|
+
payload: {
|
|
2679
|
+
...normalized,
|
|
2680
|
+
issuedAt: normalized.issuedAt || new Date().toISOString()
|
|
2681
|
+
}
|
|
2682
|
+
});
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
function requestAgentTask(deviceId, options = {}) {
|
|
2686
|
+
const device = devices.get(String(deviceId || ''));
|
|
2687
|
+
if (!device) {
|
|
2688
|
+
return { ok: false, error: 'device-not-found' };
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
if (device?.synthetic === true && device.connected) {
|
|
2692
|
+
const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
|
|
2693
|
+
if (!instruction) {
|
|
2694
|
+
return { ok: false, error: 'missing-instruction' };
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
const now = new Date().toISOString();
|
|
2698
|
+
const approvalLevel = normalizeApprovalLevel(options.approvalLevel);
|
|
2699
|
+
if (approvalLevel === 'ai-assist' && !readCapabilityFlag(device.capabilities, 'aiAssist')) {
|
|
2700
|
+
return { ok: false, error: 'device-ai-assist-unavailable' };
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
2704
|
+
const taskId = safeString(options.taskId, 128) || crypto.randomUUID();
|
|
2705
|
+
const title = safeString(options.title, 120)
|
|
2706
|
+
|| safeString(instruction.split(/\r?\n/)[0], 120)
|
|
2707
|
+
|| 'Remote task';
|
|
2708
|
+
const task = {
|
|
2709
|
+
batchId: safeString(options.batchId, 128),
|
|
2710
|
+
taskId,
|
|
2711
|
+
commandId,
|
|
2712
|
+
title,
|
|
2713
|
+
instructionPreview: safeText(instruction, 320),
|
|
2714
|
+
status: 'completed',
|
|
2715
|
+
approvalLevel,
|
|
2716
|
+
requestedAt: now,
|
|
2717
|
+
sentAt: now,
|
|
2718
|
+
updatedAt: now,
|
|
2719
|
+
completedAt: now,
|
|
2720
|
+
error: '',
|
|
2721
|
+
resultKind: approvalLevel === 'ai-assist' ? 'synthetic-ai-assist' : 'synthetic-agent-task',
|
|
2722
|
+
resultModel: approvalLevel === 'ai-assist'
|
|
2723
|
+
? safeString(options.model, 120) || 'synthetic-ai'
|
|
2724
|
+
: '',
|
|
2725
|
+
resultResponseId: approvalLevel === 'ai-assist'
|
|
2726
|
+
? `synthetic-response-${taskId}`
|
|
2727
|
+
: '',
|
|
2728
|
+
resultSummary: approvalLevel === 'ai-assist'
|
|
2729
|
+
? `Synthetic AI assist completed for ${device.deviceName}.`
|
|
2730
|
+
: `Synthetic task accepted by ${device.deviceName}.`
|
|
2731
|
+
};
|
|
2732
|
+
|
|
2733
|
+
device.counters.commandsSent += 1;
|
|
2734
|
+
device.counters.commandResultsReceived += 1;
|
|
2735
|
+
device.counters.tasksQueued += 1;
|
|
2736
|
+
device.counters.taskResultsReceived += 1;
|
|
2737
|
+
device.lastSeenAt = now;
|
|
2738
|
+
rememberDeviceTask(device, task);
|
|
2739
|
+
device.pendingTaskCommands.delete(commandId);
|
|
2740
|
+
syncTaskBatchFromTask(device, task);
|
|
2741
|
+
emitRemoteEvent('RemoteTaskQueued', device, {
|
|
2742
|
+
commandId,
|
|
2743
|
+
taskId,
|
|
2744
|
+
title,
|
|
2745
|
+
approvalLevel,
|
|
2746
|
+
synthetic: true
|
|
2747
|
+
});
|
|
2748
|
+
emitRemoteEvent('RemoteTaskResult', device, {
|
|
2749
|
+
commandId,
|
|
2750
|
+
taskId,
|
|
2751
|
+
status: task.status,
|
|
2752
|
+
error: '',
|
|
2753
|
+
synthetic: true
|
|
2754
|
+
});
|
|
2755
|
+
return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
2759
|
+
return { ok: false, error: 'device-not-connected' };
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
|
|
2763
|
+
if (!instruction) {
|
|
2764
|
+
return { ok: false, error: 'missing-instruction' };
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
const now = new Date().toISOString();
|
|
2768
|
+
const approvalLevel = normalizeApprovalLevel(options.approvalLevel);
|
|
2769
|
+
if (approvalLevel === 'ai-assist' && !readCapabilityFlag(device.capabilities, 'aiAssist')) {
|
|
2770
|
+
return { ok: false, error: 'device-ai-assist-unavailable' };
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
2774
|
+
const taskId = safeString(options.taskId, 128) || crypto.randomUUID();
|
|
2775
|
+
const title = safeString(options.title, 120)
|
|
2776
|
+
|| safeString(instruction.split(/\r?\n/)[0], 120)
|
|
2777
|
+
|| 'Remote task';
|
|
2778
|
+
const task = {
|
|
2779
|
+
batchId: safeString(options.batchId, 128),
|
|
2780
|
+
taskId,
|
|
2781
|
+
commandId,
|
|
2782
|
+
title,
|
|
2783
|
+
instructionPreview: safeText(instruction, 320),
|
|
2784
|
+
status: 'queued',
|
|
2785
|
+
approvalLevel,
|
|
2786
|
+
requestedAt: now,
|
|
2787
|
+
sentAt: now,
|
|
2788
|
+
updatedAt: now,
|
|
2789
|
+
completedAt: '',
|
|
2790
|
+
error: '',
|
|
2791
|
+
resultKind: '',
|
|
2792
|
+
resultModel: '',
|
|
2793
|
+
resultResponseId: '',
|
|
2794
|
+
resultSummary: ''
|
|
2795
|
+
};
|
|
2796
|
+
|
|
2797
|
+
const sent = writeJsonLine(device.socket, {
|
|
2798
|
+
type: 'command',
|
|
2799
|
+
commandId,
|
|
2800
|
+
command: 'agent.task',
|
|
2801
|
+
payload: {
|
|
2802
|
+
taskId,
|
|
2803
|
+
title,
|
|
2804
|
+
instruction,
|
|
2805
|
+
approvalLevel,
|
|
2806
|
+
model: safeString(options.model, 120),
|
|
2807
|
+
requestedAt: now
|
|
2808
|
+
},
|
|
2809
|
+
issuedAt: now
|
|
2810
|
+
});
|
|
2811
|
+
|
|
2812
|
+
if (!sent) {
|
|
2813
|
+
return { ok: false, error: 'device-not-connected' };
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
device.counters.commandsSent += 1;
|
|
2817
|
+
device.counters.tasksQueued += 1;
|
|
2818
|
+
rememberDeviceTask(device, task);
|
|
2819
|
+
syncTaskBatchFromTask(device, task);
|
|
2820
|
+
scheduleTaskTimeout(device, task);
|
|
2821
|
+
emitRemoteEvent('RemoteTaskQueued', device, {
|
|
2822
|
+
commandId,
|
|
2823
|
+
taskId,
|
|
2824
|
+
title,
|
|
2825
|
+
approvalLevel
|
|
2826
|
+
});
|
|
2827
|
+
return { ok: true, commandId, taskId, approvalLevel };
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
function requestAgentTaskBatch(deviceIds, options = {}) {
|
|
2831
|
+
const targets = [...new Set((deviceIds || [])
|
|
2832
|
+
.map(deviceId => safeString(deviceId, 128))
|
|
2833
|
+
.filter(Boolean))]
|
|
2834
|
+
.slice(0, 500);
|
|
2835
|
+
|
|
2836
|
+
if (targets.length === 0) {
|
|
2837
|
+
return {
|
|
2838
|
+
ok: false,
|
|
2839
|
+
error: 'no-target-devices',
|
|
2840
|
+
total: 0,
|
|
2841
|
+
queued: 0,
|
|
2842
|
+
approvalLevel: normalizeApprovalLevel(options.approvalLevel),
|
|
2843
|
+
results: []
|
|
2844
|
+
};
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
const batch = beginTaskBatch(targets, options);
|
|
2848
|
+
const results = targets.map(deviceId => {
|
|
2849
|
+
const result = requestAgentTask(deviceId, {
|
|
2850
|
+
...options,
|
|
2851
|
+
batchId: batch.batchId
|
|
2852
|
+
});
|
|
2853
|
+
|
|
2854
|
+
if (result.ok !== true) {
|
|
2855
|
+
syncTaskBatchDispatchFailure(batch.batchId, deviceId, result.error, result);
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
return {
|
|
2859
|
+
deviceId,
|
|
2860
|
+
...result
|
|
2861
|
+
};
|
|
2862
|
+
});
|
|
2863
|
+
|
|
2864
|
+
const queued = results.filter(result => result.ok === true).length;
|
|
2865
|
+
const updatedBatch = taskBatches.get(batch.batchId) || batch;
|
|
2866
|
+
recalculateTaskBatch(updatedBatch);
|
|
2867
|
+
return {
|
|
2868
|
+
ok: queued > 0,
|
|
2869
|
+
batchId: batch.batchId,
|
|
2870
|
+
total: targets.length,
|
|
2871
|
+
queued,
|
|
2872
|
+
approvalLevel: batch.approvalLevel,
|
|
2873
|
+
results,
|
|
2874
|
+
batch: serializeTaskBatch(updatedBatch),
|
|
2875
|
+
error: queued > 0 ? undefined : 'no-task-queued'
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
function requestThumbnail(deviceId, options = {}) {
|
|
2880
|
+
return sendCommand(deviceId, {
|
|
2881
|
+
command: 'thumbnail.capture',
|
|
2882
|
+
commandId: safeString(options.commandId, 128) || crypto.randomUUID(),
|
|
2883
|
+
payload: {
|
|
2884
|
+
streamId: safeString(options.streamId, 128) || `thumb-${Date.now()}`,
|
|
2885
|
+
maxWidth: clampNumber(options.maxWidth, 160, 1920, 360),
|
|
2886
|
+
maxHeight: clampNumber(options.maxHeight, 90, 1080, 220),
|
|
2887
|
+
quality: clampNumber(options.quality, 20, 95, 55),
|
|
2888
|
+
requestedAt: new Date().toISOString()
|
|
2889
|
+
}
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
function startLiveStream(deviceId, options = {}) {
|
|
2894
|
+
const device = devices.get(String(deviceId || ''));
|
|
2895
|
+
if (device?.synthetic === true && device.connected) {
|
|
2896
|
+
if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
|
|
2897
|
+
return { ok: false, error: 'device-live-stream-unavailable' };
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
const now = new Date().toISOString();
|
|
2901
|
+
const streamId = safeString(options.streamId, 128) || `live-${Date.now()}`;
|
|
2902
|
+
const fps = clampNumber(options.fps, 1, 24, 20);
|
|
2903
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
2904
|
+
device.counters.commandsSent += 1;
|
|
2905
|
+
device.activeLiveStream = {
|
|
2906
|
+
streamId,
|
|
2907
|
+
commandId,
|
|
2908
|
+
active: true,
|
|
2909
|
+
mode: 'remote-fast',
|
|
2910
|
+
fps,
|
|
2911
|
+
startedAt: now,
|
|
2912
|
+
stoppedAt: '',
|
|
2913
|
+
stopReason: '',
|
|
2914
|
+
lastFrameAt: '',
|
|
2915
|
+
lastFrameSeq: 0,
|
|
2916
|
+
framesReceived: 0
|
|
2917
|
+
};
|
|
2918
|
+
device.counters.liveStreamsStarted += 1;
|
|
2919
|
+
applySyntheticLiveFrame(device, streamId, {
|
|
2920
|
+
commandId,
|
|
2921
|
+
maxWidth: options.maxWidth,
|
|
2922
|
+
maxHeight: options.maxHeight,
|
|
2923
|
+
fps
|
|
2924
|
+
});
|
|
2925
|
+
emitRemoteEvent('RemoteLiveStreamStarted', device, {
|
|
2926
|
+
streamId,
|
|
2927
|
+
commandId,
|
|
2928
|
+
fps,
|
|
2929
|
+
synthetic: true
|
|
2930
|
+
});
|
|
2931
|
+
return { ok: true, commandId, streamId, fps, synthetic: true };
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
2935
|
+
return { ok: false, error: 'device-not-connected' };
|
|
2936
|
+
}
|
|
2937
|
+
if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
|
|
2938
|
+
return { ok: false, error: 'device-live-stream-unavailable' };
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
const now = new Date().toISOString();
|
|
2942
|
+
const streamId = safeString(options.streamId, 128) || `live-${Date.now()}`;
|
|
2943
|
+
const fps = clampNumber(options.fps, 1, 24, 12);
|
|
2944
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
2945
|
+
const result = sendCommand(deviceId, {
|
|
2946
|
+
command: 'stream.start',
|
|
2947
|
+
commandId,
|
|
2948
|
+
payload: {
|
|
2949
|
+
streamId,
|
|
2950
|
+
mode: safeString(options.mode, 80) || 'remote-fast',
|
|
2951
|
+
fps,
|
|
2952
|
+
maxWidth: clampNumber(options.maxWidth, 320, 2560, 960),
|
|
2953
|
+
maxHeight: clampNumber(options.maxHeight, 180, 1440, 540),
|
|
2954
|
+
quality: clampNumber(options.quality, 20, 95, 60),
|
|
2955
|
+
requestedAt: now
|
|
2956
|
+
}
|
|
2957
|
+
});
|
|
2958
|
+
|
|
2959
|
+
if (!result.ok) {
|
|
2960
|
+
return result;
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
device.activeLiveStream = {
|
|
2964
|
+
streamId,
|
|
2965
|
+
commandId,
|
|
2966
|
+
active: true,
|
|
2967
|
+
mode: 'remote-fast',
|
|
2968
|
+
fps,
|
|
2969
|
+
startedAt: now,
|
|
2970
|
+
stoppedAt: '',
|
|
2971
|
+
stopReason: '',
|
|
2972
|
+
lastFrameAt: '',
|
|
2973
|
+
lastFrameSeq: 0,
|
|
2974
|
+
framesReceived: 0
|
|
2975
|
+
};
|
|
2976
|
+
device.counters.liveStreamsStarted += 1;
|
|
2977
|
+
emitRemoteEvent('RemoteLiveStreamStarted', device, {
|
|
2978
|
+
streamId,
|
|
2979
|
+
commandId,
|
|
2980
|
+
fps
|
|
2981
|
+
});
|
|
2982
|
+
|
|
2983
|
+
return { ok: true, commandId, streamId, fps };
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
function stopLiveStream(deviceId, options = {}) {
|
|
2987
|
+
const device = devices.get(String(deviceId || ''));
|
|
2988
|
+
if (device?.synthetic === true && device.connected) {
|
|
2989
|
+
const streamId = safeString(options.streamId, 128) || device.activeLiveStream?.streamId || '';
|
|
2990
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
2991
|
+
device.counters.commandsSent += 1;
|
|
2992
|
+
if (device.activeLiveStream && (!streamId || device.activeLiveStream.streamId === streamId)) {
|
|
2993
|
+
device.activeLiveStream.active = false;
|
|
2994
|
+
device.activeLiveStream.stoppedAt = new Date().toISOString();
|
|
2995
|
+
device.activeLiveStream.stopReason = 'manager-request';
|
|
2996
|
+
}
|
|
2997
|
+
device.counters.liveStreamsStopped += 1;
|
|
2998
|
+
emitRemoteEvent('RemoteLiveStreamStopped', device, {
|
|
2999
|
+
streamId,
|
|
3000
|
+
commandId,
|
|
3001
|
+
synthetic: true
|
|
3002
|
+
});
|
|
3003
|
+
return { ok: true, commandId, streamId, synthetic: true };
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
3007
|
+
return { ok: false, error: 'device-not-connected' };
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
const streamId = safeString(options.streamId, 128) || device.activeLiveStream?.streamId || '';
|
|
3011
|
+
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
3012
|
+
const result = sendCommand(deviceId, {
|
|
3013
|
+
command: 'stream.stop',
|
|
3014
|
+
commandId,
|
|
3015
|
+
payload: {
|
|
3016
|
+
streamId,
|
|
3017
|
+
requestedAt: new Date().toISOString()
|
|
3018
|
+
}
|
|
3019
|
+
});
|
|
3020
|
+
|
|
3021
|
+
if (!result.ok) {
|
|
3022
|
+
return result;
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
if (device.activeLiveStream && (!streamId || device.activeLiveStream.streamId === streamId)) {
|
|
3026
|
+
device.activeLiveStream.active = false;
|
|
3027
|
+
device.activeLiveStream.stoppedAt = new Date().toISOString();
|
|
3028
|
+
device.activeLiveStream.stopReason = 'manager-request';
|
|
3029
|
+
}
|
|
3030
|
+
device.counters.liveStreamsStopped += 1;
|
|
3031
|
+
emitRemoteEvent('RemoteLiveStreamStopped', device, {
|
|
3032
|
+
streamId,
|
|
3033
|
+
commandId
|
|
3034
|
+
});
|
|
3035
|
+
|
|
3036
|
+
return { ok: true, commandId, streamId };
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
function getDeviceLiveFrame(deviceId, options = {}) {
|
|
3040
|
+
const device = devices.get(String(deviceId || ''));
|
|
3041
|
+
return serializeRemoteFrame(device?.latestLiveFrame, device?.deviceId, 'live', {
|
|
3042
|
+
includeDataUrl: options.includeDataUrl === true
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
function getDeviceThumbnail(deviceId, options = {}) {
|
|
3047
|
+
const device = devices.get(String(deviceId || ''));
|
|
3048
|
+
return serializeRemoteFrame(device?.latestThumbnail, device?.deviceId, 'thumbnail', {
|
|
3049
|
+
includeDataUrl: options.includeDataUrl === true
|
|
3050
|
+
});
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
function getFramePayload(deviceId, frameKind, options = {}) {
|
|
3054
|
+
const device = devices.get(String(deviceId || ''));
|
|
3055
|
+
if (!device) {
|
|
3056
|
+
return null;
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
|
|
3060
|
+
const frame = findRecentFramePayload(device, kind, options);
|
|
3061
|
+
if (!frame) {
|
|
3062
|
+
return null;
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
return {
|
|
3066
|
+
frame: serializeRemoteFrame(frame, device.deviceId, kind, { includeDataUrl: false }),
|
|
3067
|
+
payload: frame.payload,
|
|
3068
|
+
mimeType: safeString(frame.mimeType || frame.format || 'application/octet-stream', 120) || 'application/octet-stream',
|
|
3069
|
+
byteLength: frame.payload.length
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
return {
|
|
3074
|
+
start,
|
|
3075
|
+
close,
|
|
3076
|
+
getStatus,
|
|
3077
|
+
listDevices,
|
|
3078
|
+
listTaskBatches,
|
|
3079
|
+
getLatestTaskBatch,
|
|
3080
|
+
listDeviceFrames,
|
|
3081
|
+
disconnectDevice,
|
|
3082
|
+
sendCommand,
|
|
3083
|
+
sendInputControl,
|
|
3084
|
+
requestAgentTask,
|
|
3085
|
+
requestAgentTaskBatch,
|
|
3086
|
+
setHostTarget,
|
|
3087
|
+
requestThumbnail,
|
|
3088
|
+
startLiveStream,
|
|
3089
|
+
stopLiveStream,
|
|
3090
|
+
getDeviceLiveFrame,
|
|
3091
|
+
getDeviceThumbnail,
|
|
3092
|
+
getFramePayload,
|
|
3093
|
+
seedSyntheticFleet,
|
|
3094
|
+
clearSyntheticFleet,
|
|
3095
|
+
getPairToken: () => pairToken
|
|
3096
|
+
};
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
export function getDefaultRemoteAgentDeviceInfo() {
|
|
3100
|
+
return {
|
|
3101
|
+
hostname: os.hostname(),
|
|
3102
|
+
platform: os.platform(),
|
|
3103
|
+
arch: os.arch(),
|
|
3104
|
+
pid: process.pid
|
|
3105
|
+
};
|
|
3106
|
+
}
|