livedesk 0.1.0 → 0.1.1

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.
@@ -0,0 +1,724 @@
1
+ #!/usr/bin/env node
2
+
3
+ import cors from 'cors';
4
+ import express from 'express';
5
+ import crypto from 'node:crypto';
6
+ import { createServer } from 'node:http';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { dirname, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { WebSocketServer } from 'ws';
11
+ import { createRemoteHub } from './remote-hub.js';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const webDistCandidates = [
15
+ process.env.LIVEDESK_WEB_DIST,
16
+ resolve(__dirname, '..', 'web', 'dist'),
17
+ resolve(__dirname, '..', '..', '..', 'apps', 'web', 'dist')
18
+ ].filter(Boolean);
19
+ const webDistPath = webDistCandidates.find(candidate => existsSync(resolve(candidate, 'index.html'))) || webDistCandidates[webDistCandidates.length - 1];
20
+ const webIndexPath = resolve(webDistPath, 'index.html');
21
+ const packageInfo = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
22
+ const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '0.0.0.0';
23
+ const httpPort = Number(process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT || 5179);
24
+ const frameClients = new Set();
25
+ const inputClients = new Set();
26
+ const audioClients = new Set();
27
+ let frameClientSeq = 0;
28
+ let inputClientSeq = 0;
29
+ let audioClientSeq = 0;
30
+
31
+ const remoteHub = createRemoteHub({
32
+ managerPackage: '@livedesk/hub',
33
+ managerVersion: packageInfo.version,
34
+ env: {
35
+ ...process.env,
36
+ MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
37
+ MINDEXEC_MANAGER_VERSION: packageInfo.version
38
+ },
39
+ logEvent: (_scope, message) => console.log(`[LiveDesk Hub] ${message}`),
40
+ logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
41
+ emitFrame: broadcastRemoteBinaryFrame,
42
+ emitAudio: broadcastRemoteBinaryAudio
43
+ });
44
+
45
+ const app = express();
46
+ const httpServer = createServer(app);
47
+ const frameWss = new WebSocketServer({ noServer: true });
48
+ const inputWss = new WebSocketServer({ noServer: true });
49
+ const audioWss = new WebSocketServer({ noServer: true });
50
+
51
+ app.use((req, res, next) => {
52
+ if (req.headers.origin) {
53
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
54
+ res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
55
+ }
56
+ next();
57
+ });
58
+
59
+ app.use(cors({
60
+ origin: true,
61
+ credentials: false,
62
+ methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
63
+ allowedHeaders: ['Content-Type']
64
+ }));
65
+ app.use(express.json({ limit: '32mb' }));
66
+
67
+ const MAX_FILE_TRANSFER_FILES = 24;
68
+ const MAX_FILE_TRANSFER_BYTES = 24 * 1024 * 1024;
69
+
70
+ function noStore(res) {
71
+ res.setHeader('Cache-Control', 'no-store');
72
+ }
73
+
74
+ function clampNumber(value, min, max, fallback) {
75
+ const number = Number(value);
76
+ if (!Number.isFinite(number)) {
77
+ return fallback;
78
+ }
79
+ return Math.max(min, Math.min(max, Math.round(number)));
80
+ }
81
+
82
+ function normalizeDeviceIds(value) {
83
+ const raw = Array.isArray(value)
84
+ ? value
85
+ : String(value || '').split(',');
86
+ const ids = [];
87
+ const seen = new Set();
88
+ for (const entry of raw) {
89
+ const id = String(entry || '').trim();
90
+ if (!id || seen.has(id)) {
91
+ continue;
92
+ }
93
+ seen.add(id);
94
+ ids.push(id);
95
+ if (ids.length >= 240) {
96
+ break;
97
+ }
98
+ }
99
+ return ids;
100
+ }
101
+
102
+ function normalizeTransferFiles(value) {
103
+ const rawFiles = Array.isArray(value) ? value : [];
104
+ const files = [];
105
+ let totalBytes = 0;
106
+ for (const entry of rawFiles.slice(0, MAX_FILE_TRANSFER_FILES)) {
107
+ const dataBase64 = String(entry?.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
108
+ const name = String(entry?.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
109
+ const relativePath = String(entry?.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
110
+ const size = Number(entry?.size);
111
+ const byteLength = Number.isFinite(size) && size >= 0
112
+ ? Math.floor(size)
113
+ : Math.floor(dataBase64.length * 3 / 4);
114
+ if (!name || !dataBase64 || byteLength < 0) {
115
+ continue;
116
+ }
117
+ totalBytes += byteLength;
118
+ if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
119
+ return { ok: false, error: 'file-transfer-too-large', files: [], totalBytes };
120
+ }
121
+ files.push({
122
+ name,
123
+ relativePath,
124
+ size: byteLength,
125
+ mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
126
+ lastModified: Number(entry?.lastModified || 0) || 0,
127
+ dataBase64
128
+ });
129
+ }
130
+ if (files.length === 0) {
131
+ return { ok: false, error: 'no-files', files: [], totalBytes: 0 };
132
+ }
133
+ return { ok: true, files, totalBytes };
134
+ }
135
+
136
+ function normalizeLiveOptions(payload = {}) {
137
+ const mode = String(payload.frameMode || payload.mode || 'remote-fast').trim() || 'remote-fast';
138
+ return {
139
+ fps: clampNumber(payload.fps, 1, 24, 12),
140
+ maxWidth: clampNumber(payload.maxWidth, 320, 2560, 960),
141
+ maxHeight: clampNumber(payload.maxHeight, 180, 1440, 540),
142
+ quality: clampNumber(payload.quality, 20, 95, 60),
143
+ mode,
144
+ frameMode: mode
145
+ };
146
+ }
147
+
148
+ function sendJson(ws, payload) {
149
+ if (!ws || ws.readyState !== 1) {
150
+ return false;
151
+ }
152
+ ws.send(JSON.stringify(payload));
153
+ return true;
154
+ }
155
+
156
+ function updateFrameSubscription(ws, payload = {}) {
157
+ const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
158
+ ws.liveDeskDeviceIds = new Set(deviceIds);
159
+ ws.liveDeskAutoStart = /^(1|true|yes|on|live)$/i.test(String(payload.autoStartLive ?? payload.startLive ?? ''));
160
+ ws.liveDeskLiveOptions = normalizeLiveOptions(payload);
161
+ sendJson(ws, {
162
+ type: 'RemoteFrameSubscription',
163
+ timestamp: new Date().toISOString(),
164
+ deviceIds,
165
+ autoStartLive: ws.liveDeskAutoStart
166
+ });
167
+
168
+ if (ws.liveDeskAutoStart && deviceIds.length > 0) {
169
+ const lookup = new Map(remoteHub.listDevices({ includeDataUrl: false }).map(device => [device.deviceId, device]));
170
+ const started = [];
171
+ const skipped = [];
172
+ for (const deviceId of deviceIds.slice(0, 80)) {
173
+ const device = lookup.get(deviceId);
174
+ const liveCapable = device?.liveStreamEnabled === true
175
+ || device?.liveStreamCapable === true
176
+ || device?.capabilities?.liveStream === true
177
+ || device?.capabilities?.stream === true
178
+ || device?.capabilities?.screenStream === true;
179
+ if (!device?.connected || !liveCapable) {
180
+ skipped.push({ deviceId, reason: device?.connected ? 'live-unavailable' : 'not-connected' });
181
+ continue;
182
+ }
183
+ const result = remoteHub.startLiveStream(deviceId, {
184
+ ...ws.liveDeskLiveOptions,
185
+ streamId: `livedesk-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
186
+ });
187
+ if (result?.ok) {
188
+ started.push({ deviceId, streamId: result.streamId, fps: result.fps });
189
+ } else {
190
+ skipped.push({ deviceId, reason: result?.error || 'start-failed' });
191
+ }
192
+ }
193
+ sendJson(ws, {
194
+ type: 'RemoteFrameLiveAutoStart',
195
+ timestamp: new Date().toISOString(),
196
+ started,
197
+ skipped: skipped.slice(0, 24)
198
+ });
199
+ }
200
+ }
201
+
202
+ function updateAudioSubscription(ws, payload = {}) {
203
+ const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
204
+ ws.liveDeskAudioDeviceIds = new Set(deviceIds);
205
+ sendJson(ws, {
206
+ type: 'RemoteAudioSubscription',
207
+ timestamp: new Date().toISOString(),
208
+ deviceIds
209
+ });
210
+ }
211
+
212
+ function buildRemoteFrameBinaryPacket(frameEvent) {
213
+ const payload = frameEvent?.payload;
214
+ if (!Buffer.isBuffer(payload) || payload.length === 0) {
215
+ return null;
216
+ }
217
+ const frame = frameEvent.frame || {};
218
+ const metadata = {
219
+ type: 'remote.frame.binary',
220
+ kind: frameEvent.kind || 'live',
221
+ deviceId: frameEvent.deviceId || frame.deviceId || '',
222
+ frameSeq: Number(frame.frameSeq || 0) || 0,
223
+ streamId: frame.streamId || '',
224
+ width: Number(frame.width || 0) || 0,
225
+ height: Number(frame.height || 0) || 0,
226
+ mimeType: frameEvent.mimeType || frame.mimeType || frame.format || 'image/jpeg',
227
+ mode: frame.mode || '',
228
+ frameMode: frame.frameMode || frame.mode || '',
229
+ frameProfile: frame.frameProfile || '',
230
+ encoding: frame.encoding || '',
231
+ codec: frame.codec || '',
232
+ compression: frame.compression || frame.mimeType || frame.format || '',
233
+ fps: Number(frame.fps || 0) || 0,
234
+ capturedAt: frame.capturedAt || '',
235
+ receivedAt: frame.receivedAt || '',
236
+ byteLength: Number(frameEvent.byteLength || payload.length) || payload.length,
237
+ contentHash: frame.contentHash || '',
238
+ captureMs: Number(frame.captureMs || 0) || 0,
239
+ sameContentStreak: Number(frame.sameContentStreak || 0) || 0
240
+ };
241
+ const metaBuffer = Buffer.from(JSON.stringify(metadata), 'utf8');
242
+ const header = Buffer.allocUnsafe(4);
243
+ header.writeUInt32BE(metaBuffer.length, 0);
244
+ return Buffer.concat([header, metaBuffer, payload], 4 + metaBuffer.length + payload.length);
245
+ }
246
+
247
+ function buildRemoteAudioBinaryPacket(audioEvent) {
248
+ const payload = audioEvent?.payload;
249
+ if (!Buffer.isBuffer(payload) || payload.length === 0) {
250
+ return null;
251
+ }
252
+ const frame = audioEvent.frame || {};
253
+ const metadata = {
254
+ type: 'remote.audio.binary',
255
+ deviceId: audioEvent.deviceId || frame.deviceId || '',
256
+ frameSeq: Number(frame.frameSeq || 0) || 0,
257
+ streamId: frame.streamId || '',
258
+ mimeType: audioEvent.mimeType || frame.mimeType || frame.format || 'audio/webm;codecs=opus',
259
+ sampleRate: Number(frame.sampleRate || 0) || 48000,
260
+ channels: Number(frame.channels || 0) || 2,
261
+ capturedAt: frame.capturedAt || '',
262
+ receivedAt: frame.receivedAt || '',
263
+ byteLength: Number(audioEvent.byteLength || payload.length) || payload.length
264
+ };
265
+ const metaBuffer = Buffer.from(JSON.stringify(metadata), 'utf8');
266
+ const header = Buffer.allocUnsafe(4);
267
+ header.writeUInt32BE(metaBuffer.length, 0);
268
+ return Buffer.concat([header, metaBuffer, payload], 4 + metaBuffer.length + payload.length);
269
+ }
270
+
271
+ function broadcastRemoteBinaryFrame(frameEvent) {
272
+ const deviceId = String(frameEvent?.deviceId || frameEvent?.frame?.deviceId || '').trim();
273
+ if (!deviceId || frameClients.size === 0) {
274
+ return;
275
+ }
276
+ const packet = buildRemoteFrameBinaryPacket(frameEvent);
277
+ if (!packet) {
278
+ return;
279
+ }
280
+ for (const client of frameClients) {
281
+ if (client.readyState !== client.OPEN) {
282
+ continue;
283
+ }
284
+ if (client.liveDeskDeviceIds?.size > 0 && !client.liveDeskDeviceIds.has(deviceId)) {
285
+ continue;
286
+ }
287
+ if (client.bufferedAmount > 8 * 1024 * 1024) {
288
+ continue;
289
+ }
290
+ client.send(packet, { binary: true });
291
+ }
292
+ }
293
+
294
+ function broadcastRemoteBinaryAudio(audioEvent) {
295
+ const deviceId = String(audioEvent?.deviceId || audioEvent?.frame?.deviceId || '').trim();
296
+ if (!deviceId || audioClients.size === 0) {
297
+ return;
298
+ }
299
+ const packet = buildRemoteAudioBinaryPacket(audioEvent);
300
+ if (!packet) {
301
+ return;
302
+ }
303
+ for (const client of audioClients) {
304
+ if (client.readyState !== client.OPEN) {
305
+ continue;
306
+ }
307
+ if (client.liveDeskAudioDeviceIds?.size > 0 && !client.liveDeskAudioDeviceIds.has(deviceId)) {
308
+ continue;
309
+ }
310
+ if (client.bufferedAmount > 2 * 1024 * 1024) {
311
+ continue;
312
+ }
313
+ client.send(packet, { binary: true });
314
+ }
315
+ }
316
+
317
+ function wantsBinaryFrame(req) {
318
+ return /^(1|true|yes|on|binary|raw)$/i.test(String(req.query?.binary ?? req.query?.raw ?? ''))
319
+ || /image\//i.test(String(req.headers.accept || ''));
320
+ }
321
+
322
+ function sendFrameBinary(req, res, kind) {
323
+ const payload = remoteHub.getFramePayload(req.params.deviceId, kind, {
324
+ token: req.query?.token,
325
+ frameSeq: req.query?.seq,
326
+ requireToken: false
327
+ });
328
+ if (!payload) {
329
+ res.status(404).json({ ok: false, error: 'frame-payload-not-available' });
330
+ return;
331
+ }
332
+ noStore(res);
333
+ res.setHeader('Content-Type', payload.mimeType);
334
+ res.setHeader('Content-Length', String(payload.byteLength));
335
+ res.send(payload.payload);
336
+ }
337
+
338
+ function isCapabilityEnabled(device, key) {
339
+ const value = device?.capabilities?.[key];
340
+ if (typeof value === 'boolean') {
341
+ return value;
342
+ }
343
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
344
+ }
345
+
346
+ app.get('/api/health', (_req, res) => {
347
+ noStore(res);
348
+ res.json({ ok: true, product: 'LiveDesk', timestamp: new Date().toISOString() });
349
+ });
350
+
351
+ app.get('/api/remote/status', (_req, res) => {
352
+ noStore(res);
353
+ res.json({
354
+ ...remoteHub.getStatus({ includeSecrets: true }),
355
+ product: 'LiveDesk',
356
+ agentPackage: '@livedesk/client'
357
+ });
358
+ });
359
+
360
+ app.get('/api/remote/devices', (req, res) => {
361
+ noStore(res);
362
+ const includeDataUrl = /^(1|true|yes|on|data|base64)$/i.test(String(req.query?.includeDataUrl ?? ''));
363
+ const devices = remoteHub.listDevices({ includeDataUrl });
364
+ res.json({
365
+ total: devices.length,
366
+ pagination: 'none',
367
+ latestTaskBatch: remoteHub.getLatestTaskBatch(),
368
+ recentTaskBatches: remoteHub.listTaskBatches(),
369
+ devices
370
+ });
371
+ });
372
+
373
+ app.post('/api/remote/frames', (req, res) => {
374
+ noStore(res);
375
+ res.json({
376
+ ok: true,
377
+ frames: remoteHub.listDeviceFrames({
378
+ deviceIds: normalizeDeviceIds(req.body?.deviceIds),
379
+ includeThumbnail: req.body?.includeThumbnail !== false,
380
+ includeLive: req.body?.includeLive !== false
381
+ })
382
+ });
383
+ });
384
+
385
+ app.post('/api/remote/host-target', async (req, res) => {
386
+ noStore(res);
387
+ try {
388
+ res.json(await remoteHub.setHostTarget({
389
+ enabled: req.body?.enabled !== false,
390
+ nodeId: req.body?.nodeId,
391
+ leaseMs: req.body?.leaseMs,
392
+ takeover: req.body?.takeover === true
393
+ }));
394
+ } catch (err) {
395
+ res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
396
+ }
397
+ });
398
+
399
+ app.delete('/api/remote/host-target', async (req, res) => {
400
+ noStore(res);
401
+ try {
402
+ res.json(await remoteHub.setHostTarget({
403
+ enabled: false,
404
+ nodeId: req.body?.nodeId
405
+ }));
406
+ } catch (err) {
407
+ res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
408
+ }
409
+ });
410
+
411
+ app.post('/api/remote/synthetic/seed', (req, res) => {
412
+ noStore(res);
413
+ res.json(remoteHub.seedSyntheticFleet({
414
+ count: req.body?.count,
415
+ connectedRatio: req.body?.connectedRatio,
416
+ thumbnailRatio: req.body?.thumbnailRatio,
417
+ aiAssistRatio: req.body?.aiAssistRatio,
418
+ liveCount: req.body?.liveCount,
419
+ replace: req.body?.replace
420
+ }));
421
+ });
422
+
423
+ app.delete('/api/remote/synthetic', (_req, res) => {
424
+ noStore(res);
425
+ res.json(remoteHub.clearSyntheticFleet());
426
+ });
427
+
428
+ app.post('/api/remote/devices/:deviceId/disconnect', (req, res) => {
429
+ noStore(res);
430
+ res.json({ ok: remoteHub.disconnectDevice(req.params.deviceId, 'manager-request') });
431
+ });
432
+
433
+ app.post('/api/remote/devices/:deviceId/ping', (req, res) => {
434
+ noStore(res);
435
+ res.json(remoteHub.sendCommand(req.params.deviceId, {
436
+ command: 'ping',
437
+ payload: { requestedAt: new Date().toISOString() }
438
+ }));
439
+ });
440
+
441
+ app.post('/api/remote/devices/:deviceId/input', (req, res) => {
442
+ noStore(res);
443
+ res.json(remoteHub.sendInputControl(req.params.deviceId, req.body || {}));
444
+ });
445
+
446
+ app.post('/api/remote/files/transfer', (req, res) => {
447
+ noStore(res);
448
+ const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
449
+ if (deviceIds.length === 0) {
450
+ res.status(400).json({ ok: false, error: 'no-target-devices' });
451
+ return;
452
+ }
453
+ const normalized = normalizeTransferFiles(req.body?.files);
454
+ if (!normalized.ok) {
455
+ res.status(400).json({ ok: false, error: normalized.error, totalBytes: normalized.totalBytes || 0 });
456
+ return;
457
+ }
458
+ const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
459
+ const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
460
+ const queuedAt = new Date().toISOString();
461
+ const results = deviceIds.map(deviceId => ({
462
+ deviceId,
463
+ ...remoteHub.sendCommand(deviceId, {
464
+ command: 'file.transfer',
465
+ payload: {
466
+ transferId,
467
+ remoteDirectory,
468
+ files: normalized.files,
469
+ totalBytes: normalized.totalBytes,
470
+ requestedAt: queuedAt
471
+ }
472
+ })
473
+ }));
474
+ const queued = results.filter(result => result.ok).length;
475
+ res.json({
476
+ ok: queued > 0,
477
+ transferId,
478
+ queued,
479
+ total: deviceIds.length,
480
+ totalBytes: normalized.totalBytes,
481
+ files: normalized.files.length,
482
+ results,
483
+ error: queued > 0 ? undefined : 'no-transfer-queued'
484
+ });
485
+ });
486
+
487
+ app.post('/api/remote/devices/:deviceId/tasks', (req, res) => {
488
+ noStore(res);
489
+ res.json(remoteHub.requestAgentTask(req.params.deviceId, {
490
+ instruction: req.body?.instruction,
491
+ title: req.body?.title,
492
+ taskId: req.body?.taskId,
493
+ commandId: req.body?.commandId,
494
+ approvalLevel: req.body?.approvalLevel,
495
+ model: req.body?.model
496
+ }));
497
+ });
498
+
499
+ app.post('/api/remote/tasks', (req, res) => {
500
+ noStore(res);
501
+ const requestedIds = normalizeDeviceIds(req.body?.deviceIds);
502
+ const approvalLevel = req.body?.approvalLevel === 'ai-assist' ? 'ai-assist' : 'task-only';
503
+ const devices = remoteHub.listDevices({ includeDataUrl: false });
504
+ const targetIds = requestedIds.length > 0
505
+ ? requestedIds
506
+ : devices
507
+ .filter(device => device.connected)
508
+ .filter(device => approvalLevel !== 'ai-assist' || isCapabilityEnabled(device, 'aiAssist'))
509
+ .map(device => device.deviceId);
510
+ const result = remoteHub.requestAgentTaskBatch([...new Set(targetIds)].slice(0, 500), {
511
+ instruction: req.body?.instruction,
512
+ title: req.body?.title,
513
+ approvalLevel,
514
+ model: req.body?.model,
515
+ batchId: req.body?.batchId
516
+ });
517
+ res.json({
518
+ ...result,
519
+ ok: result.ok === true,
520
+ total: targetIds.length,
521
+ queued: result.queued || 0,
522
+ approvalLevel,
523
+ error: result.ok ? undefined : (targetIds.length === 0 ? 'no-target-devices' : (result.error || 'no-task-queued'))
524
+ });
525
+ });
526
+
527
+ app.get('/api/remote/devices/:deviceId/thumbnail', (req, res) => {
528
+ noStore(res);
529
+ if (wantsBinaryFrame(req)) {
530
+ sendFrameBinary(req, res, 'thumbnail');
531
+ return;
532
+ }
533
+ const thumbnail = remoteHub.getDeviceThumbnail(req.params.deviceId, {
534
+ includeDataUrl: /^(1|true|yes|on|data|base64)$/i.test(String(req.query?.includeDataUrl ?? ''))
535
+ });
536
+ if (!thumbnail) {
537
+ res.status(404).json({ ok: false, error: 'thumbnail-not-available' });
538
+ return;
539
+ }
540
+ res.json({ ok: true, thumbnail });
541
+ });
542
+
543
+ app.post('/api/remote/devices/:deviceId/thumbnail/request', (req, res) => {
544
+ noStore(res);
545
+ res.json(remoteHub.requestThumbnail(req.params.deviceId, req.body || {}));
546
+ });
547
+
548
+ app.get('/api/remote/devices/:deviceId/live/frame', (req, res) => {
549
+ noStore(res);
550
+ if (wantsBinaryFrame(req)) {
551
+ sendFrameBinary(req, res, 'live');
552
+ return;
553
+ }
554
+ const frame = remoteHub.getDeviceLiveFrame(req.params.deviceId, {
555
+ includeDataUrl: /^(1|true|yes|on|data|base64)$/i.test(String(req.query?.includeDataUrl ?? ''))
556
+ });
557
+ if (!frame) {
558
+ res.status(404).json({ ok: false, error: 'live-frame-not-available' });
559
+ return;
560
+ }
561
+ res.json({ ok: true, frame });
562
+ });
563
+
564
+ app.post('/api/remote/devices/:deviceId/live/start', (req, res) => {
565
+ noStore(res);
566
+ res.json(remoteHub.startLiveStream(req.params.deviceId, req.body || {}));
567
+ });
568
+
569
+ app.post('/api/remote/devices/:deviceId/live/stop', (req, res) => {
570
+ noStore(res);
571
+ res.json(remoteHub.stopLiveStream(req.params.deviceId, req.body || {}));
572
+ });
573
+
574
+ app.post('/api/remote/devices/:deviceId/audio/start', (req, res) => {
575
+ noStore(res);
576
+ res.json(remoteHub.startAudioStream(req.params.deviceId, req.body || {}));
577
+ });
578
+
579
+ app.post('/api/remote/devices/:deviceId/audio/stop', (req, res) => {
580
+ noStore(res);
581
+ res.json(remoteHub.stopAudioStream(req.params.deviceId, req.body || {}));
582
+ });
583
+
584
+ if (existsSync(webIndexPath)) {
585
+ app.use(express.static(webDistPath, {
586
+ etag: true,
587
+ index: false,
588
+ maxAge: '1h'
589
+ }));
590
+ app.get('*', (req, res, next) => {
591
+ if (req.path.startsWith('/api/')) {
592
+ next();
593
+ return;
594
+ }
595
+ res.sendFile(webIndexPath);
596
+ });
597
+ }
598
+
599
+ httpServer.on('upgrade', (req, socket, head) => {
600
+ try {
601
+ const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
602
+ if (parsed.pathname === '/api/remote/frames/ws') {
603
+ frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
604
+ return;
605
+ }
606
+ if (parsed.pathname === '/api/remote/input/ws') {
607
+ inputWss.handleUpgrade(req, socket, head, ws => inputWss.emit('connection', ws, req));
608
+ return;
609
+ }
610
+ if (parsed.pathname === '/api/remote/audio/ws') {
611
+ audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
612
+ return;
613
+ }
614
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
615
+ socket.destroy();
616
+ } catch {
617
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
618
+ socket.destroy();
619
+ }
620
+ });
621
+
622
+ frameWss.on('connection', (ws, req) => {
623
+ frameClients.add(ws);
624
+ ws.liveDeskFrameClientId = `rfws-${++frameClientSeq}`;
625
+ try {
626
+ const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
627
+ updateFrameSubscription(ws, { deviceIds: parsed.searchParams.get('devices') || '' });
628
+ } catch {
629
+ updateFrameSubscription(ws, {});
630
+ }
631
+ ws.on('message', data => {
632
+ try {
633
+ const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
634
+ if (payload?.type === 'subscribe') {
635
+ updateFrameSubscription(ws, payload);
636
+ }
637
+ } catch {
638
+ sendJson(ws, { type: 'RemoteFrameSubscriptionError', error: 'invalid-message' });
639
+ }
640
+ });
641
+ ws.on('close', () => frameClients.delete(ws));
642
+ ws.on('error', () => frameClients.delete(ws));
643
+ sendJson(ws, {
644
+ type: 'RemoteFrameSocketReady',
645
+ protocol: 'livedesk.remote.frames.binary.v1',
646
+ clientId: ws.liveDeskFrameClientId,
647
+ timestamp: new Date().toISOString()
648
+ });
649
+ });
650
+
651
+ audioWss.on('connection', (ws, req) => {
652
+ audioClients.add(ws);
653
+ ws.liveDeskAudioClientId = `raws-${++audioClientSeq}`;
654
+ try {
655
+ const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
656
+ updateAudioSubscription(ws, { deviceIds: parsed.searchParams.get('devices') || '' });
657
+ } catch {
658
+ updateAudioSubscription(ws, {});
659
+ }
660
+ ws.on('message', data => {
661
+ try {
662
+ const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
663
+ if (payload?.type === 'subscribe') {
664
+ updateAudioSubscription(ws, payload);
665
+ }
666
+ } catch {
667
+ sendJson(ws, { type: 'RemoteAudioSubscriptionError', error: 'invalid-message' });
668
+ }
669
+ });
670
+ ws.on('close', () => audioClients.delete(ws));
671
+ ws.on('error', () => audioClients.delete(ws));
672
+ sendJson(ws, {
673
+ type: 'RemoteAudioSocketReady',
674
+ protocol: 'livedesk.remote.audio.binary.v1',
675
+ clientId: ws.liveDeskAudioClientId,
676
+ timestamp: new Date().toISOString()
677
+ });
678
+ });
679
+
680
+ inputWss.on('connection', ws => {
681
+ inputClients.add(ws);
682
+ ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
683
+ ws.on('message', data => {
684
+ let payload;
685
+ try {
686
+ payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
687
+ } catch {
688
+ sendJson(ws, { type: 'RemoteInputError', error: 'invalid-json' });
689
+ return;
690
+ }
691
+ const deviceId = String(payload?.deviceId || '').trim();
692
+ const input = payload?.input && typeof payload.input === 'object' ? payload.input : payload;
693
+ const result = remoteHub.sendInputControl(deviceId, input);
694
+ sendJson(ws, {
695
+ type: result?.ok ? 'RemoteInputQueued' : 'RemoteInputError',
696
+ requestId: payload?.requestId || '',
697
+ deviceId,
698
+ result,
699
+ timestamp: new Date().toISOString()
700
+ });
701
+ });
702
+ ws.on('close', () => inputClients.delete(ws));
703
+ ws.on('error', () => inputClients.delete(ws));
704
+ sendJson(ws, {
705
+ type: 'RemoteInputSocketReady',
706
+ protocol: 'livedesk.remote.input.json.v1',
707
+ clientId: ws.liveDeskInputClientId,
708
+ timestamp: new Date().toISOString()
709
+ });
710
+ });
711
+
712
+ await remoteHub.start();
713
+ httpServer.listen(httpPort, httpHost, () => {
714
+ const status = remoteHub.getStatus({ includeSecrets: true });
715
+ console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
716
+ console.log(`[LiveDesk Hub] RemoteHub ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
717
+ });
718
+
719
+ for (const signal of ['SIGINT', 'SIGTERM']) {
720
+ process.once(signal, async () => {
721
+ await remoteHub.close();
722
+ httpServer.close(() => process.exit(0));
723
+ });
724
+ }