@mindexec/cli 0.2.2 → 0.2.3
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 +38 -0
- package/package.json +6 -4
- package/remote-hub.js +571 -0
- package/scripts/remote-hub-smoke.mjs +117 -0
- package/server.js +108 -28
- package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js +11 -0
- package/wwwroot/_content/MindExecution.Shared/js/mind-map-css3d-manager.js +550 -2
- package/wwwroot/_content/MindExecution.Shared/js/mind-map-nodes.js +3 -1
- package/wwwroot/_framework/MindExecution.Core.5luow1xgjs.dll +0 -0
- package/wwwroot/_framework/{MindExecution.Kernel.gwwc40sc45.dll → MindExecution.Kernel.mot9nj6bzm.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Admin.0jgrn1sckv.dll → MindExecution.Plugins.Admin.x9v2drg2f7.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Business.13mme2qcag.dll → MindExecution.Plugins.Business.b0kjoyx31x.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Concept.9al2g3v3f9.dll → MindExecution.Plugins.Concept.6tojojgh1a.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Directory.3w4t6n3se0.dll → MindExecution.Plugins.Directory.fqtbuqadsx.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.PlanMaster.vfmfbygv5y.dll → MindExecution.Plugins.PlanMaster.j7llfeae6l.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.YouTube.32jyiqs383.dll → MindExecution.Plugins.YouTube.yo5fwdhugr.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Shared.7ttmykvopx.dll → MindExecution.Shared.0qi7vbn9a4.dll} +0 -0
- package/wwwroot/_framework/MindExecution.Web.6cv7ad7rik.dll +0 -0
- package/wwwroot/_framework/blazor.boot.json +21 -21
- package/wwwroot/index.html +1 -1
- package/wwwroot/service-worker-assets.js +26 -26
- package/wwwroot/service-worker.js +1 -1
- package/wwwroot/_framework/MindExecution.Core.1q1trifbuu.dll +0 -0
- package/wwwroot/_framework/MindExecution.Web.ozzcqp30uy.dll +0 -0
package/README.md
CHANGED
|
@@ -45,6 +45,44 @@ precompressed static files from the npm bundle by default. Use
|
|
|
45
45
|
`-KeepPackagedGalleryImages` or `-KeepPackagedPrecompressedAssets` only when
|
|
46
46
|
testing a full untrimmed local package.
|
|
47
47
|
|
|
48
|
+
## Remote Direct quick start
|
|
49
|
+
|
|
50
|
+
LocalBridge also starts a separate RemoteHub listener for direct remote-agent
|
|
51
|
+
experiments. By default it binds to loopback only:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx @mindexec/cli
|
|
55
|
+
npx @mindexec/remote connect --manager 127.0.0.1:5197 --pair <pair-token>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Read the pair token from the protected local endpoint:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
curl -H "X-Bridge-Token: <bridge-token>" http://127.0.0.1:5147/api/remote/status
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
LAN/direct mode must be enabled explicitly by changing the RemoteHub bind host:
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
$env:REMOTE_HUB_HOST="0.0.0.0"
|
|
68
|
+
$env:REMOTE_HUB_PORT="5197"
|
|
69
|
+
$env:REMOTE_HUB_PAIR_TOKEN="<strong-token>"
|
|
70
|
+
npx @mindexec/cli
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Device inventory is intentionally not paginated:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
curl -H "X-Bridge-Token: <bridge-token>" http://127.0.0.1:5147/api/remote/devices
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Request and read the latest view-only thumbnail for a connected device:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
curl -X POST -H "X-Bridge-Token: <bridge-token>" http://127.0.0.1:5147/api/remote/devices/<device-id>/thumbnail/request
|
|
83
|
+
curl -H "X-Bridge-Token: <bridge-token>" http://127.0.0.1:5147/api/remote/devices/<device-id>/thumbnail
|
|
84
|
+
```
|
|
85
|
+
|
|
48
86
|
?먮뒗 ?꾩뿭 ?ㅼ튂 ???ㅽ뻾?⑸땲??
|
|
49
87
|
|
|
50
88
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindexec/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "MindExec local runtime and bridge CLI",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"type": "module",
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
"start-bridge.bat",
|
|
9
9
|
"start-bridge.sh",
|
|
10
10
|
"launch-bridge.cjs",
|
|
11
|
-
"server.js",
|
|
12
|
-
"
|
|
11
|
+
"server.js",
|
|
12
|
+
"remote-hub.js",
|
|
13
|
+
"codex-runtime.js",
|
|
13
14
|
"port-guard.cjs",
|
|
14
15
|
"wwwroot/",
|
|
15
16
|
"scripts/",
|
|
@@ -19,7 +20,8 @@
|
|
|
19
20
|
"scripts": {
|
|
20
21
|
"start": "node launch-bridge.cjs",
|
|
21
22
|
"dev": "node launch-bridge.cjs --watch",
|
|
22
|
-
"test:syntax": "node --check server.js && node --check codex-runtime.js && node --check launch-bridge.cjs && node --check port-guard.cjs && node --check scripts/setup-tree-sitter-grammars.mjs",
|
|
23
|
+
"test:syntax": "node --check server.js && node --check remote-hub.js && node --check codex-runtime.js && node --check launch-bridge.cjs && node --check port-guard.cjs && node --check scripts/setup-tree-sitter-grammars.mjs && node --check scripts/remote-hub-smoke.mjs",
|
|
24
|
+
"test:remote": "node scripts/remote-hub-smoke.mjs",
|
|
23
25
|
"pack:dry": "npm pack --dry-run",
|
|
24
26
|
"setup:grammars": "node scripts/setup-tree-sitter-grammars.mjs",
|
|
25
27
|
"postinstall": "npm run setup:grammars"
|
package/remote-hub.js
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
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 = '127.0.0.1';
|
|
7
|
+
const DEFAULT_HEARTBEAT_MS = 5000;
|
|
8
|
+
const MAX_LINE_CHARS = 4 * 1024 * 1024;
|
|
9
|
+
const MAX_THUMBNAIL_BASE64_CHARS = 3 * 1024 * 1024;
|
|
10
|
+
const REMOTE_PROTOCOL_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
function isEnabledValue(value, fallback = true) {
|
|
13
|
+
if (value === undefined || value === null || value === '') {
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return /^(1|true|yes|on)$/i.test(String(value).trim());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isDisabledValue(value) {
|
|
21
|
+
return /^(1|true|yes|on)$/i.test(String(value || '').trim());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clampNumber(value, min, max, fallback) {
|
|
25
|
+
const number = Number(value);
|
|
26
|
+
if (!Number.isFinite(number)) {
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return Math.max(min, Math.min(max, Math.floor(number)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizePort(value) {
|
|
34
|
+
return clampNumber(value, 0, 65535, DEFAULT_REMOTE_HUB_PORT);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function safeString(value, maxLength = 200) {
|
|
38
|
+
return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeDeviceId(value) {
|
|
42
|
+
const id = safeString(value, 128).replace(/[^a-zA-Z0-9_.:-]/g, '-');
|
|
43
|
+
return id || crypto.randomUUID();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function maskToken(token) {
|
|
47
|
+
const value = String(token || '');
|
|
48
|
+
if (value.length <= 8) {
|
|
49
|
+
return value ? '****' : '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function timingSafeStringEqual(left, right) {
|
|
56
|
+
const a = Buffer.from(String(left || ''), 'utf8');
|
|
57
|
+
const b = Buffer.from(String(right || ''), 'utf8');
|
|
58
|
+
if (a.length !== b.length) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return crypto.timingSafeEqual(a, b);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseJsonLine(line) {
|
|
66
|
+
const text = String(line || '').trim();
|
|
67
|
+
if (!text) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return JSON.parse(text);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function serializeDevice(device) {
|
|
75
|
+
if (!device) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
deviceId: device.deviceId,
|
|
81
|
+
sessionId: device.sessionId,
|
|
82
|
+
deviceName: device.deviceName,
|
|
83
|
+
hostname: device.hostname,
|
|
84
|
+
platform: device.platform,
|
|
85
|
+
arch: device.arch,
|
|
86
|
+
pid: device.pid,
|
|
87
|
+
agentVersion: device.agentVersion,
|
|
88
|
+
capabilities: { ...device.capabilities },
|
|
89
|
+
connected: device.connected,
|
|
90
|
+
connectedAt: device.connectedAt,
|
|
91
|
+
disconnectedAt: device.disconnectedAt,
|
|
92
|
+
lastSeenAt: device.lastSeenAt,
|
|
93
|
+
lastStatusAt: device.lastStatusAt,
|
|
94
|
+
lastDisconnectReason: device.lastDisconnectReason,
|
|
95
|
+
remoteAddress: device.remoteAddress,
|
|
96
|
+
remotePort: device.remotePort,
|
|
97
|
+
latestThumbnail: device.latestThumbnail ? { ...device.latestThumbnail } : null,
|
|
98
|
+
status: { ...device.status },
|
|
99
|
+
counters: { ...device.counters }
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeJsonLine(socket, payload) {
|
|
104
|
+
if (!socket || socket.destroyed) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createRemoteHub(options = {}) {
|
|
113
|
+
const env = options.env || process.env;
|
|
114
|
+
const logEvent = options.logEvent || (() => {});
|
|
115
|
+
const logWarn = options.logWarn || (() => {});
|
|
116
|
+
const emitEvent = options.emitEvent || (() => {});
|
|
117
|
+
|
|
118
|
+
const enabled = isEnabledValue(env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
|
|
119
|
+
&& !isDisabledValue(env.REMOTE_HUB_DISABLED);
|
|
120
|
+
const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
|
|
121
|
+
const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
|
|
122
|
+
const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
|
|
123
|
+
const pairToken = safeString(
|
|
124
|
+
env.REMOTE_HUB_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN || crypto.randomBytes(6).toString('hex'),
|
|
125
|
+
256);
|
|
126
|
+
|
|
127
|
+
const devices = new Map();
|
|
128
|
+
const sockets = new Map();
|
|
129
|
+
const allSockets = new Set();
|
|
130
|
+
let server = null;
|
|
131
|
+
let started = false;
|
|
132
|
+
let boundPort = requestedPort;
|
|
133
|
+
let lastError = '';
|
|
134
|
+
|
|
135
|
+
function getStatus({ includeSecrets = false } = {}) {
|
|
136
|
+
const connectedDevices = [...devices.values()].filter(device => device.connected).length;
|
|
137
|
+
return {
|
|
138
|
+
enabled,
|
|
139
|
+
started,
|
|
140
|
+
host,
|
|
141
|
+
port: boundPort || requestedPort,
|
|
142
|
+
protocol: 'tcp-jsonl',
|
|
143
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
144
|
+
heartbeatMs,
|
|
145
|
+
agentPackage: '@mindexec/remote',
|
|
146
|
+
agentEndpoint: `${host}:${boundPort || requestedPort}`,
|
|
147
|
+
pairToken: includeSecrets ? pairToken : undefined,
|
|
148
|
+
pairTokenPreview: maskToken(pairToken),
|
|
149
|
+
deviceCount: devices.size,
|
|
150
|
+
connectedDeviceCount: connectedDevices,
|
|
151
|
+
canvasDeviceListMode: 'all-devices',
|
|
152
|
+
canvasPagination: 'none',
|
|
153
|
+
externalExposure: host === '0.0.0.0' || host === '::',
|
|
154
|
+
lastError
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function listDevices() {
|
|
159
|
+
return [...devices.values()]
|
|
160
|
+
.map(serializeDevice)
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
.sort((a, b) => {
|
|
163
|
+
const nameCompare = String(a.deviceName || '').localeCompare(String(b.deviceName || ''));
|
|
164
|
+
if (nameCompare !== 0) {
|
|
165
|
+
return nameCompare;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return String(a.deviceId).localeCompare(String(b.deviceId));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function emitRemoteEvent(type, device = null, extra = {}) {
|
|
173
|
+
emitEvent(type, {
|
|
174
|
+
...extra,
|
|
175
|
+
remoteHub: getStatus({ includeSecrets: false }),
|
|
176
|
+
device: serializeDevice(device)
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function closeExistingDeviceSocket(deviceId, nextSessionId) {
|
|
181
|
+
const existing = devices.get(deviceId);
|
|
182
|
+
if (!existing?.socket || existing.socket.destroyed) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
existing.lastDisconnectReason = 'replaced-by-new-session';
|
|
187
|
+
writeJsonLine(existing.socket, {
|
|
188
|
+
type: 'disconnect',
|
|
189
|
+
reason: 'replaced-by-new-session',
|
|
190
|
+
nextSessionId
|
|
191
|
+
});
|
|
192
|
+
existing.socket.destroy();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function attachDevice(socket, hello) {
|
|
196
|
+
const now = new Date().toISOString();
|
|
197
|
+
const sessionId = crypto.randomUUID();
|
|
198
|
+
const deviceId = normalizeDeviceId(hello.deviceId);
|
|
199
|
+
|
|
200
|
+
closeExistingDeviceSocket(deviceId, sessionId);
|
|
201
|
+
|
|
202
|
+
const device = {
|
|
203
|
+
socket,
|
|
204
|
+
deviceId,
|
|
205
|
+
sessionId,
|
|
206
|
+
deviceName: safeString(hello.deviceName || hello.hostname || deviceId, 120),
|
|
207
|
+
hostname: safeString(hello.hostname, 120),
|
|
208
|
+
platform: safeString(hello.platform, 80),
|
|
209
|
+
arch: safeString(hello.arch, 40),
|
|
210
|
+
pid: Number.isFinite(Number(hello.pid)) ? Number(hello.pid) : 0,
|
|
211
|
+
agentVersion: safeString(hello.agentVersion, 40),
|
|
212
|
+
capabilities: typeof hello.capabilities === 'object' && hello.capabilities
|
|
213
|
+
? { ...hello.capabilities }
|
|
214
|
+
: {},
|
|
215
|
+
connected: true,
|
|
216
|
+
connectedAt: now,
|
|
217
|
+
disconnectedAt: '',
|
|
218
|
+
lastSeenAt: now,
|
|
219
|
+
lastStatusAt: '',
|
|
220
|
+
lastDisconnectReason: '',
|
|
221
|
+
remoteAddress: socket.remoteAddress || '',
|
|
222
|
+
remotePort: socket.remotePort || 0,
|
|
223
|
+
status: {},
|
|
224
|
+
latestThumbnail: null,
|
|
225
|
+
counters: {
|
|
226
|
+
messagesReceived: 1,
|
|
227
|
+
statusReceived: 0,
|
|
228
|
+
commandsSent: 0,
|
|
229
|
+
commandResultsReceived: 0,
|
|
230
|
+
thumbnailFramesReceived: 0,
|
|
231
|
+
thumbnailFramesDropped: 0
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
devices.set(deviceId, device);
|
|
236
|
+
sockets.set(socket, deviceId);
|
|
237
|
+
writeJsonLine(socket, {
|
|
238
|
+
type: 'welcome',
|
|
239
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
240
|
+
sessionId,
|
|
241
|
+
deviceId,
|
|
242
|
+
heartbeatMs,
|
|
243
|
+
serverTime: now
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
logEvent('remote', `device connected ${device.deviceName} (${deviceId})`, 'success');
|
|
247
|
+
emitRemoteEvent('RemoteDeviceConnected', device);
|
|
248
|
+
return device;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function detachSocket(socket, reason = 'socket-closed') {
|
|
252
|
+
const deviceId = sockets.get(socket);
|
|
253
|
+
sockets.delete(socket);
|
|
254
|
+
if (!deviceId) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const device = devices.get(deviceId);
|
|
259
|
+
if (!device || device.socket !== socket) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
device.connected = false;
|
|
264
|
+
device.socket = null;
|
|
265
|
+
device.disconnectedAt = new Date().toISOString();
|
|
266
|
+
device.lastDisconnectReason = reason;
|
|
267
|
+
logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
|
|
268
|
+
emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function handleAgentMessage(socket, state, message) {
|
|
272
|
+
if (!message || typeof message !== 'object') {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!state.authenticated) {
|
|
277
|
+
if (message.type !== 'hello') {
|
|
278
|
+
writeJsonLine(socket, { type: 'error', error: 'hello-required' });
|
|
279
|
+
socket.destroy();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!timingSafeStringEqual(message.pairToken, pairToken)) {
|
|
284
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-pair-token' });
|
|
285
|
+
socket.destroy();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
state.authenticated = true;
|
|
290
|
+
state.device = attachDevice(socket, message);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const device = state.device;
|
|
295
|
+
if (!device) {
|
|
296
|
+
socket.destroy();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
device.counters.messagesReceived += 1;
|
|
301
|
+
device.lastSeenAt = new Date().toISOString();
|
|
302
|
+
|
|
303
|
+
switch (message.type) {
|
|
304
|
+
case 'heartbeat':
|
|
305
|
+
case 'status':
|
|
306
|
+
device.status = typeof message.status === 'object' && message.status
|
|
307
|
+
? { ...message.status }
|
|
308
|
+
: {};
|
|
309
|
+
device.lastStatusAt = device.lastSeenAt;
|
|
310
|
+
device.counters.statusReceived += 1;
|
|
311
|
+
emitRemoteEvent('RemoteDeviceStatus', device);
|
|
312
|
+
break;
|
|
313
|
+
case 'command.result':
|
|
314
|
+
device.counters.commandResultsReceived += 1;
|
|
315
|
+
emitRemoteEvent('RemoteCommandResult', device, {
|
|
316
|
+
commandId: safeString(message.commandId, 128),
|
|
317
|
+
result: message.result ?? null,
|
|
318
|
+
error: safeString(message.error, 500)
|
|
319
|
+
});
|
|
320
|
+
break;
|
|
321
|
+
case 'thumbnail.frame': {
|
|
322
|
+
const frameData = safeString(message.data, MAX_THUMBNAIL_BASE64_CHARS + 1);
|
|
323
|
+
const frameSeq = Number(message.frameSeq);
|
|
324
|
+
if (!frameData || frameData.length > MAX_THUMBNAIL_BASE64_CHARS || !Number.isFinite(frameSeq)) {
|
|
325
|
+
device.counters.thumbnailFramesDropped += 1;
|
|
326
|
+
emitRemoteEvent('RemoteFrameDropped', device, {
|
|
327
|
+
reason: 'invalid-thumbnail-frame',
|
|
328
|
+
frameSeq: Number.isFinite(frameSeq) ? frameSeq : null
|
|
329
|
+
});
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const mimeType = safeString(message.mimeType || message.format || 'image/jpeg', 80) || 'image/jpeg';
|
|
334
|
+
const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
|
|
335
|
+
device.latestThumbnail = {
|
|
336
|
+
streamId: safeString(message.streamId, 128) || 'thumbnail',
|
|
337
|
+
frameSeq,
|
|
338
|
+
commandId: safeString(message.commandId, 128),
|
|
339
|
+
width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
|
|
340
|
+
height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
|
|
341
|
+
mimeType,
|
|
342
|
+
format: mimeType,
|
|
343
|
+
capturedAt,
|
|
344
|
+
receivedAt: device.lastSeenAt,
|
|
345
|
+
byteLength: Math.floor(frameData.length * 3 / 4),
|
|
346
|
+
dataUrl: frameData.startsWith('data:')
|
|
347
|
+
? frameData
|
|
348
|
+
: `data:${mimeType};base64,${frameData}`
|
|
349
|
+
};
|
|
350
|
+
device.counters.thumbnailFramesReceived += 1;
|
|
351
|
+
emitRemoteEvent('RemoteFrameReceived', device, {
|
|
352
|
+
streamId: device.latestThumbnail.streamId,
|
|
353
|
+
frameSeq,
|
|
354
|
+
width: device.latestThumbnail.width,
|
|
355
|
+
height: device.latestThumbnail.height
|
|
356
|
+
});
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
default:
|
|
360
|
+
emitRemoteEvent('RemoteAgentMessageIgnored', device, {
|
|
361
|
+
messageType: safeString(message.type, 80)
|
|
362
|
+
});
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function handleSocket(socket) {
|
|
368
|
+
allSockets.add(socket);
|
|
369
|
+
socket.setEncoding('utf8');
|
|
370
|
+
socket.setNoDelay(true);
|
|
371
|
+
socket.setKeepAlive(true, heartbeatMs);
|
|
372
|
+
|
|
373
|
+
const state = {
|
|
374
|
+
authenticated: false,
|
|
375
|
+
device: null,
|
|
376
|
+
buffer: ''
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const helloTimer = setTimeout(() => {
|
|
380
|
+
if (!state.authenticated) {
|
|
381
|
+
writeJsonLine(socket, { type: 'error', error: 'hello-timeout' });
|
|
382
|
+
socket.destroy();
|
|
383
|
+
}
|
|
384
|
+
}, 10000);
|
|
385
|
+
|
|
386
|
+
socket.on('data', chunk => {
|
|
387
|
+
state.buffer += chunk;
|
|
388
|
+
if (state.buffer.length > MAX_LINE_CHARS) {
|
|
389
|
+
writeJsonLine(socket, { type: 'error', error: 'message-too-large' });
|
|
390
|
+
socket.destroy();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let newlineIndex = state.buffer.indexOf('\n');
|
|
395
|
+
while (newlineIndex >= 0) {
|
|
396
|
+
const line = state.buffer.slice(0, newlineIndex);
|
|
397
|
+
state.buffer = state.buffer.slice(newlineIndex + 1);
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const message = parseJsonLine(line);
|
|
401
|
+
handleAgentMessage(socket, state, message);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
|
|
404
|
+
logWarn('remote', `invalid agent message: ${err?.message || err}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
newlineIndex = state.buffer.indexOf('\n');
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
socket.on('close', () => {
|
|
412
|
+
allSockets.delete(socket);
|
|
413
|
+
clearTimeout(helloTimer);
|
|
414
|
+
detachSocket(socket);
|
|
415
|
+
});
|
|
416
|
+
socket.on('error', err => {
|
|
417
|
+
allSockets.delete(socket);
|
|
418
|
+
clearTimeout(helloTimer);
|
|
419
|
+
detachSocket(socket, err?.message || 'socket-error');
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function start() {
|
|
424
|
+
if (!enabled || started) {
|
|
425
|
+
return getStatus({ includeSecrets: false });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
await new Promise((resolve, reject) => {
|
|
429
|
+
server = net.createServer(handleSocket);
|
|
430
|
+
server.once('error', err => {
|
|
431
|
+
lastError = err?.message || String(err);
|
|
432
|
+
server = null;
|
|
433
|
+
reject(err);
|
|
434
|
+
});
|
|
435
|
+
server.listen(requestedPort, host, () => {
|
|
436
|
+
started = true;
|
|
437
|
+
boundPort = server.address()?.port || requestedPort;
|
|
438
|
+
lastError = '';
|
|
439
|
+
logEvent('remote', `RemoteHub listening on tcp://${host}:${boundPort}`, 'success');
|
|
440
|
+
if (host === '0.0.0.0' || host === '::') {
|
|
441
|
+
logWarn('remote', 'RemoteHub is externally reachable. Use a strong pairing token and trusted network.');
|
|
442
|
+
}
|
|
443
|
+
emitRemoteEvent('RemoteHubStarted', null);
|
|
444
|
+
resolve();
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
return getStatus({ includeSecrets: false });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function close() {
|
|
452
|
+
for (const device of devices.values()) {
|
|
453
|
+
if (device.socket && !device.socket.destroyed) {
|
|
454
|
+
writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
|
|
455
|
+
device.socket.destroy();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
for (const socket of allSockets) {
|
|
460
|
+
if (!socket.destroyed) {
|
|
461
|
+
socket.destroy();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
sockets.clear();
|
|
466
|
+
if (!server) {
|
|
467
|
+
started = false;
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
await new Promise(resolve => {
|
|
472
|
+
let settled = false;
|
|
473
|
+
const settle = () => {
|
|
474
|
+
if (settled) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
settled = true;
|
|
479
|
+
clearTimeout(closeTimer);
|
|
480
|
+
resolve();
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const closeTimer = setTimeout(settle, 1000);
|
|
484
|
+
closeTimer.unref?.();
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
server.close(settle);
|
|
488
|
+
} catch {
|
|
489
|
+
settle();
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
server = null;
|
|
494
|
+
started = false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function disconnectDevice(deviceId, reason = 'manager-disconnect') {
|
|
498
|
+
const device = devices.get(String(deviceId || ''));
|
|
499
|
+
if (!device?.socket || device.socket.destroyed) {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
writeJsonLine(device.socket, { type: 'disconnect', reason });
|
|
504
|
+
device.socket.destroy();
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function sendCommand(deviceId, command) {
|
|
509
|
+
const device = devices.get(String(deviceId || ''));
|
|
510
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
511
|
+
return { ok: false, error: 'device-not-connected' };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
|
|
515
|
+
const payload = {
|
|
516
|
+
type: 'command',
|
|
517
|
+
commandId,
|
|
518
|
+
command: safeString(command?.command || 'ping', 80),
|
|
519
|
+
payload: command?.payload ?? null,
|
|
520
|
+
issuedAt: new Date().toISOString()
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
writeJsonLine(device.socket, payload);
|
|
524
|
+
device.counters.commandsSent += 1;
|
|
525
|
+
emitRemoteEvent('RemoteCommandQueued', device, {
|
|
526
|
+
commandId,
|
|
527
|
+
command: payload.command
|
|
528
|
+
});
|
|
529
|
+
return { ok: true, commandId };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function requestThumbnail(deviceId, options = {}) {
|
|
533
|
+
return sendCommand(deviceId, {
|
|
534
|
+
command: 'thumbnail.capture',
|
|
535
|
+
commandId: safeString(options.commandId, 128) || crypto.randomUUID(),
|
|
536
|
+
payload: {
|
|
537
|
+
streamId: safeString(options.streamId, 128) || `thumb-${Date.now()}`,
|
|
538
|
+
maxWidth: clampNumber(options.maxWidth, 160, 1920, 360),
|
|
539
|
+
maxHeight: clampNumber(options.maxHeight, 90, 1080, 220),
|
|
540
|
+
quality: clampNumber(options.quality, 20, 95, 55),
|
|
541
|
+
requestedAt: new Date().toISOString()
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function getDeviceThumbnail(deviceId) {
|
|
547
|
+
const device = devices.get(String(deviceId || ''));
|
|
548
|
+
return device?.latestThumbnail ? { ...device.latestThumbnail } : null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
start,
|
|
553
|
+
close,
|
|
554
|
+
getStatus,
|
|
555
|
+
listDevices,
|
|
556
|
+
disconnectDevice,
|
|
557
|
+
sendCommand,
|
|
558
|
+
requestThumbnail,
|
|
559
|
+
getDeviceThumbnail,
|
|
560
|
+
getPairToken: () => pairToken
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function getDefaultRemoteAgentDeviceInfo() {
|
|
565
|
+
return {
|
|
566
|
+
hostname: os.hostname(),
|
|
567
|
+
platform: os.platform(),
|
|
568
|
+
arch: os.arch(),
|
|
569
|
+
pid: process.pid
|
|
570
|
+
};
|
|
571
|
+
}
|