@livedesk/hub 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import cors from 'cors';
4
3
  import express from 'express';
5
4
  import crypto from 'node:crypto';
6
5
  import { createServer } from 'node:http';
@@ -9,6 +8,11 @@ import { dirname, resolve } from 'node:path';
9
8
  import { fileURLToPath } from 'node:url';
10
9
  import { WebSocketServer } from 'ws';
11
10
  import { createRemoteHub } from './remote-hub.js';
11
+ import { Mode4AtlasSession } from './mode4-atlas.js';
12
+ import { createHubFilesystem } from './filesystem/hub-filesystem.js';
13
+ import { createHubTransferJobs } from './filesystem/transfer-jobs.js';
14
+ import { createHubSharedFolders } from './filesystem/shared-folders.js';
15
+ import { toFilesystemHttpError } from './filesystem/directory-reader.js';
12
16
 
13
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
18
  const webDistCandidates = [
@@ -19,15 +23,173 @@ const webDistCandidates = [
19
23
  const webDistPath = webDistCandidates.find(candidate => existsSync(resolve(candidate, 'index.html'))) || webDistCandidates[webDistCandidates.length - 1];
20
24
  const webIndexPath = resolve(webDistPath, 'index.html');
21
25
  const packageInfo = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
22
- const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '0.0.0.0';
26
+ const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '127.0.0.1';
23
27
  const httpPort = Number(process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT || 5179);
28
+ const frameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_BACKPRESSURE_BYTES', 64 * 1024 * 1024);
29
+ const frameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_QUEUE_PACKETS', 256);
30
+ const frameClientDrainBudgetPackets = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_DRAIN_BUDGET_PACKETS', 64);
31
+ const mode5FrameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_MODE5_WS_BACKPRESSURE_BYTES', 2 * 1024 * 1024);
32
+ const mode5FrameQueuePackets = readPositiveIntegerEnv('LIVEDESK_MODE5_WS_QUEUE_PACKETS', 2);
24
33
  const frameClients = new Set();
34
+ const frameClientsByDeviceId = new Map();
35
+ const frameWildcardClients = new Set();
36
+ const atlasClients = new Map();
25
37
  const inputClients = new Set();
26
38
  const audioClients = new Set();
39
+ const FREE_DEVICE_LIMIT = 5;
40
+ const PLUS_DEVICE_LIMIT = 30;
41
+ const LICENSE_VERIFY_MAX_AGE_MS = 6 * 60 * 60 * 1000;
42
+ const supabaseUrl = String(process.env.LIVEDESK_SUPABASE_URL || 'https://otbyfkjxrkngvjziawki.supabase.co').replace(/\/+$/, '');
43
+ const supabasePublishableKey = String(process.env.LIVEDESK_SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_NpUs0RDJH2YnllsqTKO6TQ_1jTdSsNQ');
44
+ const testLicensePlan = process.env.LIVEDESK_TEST_MODE === '1'
45
+ && ['ltd', 'pro'].includes(String(process.env.LIVEDESK_TEST_LICENSE_PLAN || '').toLowerCase())
46
+ ? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
47
+ : '';
48
+ let connectedDeviceCount = 0;
49
+ let verifiedLicense = {
50
+ userId: '',
51
+ plan: 'free',
52
+ status: 'inactive',
53
+ expiresAt: '',
54
+ verifiedAt: 0
55
+ };
27
56
  let frameClientSeq = 0;
28
57
  let inputClientSeq = 0;
29
58
  let audioClientSeq = 0;
30
59
 
60
+ function readPositiveIntegerEnv(name, fallback) {
61
+ const value = Number(process.env[name]);
62
+ return Number.isFinite(value) && value > 0 ? Math.round(value) : fallback;
63
+ }
64
+
65
+ function handleRemoteHubEvent(type, event) {
66
+ if (type === 'RemoteInputApplied') {
67
+ for (const ws of inputClients) {
68
+ sendJson(ws, {
69
+ type: 'RemoteInputApplied',
70
+ ...event
71
+ });
72
+ }
73
+ return;
74
+ }
75
+ if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
76
+ connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
77
+ }
78
+ if (type !== 'RemoteDeviceConnected') {
79
+ return;
80
+ }
81
+ const deviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
82
+ if (!deviceId) {
83
+ return;
84
+ }
85
+ for (const ws of atlasClients.keys()) {
86
+ if (ws.readyState === 1 && ws.liveDeskAtlasDeviceIds?.has(deviceId)) {
87
+ startMode4AtlasInput(ws, deviceId, 'device-connected');
88
+ }
89
+ }
90
+ const clients = new Set([
91
+ ...(frameClientsByDeviceId.get(deviceId) || []),
92
+ ...frameWildcardClients
93
+ ]);
94
+ for (const ws of clients) {
95
+ if (ws.readyState === 1
96
+ && ws.liveDeskAutoStart
97
+ && (!ws.liveDeskDeviceIds?.size || ws.liveDeskDeviceIds.has(deviceId))) {
98
+ startFrameSubscriptionLive(ws, 'device-reconnected', deviceId);
99
+ }
100
+ }
101
+ }
102
+
103
+ function activeLicensePlan() {
104
+ if (testLicensePlan) {
105
+ return testLicensePlan;
106
+ }
107
+ const now = Date.now();
108
+ if (verifiedLicense.status !== 'active'
109
+ || now - verifiedLicense.verifiedAt > LICENSE_VERIFY_MAX_AGE_MS) {
110
+ return 'free';
111
+ }
112
+ const expiresAt = verifiedLicense.expiresAt ? Date.parse(verifiedLicense.expiresAt) : Number.POSITIVE_INFINITY;
113
+ if (Number.isFinite(expiresAt) && expiresAt <= now) {
114
+ return 'free';
115
+ }
116
+ return verifiedLicense.plan === 'pro' ? 'pro' : verifiedLicense.plan === 'ltd' ? 'ltd' : 'free';
117
+ }
118
+
119
+ function activeDeviceLimit() {
120
+ const plan = activeLicensePlan();
121
+ return plan === 'pro' ? Number.POSITIVE_INFINITY : plan === 'ltd' ? PLUS_DEVICE_LIMIT : FREE_DEVICE_LIMIT;
122
+ }
123
+
124
+ function hasHubFeatureAccess() {
125
+ return connectedDeviceCount <= activeDeviceLimit();
126
+ }
127
+
128
+ function licenseSnapshot() {
129
+ const plan = activeLicensePlan();
130
+ const limit = activeDeviceLimit();
131
+ return {
132
+ plan,
133
+ status: plan === 'free' ? 'free' : 'active',
134
+ deviceLimit: Number.isFinite(limit) ? limit : null,
135
+ connectedDeviceCount,
136
+ featureAccess: connectedDeviceCount <= limit,
137
+ verifiedAt: verifiedLicense.verifiedAt ? new Date(verifiedLicense.verifiedAt).toISOString() : '',
138
+ expiresAt: verifiedLicense.expiresAt || ''
139
+ };
140
+ }
141
+
142
+ async function syncVerifiedLicense(accessToken) {
143
+ const token = String(accessToken || '').trim();
144
+ if (!token) {
145
+ throw new Error('supabase-access-token-required');
146
+ }
147
+ const headers = {
148
+ apikey: supabasePublishableKey,
149
+ Authorization: `Bearer ${token}`,
150
+ Accept: 'application/json'
151
+ };
152
+ const userResponse = await fetch(`${supabaseUrl}/auth/v1/user`, { headers });
153
+ if (!userResponse.ok) {
154
+ throw new Error(`supabase-user-verification-failed:${userResponse.status}`);
155
+ }
156
+ const user = await userResponse.json();
157
+ const userId = String(user?.id || '').trim();
158
+ if (!userId) {
159
+ throw new Error('supabase-user-missing');
160
+ }
161
+ const query = new URLSearchParams({
162
+ select: 'user_id,product_key,plan,status,expires_at,updated_at',
163
+ user_id: `eq.${userId}`,
164
+ product_key: 'eq.livedesk',
165
+ limit: '1'
166
+ });
167
+ const entitlementResponse = await fetch(`${supabaseUrl}/rest/v1/livedesk_entitlements?${query}`, { headers });
168
+ if (!entitlementResponse.ok) {
169
+ throw new Error(`supabase-entitlement-query-failed:${entitlementResponse.status}`);
170
+ }
171
+ const rows = await entitlementResponse.json();
172
+ const entitlement = Array.isArray(rows) ? rows[0] : null;
173
+ const plan = entitlement?.plan === 'pro' ? 'pro' : entitlement?.plan === 'ltd' ? 'ltd' : 'free';
174
+ const status = entitlement?.status === 'active' ? 'active' : 'inactive';
175
+ verifiedLicense = {
176
+ userId,
177
+ plan,
178
+ status,
179
+ expiresAt: String(entitlement?.expires_at || ''),
180
+ verifiedAt: Date.now()
181
+ };
182
+ return licenseSnapshot();
183
+ }
184
+
185
+ function requireHubFeatureAccess(_req, res, next) {
186
+ if (hasHubFeatureAccess()) {
187
+ next();
188
+ return;
189
+ }
190
+ res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
191
+ }
192
+
31
193
  const remoteHub = createRemoteHub({
32
194
  managerPackage: '@livedesk/hub',
33
195
  managerVersion: packageInfo.version,
@@ -38,39 +200,125 @@ const remoteHub = createRemoteHub({
38
200
  },
39
201
  logEvent: (_scope, message) => console.log(`[LiveDesk Hub] ${message}`),
40
202
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
203
+ emitEvent: handleRemoteHubEvent,
41
204
  emitFrame: broadcastRemoteBinaryFrame,
42
205
  emitAudio: broadcastRemoteBinaryAudio
43
206
  });
207
+ const hubFilesystem = createHubFilesystem();
208
+ const hubTransferJobs = createHubTransferJobs({
209
+ filesystem: hubFilesystem,
210
+ remoteHub,
211
+ maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2)
212
+ });
213
+ const hubSharedFolders = createHubSharedFolders({
214
+ filesystem: hubFilesystem,
215
+ transferJobs: hubTransferJobs,
216
+ remoteHub
217
+ });
44
218
 
45
219
  const app = express();
46
220
  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 });
221
+ const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
222
+ const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
223
+ const inputWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
224
+ const audioWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
225
+
226
+ function normalizeHostname(value) {
227
+ return String(value || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
228
+ }
229
+
230
+ function isLoopbackHostname(value) {
231
+ const hostname = normalizeHostname(value);
232
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
233
+ }
234
+
235
+ function isPrivateHubHostname(value) {
236
+ const hostname = normalizeHostname(value);
237
+ if (isLoopbackHostname(hostname)
238
+ || hostname === '0.0.0.0'
239
+ || hostname.startsWith('10.')
240
+ || hostname.startsWith('192.168.')
241
+ || hostname.startsWith('169.254.')
242
+ || hostname.startsWith('fc')
243
+ || hostname.startsWith('fd')
244
+ || hostname.startsWith('fe80:')) {
245
+ return true;
246
+ }
247
+ const match = /^(172)\.(\d{1,3})\./.exec(hostname);
248
+ return !!match && Number(match[2]) >= 16 && Number(match[2]) <= 31;
249
+ }
250
+
251
+ function isLoopbackAddress(value) {
252
+ const address = String(value || '').trim().toLowerCase();
253
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
254
+ }
255
+
256
+ function requestHostname(req) {
257
+ try {
258
+ return normalizeHostname(new URL(`http://${req.headers.host || '127.0.0.1'}`).hostname);
259
+ } catch {
260
+ return '';
261
+ }
262
+ }
263
+
264
+ function isTrustedBrowserRequest(req) {
265
+ const origin = String(req.headers.origin || '').trim();
266
+ if (!origin) {
267
+ const fetchSite = String(req.headers['sec-fetch-site'] || '').toLowerCase();
268
+ return isLoopbackAddress(req.socket?.remoteAddress)
269
+ || fetchSite === 'same-origin'
270
+ || fetchSite === 'none';
271
+ }
272
+ try {
273
+ const parsed = new URL(origin);
274
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
275
+ return false;
276
+ }
277
+ const originHost = normalizeHostname(parsed.hostname);
278
+ const targetHost = requestHostname(req);
279
+ return (originHost === targetHost && isPrivateHubHostname(targetHost))
280
+ || (isLoopbackHostname(originHost) && isLoopbackHostname(targetHost));
281
+ } catch {
282
+ return false;
283
+ }
284
+ }
50
285
 
51
286
  app.use((req, res, next) => {
52
- if (req.headers.origin) {
287
+ if (!isTrustedBrowserRequest(req)) {
288
+ res.status(403).json({ ok: false, error: 'untrusted-hub-origin' });
289
+ return;
290
+ }
291
+ const origin = String(req.headers.origin || '').trim();
292
+ if (origin) {
293
+ res.setHeader('Access-Control-Allow-Origin', origin);
53
294
  res.setHeader('Access-Control-Allow-Private-Network', 'true');
295
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
296
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
54
297
  res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
55
298
  }
299
+ if (req.method === 'OPTIONS') {
300
+ res.status(204).end();
301
+ return;
302
+ }
56
303
  next();
57
304
  });
58
-
59
- app.use(cors({
60
- origin: true,
61
- credentials: false,
62
- methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
63
- allowedHeaders: ['Content-Type']
64
- }));
65
305
  app.use(express.json({ limit: '32mb' }));
66
306
 
67
307
  const MAX_FILE_TRANSFER_FILES = 24;
68
308
  const MAX_FILE_TRANSFER_BYTES = 24 * 1024 * 1024;
309
+ const MAX_FILE_TRANSFER_CHUNK_BYTES = 512 * 1024;
69
310
 
70
311
  function noStore(res) {
71
312
  res.setHeader('Cache-Control', 'no-store');
72
313
  }
73
314
 
315
+ function sendFilesystemError(res, error) {
316
+ const normalized = toFilesystemHttpError(error);
317
+ const code = String(normalized.code || 'FILESYSTEM_ERROR').toLowerCase();
318
+ const status = Number(normalized.status || 500);
319
+ res.status(status).json({ ok: false, error: code });
320
+ }
321
+
74
322
  function clampNumber(value, min, max, fallback) {
75
323
  const number = Number(value);
76
324
  if (!Number.isFinite(number)) {
@@ -79,6 +327,24 @@ function clampNumber(value, min, max, fallback) {
79
327
  return Math.max(min, Math.min(max, Math.round(number)));
80
328
  }
81
329
 
330
+ function normalizeMonitorIndex(value, fallback = 0) {
331
+ return clampNumber(value, 0, 63, fallback);
332
+ }
333
+
334
+ function normalizeMonitorSelections(value) {
335
+ const result = {};
336
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
337
+ return result;
338
+ }
339
+ for (const [deviceId, monitorIndex] of Object.entries(value)) {
340
+ const id = String(deviceId || '').trim();
341
+ if (id) {
342
+ result[id] = normalizeMonitorIndex(monitorIndex);
343
+ }
344
+ }
345
+ return result;
346
+ }
347
+
82
348
  function normalizeDeviceIds(value) {
83
349
  const raw = Array.isArray(value)
84
350
  ? value
@@ -133,13 +399,55 @@ function normalizeTransferFiles(value) {
133
399
  return { ok: true, files, totalBytes };
134
400
  }
135
401
 
402
+ function normalizeTransferChunk(body = {}) {
403
+ const dataBase64 = String(body.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
404
+ const name = String(body.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
405
+ const relativePath = String(body.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
406
+ const offset = Math.max(0, Math.floor(Number(body.offset) || 0));
407
+ const totalBytes = Math.max(0, Math.floor(Number(body.totalBytes) || 0));
408
+ const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
409
+ const final = body.final === true;
410
+
411
+ if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
412
+ return { ok: false, error: 'invalid-file-transfer-chunk' };
413
+ }
414
+ if (byteLength > MAX_FILE_TRANSFER_CHUNK_BYTES) {
415
+ return { ok: false, error: 'file-transfer-chunk-too-large', byteLength };
416
+ }
417
+ if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
418
+ return { ok: false, error: 'empty-file-transfer-chunk' };
419
+ }
420
+
421
+ return {
422
+ ok: true,
423
+ chunk: {
424
+ name,
425
+ relativePath,
426
+ offset,
427
+ totalBytes,
428
+ byteLength,
429
+ dataBase64,
430
+ final,
431
+ mimeType: String(body.type || body.mimeType || '').slice(0, 160),
432
+ lastModified: Number(body.lastModified || 0) || 0
433
+ }
434
+ };
435
+ }
436
+
136
437
  function normalizeLiveOptions(payload = {}) {
137
- const mode = String(payload.frameMode || payload.mode || 'remote-fast').trim() || 'remote-fast';
438
+ const mode = String(payload.frameMode || payload.mode || 'mode3-h264-hw').trim() || 'mode3-h264-hw';
439
+ const streamPurpose = String(payload.streamPurpose || payload.purpose || 'wall').trim().slice(0, 24) || 'wall';
440
+ const maxFps = streamPurpose === 'control' ? 60 : 30;
138
441
  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),
442
+ fps: clampNumber(payload.fps, 1, maxFps, 2),
443
+ maxWidth: clampNumber(payload.maxWidth, 320, 3840, 640),
444
+ maxHeight: clampNumber(payload.maxHeight, 180, 2160, 360),
445
+ quality: clampNumber(payload.quality, 20, 95, 45),
446
+ monitorIndex: normalizeMonitorIndex(payload.monitorIndex ?? payload.screenIndex ?? payload.displayIndex),
447
+ monitorSelections: normalizeMonitorSelections(payload.monitorSelections),
448
+ forceRestart: /^(1|true|yes|on)$/i.test(String(payload.forceRestart ?? '')),
449
+ reuseExisting: /^(1|true|yes|on)$/i.test(String(payload.reuseExisting ?? '')),
450
+ streamPurpose,
143
451
  mode,
144
452
  frameMode: mode
145
453
  };
@@ -149,15 +457,226 @@ function sendJson(ws, payload) {
149
457
  if (!ws || ws.readyState !== 1) {
150
458
  return false;
151
459
  }
152
- ws.send(JSON.stringify(payload));
460
+ return safeWebSocketSend(ws, JSON.stringify(payload));
461
+ }
462
+
463
+ function forgetWebSocketClient(ws) {
464
+ unregisterFrameClient(ws);
465
+ frameClients.delete(ws);
466
+ audioClients.delete(ws);
467
+ inputClients.delete(ws);
468
+ try {
469
+ ws.terminate?.();
470
+ } catch {
471
+ // Ignore cleanup failures.
472
+ }
473
+ }
474
+
475
+ function safeWebSocketSend(ws, payload, options = undefined) {
476
+ if (!ws || ws.readyState !== 1) {
477
+ return false;
478
+ }
479
+
480
+ try {
481
+ ws.send(payload, options, err => {
482
+ if (err) {
483
+ forgetWebSocketClient(ws);
484
+ }
485
+ });
486
+ return true;
487
+ } catch {
488
+ forgetWebSocketClient(ws);
489
+ return false;
490
+ }
491
+ }
492
+
493
+ function unregisterFrameClient(ws) {
494
+ if (!ws) {
495
+ return;
496
+ }
497
+ const ids = ws.liveDeskDeviceIds instanceof Set ? ws.liveDeskDeviceIds : new Set();
498
+ for (const deviceId of ids) {
499
+ const clients = frameClientsByDeviceId.get(deviceId);
500
+ if (!clients) {
501
+ continue;
502
+ }
503
+ clients.delete(ws);
504
+ if (clients.size === 0) {
505
+ frameClientsByDeviceId.delete(deviceId);
506
+ }
507
+ }
508
+ frameWildcardClients.delete(ws);
509
+ ws.liveDeskFrameSendLane?.queue?.splice?.(0);
510
+ }
511
+
512
+ function registerFrameClient(ws) {
513
+ if (!ws) {
514
+ return;
515
+ }
516
+ const ids = ws.liveDeskDeviceIds instanceof Set ? ws.liveDeskDeviceIds : new Set();
517
+ if (ids.size === 0) {
518
+ frameWildcardClients.add(ws);
519
+ return;
520
+ }
521
+ for (const deviceId of ids) {
522
+ let clients = frameClientsByDeviceId.get(deviceId);
523
+ if (!clients) {
524
+ clients = new Set();
525
+ frameClientsByDeviceId.set(deviceId, clients);
526
+ }
527
+ clients.add(ws);
528
+ }
529
+ }
530
+
531
+ function ensureFrameClientSendLane(ws) {
532
+ if (!ws.liveDeskFrameSendLane) {
533
+ ws.liveDeskFrameSendLane = {
534
+ queue: [],
535
+ draining: false,
536
+ dropped: 0,
537
+ awaitingKeyFrames: new Set()
538
+ };
539
+ }
540
+ return ws.liveDeskFrameSendLane;
541
+ }
542
+
543
+ function dropQueuedFrameForLane(lane) {
544
+ const deltaIndex = lane.queue.findIndex(item => item?.isKeyFrame !== true);
545
+ const dropIndex = deltaIndex >= 0 ? deltaIndex : 0;
546
+ const dropped = lane.queue.splice(dropIndex, 1);
547
+ lane.dropped += dropped.length;
548
+ const droppedItem = dropped[0];
549
+ if (droppedItem?.isH264 && droppedItem.deviceId) {
550
+ lane.awaitingKeyFrames.add(droppedItem.deviceId);
551
+ let recoveryKeySeen = false;
552
+ lane.queue = lane.queue.filter(item => {
553
+ if (!item?.isH264 || item.deviceId !== droppedItem.deviceId) {
554
+ return true;
555
+ }
556
+ if (item.isKeyFrame) {
557
+ recoveryKeySeen = true;
558
+ lane.awaitingKeyFrames.delete(item.deviceId);
559
+ return true;
560
+ }
561
+ if (recoveryKeySeen) {
562
+ return true;
563
+ }
564
+ lane.dropped += 1;
565
+ return false;
566
+ });
567
+ }
568
+ }
569
+
570
+ function dropQueuedMode5FrameForLane(lane, deviceId) {
571
+ const dropIndex = lane.queue.findIndex(item => item?.isMode5 && item.deviceId === deviceId);
572
+ if (dropIndex < 0) {
573
+ return false;
574
+ }
575
+ lane.queue.splice(dropIndex, 1);
576
+ lane.dropped += 1;
577
+ return true;
578
+ }
579
+
580
+ function drainFrameClientSendLane(ws) {
581
+ const lane = ws.liveDeskFrameSendLane;
582
+ if (!lane || lane.draining) {
583
+ return;
584
+ }
585
+ lane.draining = true;
586
+ setImmediate(() => {
587
+ lane.draining = false;
588
+ if (!ws || ws.readyState !== ws.OPEN) {
589
+ lane.queue.length = 0;
590
+ return;
591
+ }
592
+
593
+ let sent = 0;
594
+ while (lane.queue.length > 0 && sent < frameClientDrainBudgetPackets) {
595
+ if (ws.bufferedAmount > frameBackpressureBytes) {
596
+ ws.liveDeskFrameBackpressured = true;
597
+ break;
598
+ }
599
+ const item = lane.queue.shift();
600
+ if (!item) {
601
+ continue;
602
+ }
603
+ ws.liveDeskFrameBackpressured = false;
604
+ safeWebSocketSend(ws, item.packet, { binary: true });
605
+ sent += 1;
606
+ }
607
+
608
+ if (lane.queue.length > 0 && ws.readyState === ws.OPEN) {
609
+ drainFrameClientSendLane(ws);
610
+ }
611
+ });
612
+ }
613
+
614
+ function enqueueFramePacketForClient(ws, packet, meta) {
615
+ if (!ws || ws.readyState !== ws.OPEN) {
616
+ return false;
617
+ }
618
+ const lane = ensureFrameClientSendLane(ws);
619
+ if (meta.isH264 && meta.isKeyFrame) {
620
+ lane.awaitingKeyFrames.delete(meta.deviceId);
621
+ } else if (meta.isH264 && lane.awaitingKeyFrames.has(meta.deviceId)) {
622
+ lane.dropped += 1;
623
+ ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
624
+ return false;
625
+ }
626
+ const backpressureBytes = meta.isMode5 ? mode5FrameBackpressureBytes : frameBackpressureBytes;
627
+ if (ws.bufferedAmount > backpressureBytes) {
628
+ ws.liveDeskFrameBackpressured = true;
629
+ lane.dropped += 1;
630
+ if (meta.isH264 && meta.deviceId) {
631
+ lane.awaitingKeyFrames.add(meta.deviceId);
632
+ }
633
+ ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
634
+ return false;
635
+ }
636
+ if (meta.isMode5) {
637
+ let queuedMode5Packets = lane.queue.filter(item => item?.isMode5 && item.deviceId === meta.deviceId).length;
638
+ while (queuedMode5Packets >= mode5FrameQueuePackets) {
639
+ if (!dropQueuedMode5FrameForLane(lane, meta.deviceId)) {
640
+ break;
641
+ }
642
+ queuedMode5Packets -= 1;
643
+ ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
644
+ }
645
+ }
646
+ while (lane.queue.length >= frameClientQueuePackets) {
647
+ dropQueuedFrameForLane(lane);
648
+ ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
649
+ }
650
+ lane.queue.push({
651
+ packet,
652
+ deviceId: meta.deviceId,
653
+ frameSeq: meta.frameSeq,
654
+ isH264: meta.isH264,
655
+ isMode5: meta.isMode5,
656
+ isKeyFrame: meta.isKeyFrame
657
+ });
658
+ drainFrameClientSendLane(ws);
153
659
  return true;
154
660
  }
155
661
 
156
662
  function updateFrameSubscription(ws, payload = {}) {
663
+ const previousDeviceIds = ws.liveDeskDeviceIds instanceof Set
664
+ ? new Set(ws.liveDeskDeviceIds)
665
+ : new Set();
666
+ unregisterFrameClient(ws);
157
667
  const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
158
668
  ws.liveDeskDeviceIds = new Set(deviceIds);
669
+ registerFrameClient(ws);
159
670
  ws.liveDeskAutoStart = /^(1|true|yes|on|live)$/i.test(String(payload.autoStartLive ?? payload.startLive ?? ''));
160
671
  ws.liveDeskLiveOptions = normalizeLiveOptions(payload);
672
+ if (!(ws.liveDeskStreamIdsByDeviceId instanceof Map)) {
673
+ ws.liveDeskStreamIdsByDeviceId = new Map();
674
+ }
675
+ for (const subscribedDeviceId of ws.liveDeskStreamIdsByDeviceId.keys()) {
676
+ if (!ws.liveDeskDeviceIds.has(subscribedDeviceId)) {
677
+ ws.liveDeskStreamIdsByDeviceId.delete(subscribedDeviceId);
678
+ }
679
+ }
161
680
  sendJson(ws, {
162
681
  type: 'RemoteFrameSubscription',
163
682
  timestamp: new Date().toISOString(),
@@ -165,37 +684,97 @@ function updateFrameSubscription(ws, payload = {}) {
165
684
  autoStartLive: ws.liveDeskAutoStart
166
685
  });
167
686
 
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
- }
687
+ const newlySubscribedIds = deviceIds.filter(deviceId => !previousDeviceIds.has(deviceId));
688
+ const targetIds = newlySubscribedIds.length > 0 ? newlySubscribedIds : deviceIds;
689
+ for (const deviceId of targetIds.slice(0, 80)) {
690
+ startFrameSubscriptionLive(ws, 'subscribe', deviceId, { reuseExisting: true });
691
+ }
692
+ }
693
+
694
+ function startFrameSubscriptionLive(ws, reason = 'subscribe', onlyDeviceId = '', overrideLiveOptions = null) {
695
+ if (!hasHubFeatureAccess()) {
696
+ sendJson(ws, { type: 'RemoteFrameSubscriptionError', error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
697
+ return;
698
+ }
699
+ const subscribedIds = [...(ws.liveDeskDeviceIds || [])];
700
+ if (!ws.liveDeskAutoStart || subscribedIds.length === 0) {
701
+ return;
702
+ }
703
+
704
+ const targetIds = onlyDeviceId
705
+ ? subscribedIds.filter(deviceId => deviceId === onlyDeviceId)
706
+ : subscribedIds;
707
+ if (targetIds.length === 0) {
708
+ return;
709
+ }
710
+
711
+ const liveOptions = overrideLiveOptions && typeof overrideLiveOptions === 'object'
712
+ ? { ...ws.liveDeskLiveOptions, ...overrideLiveOptions }
713
+ : ws.liveDeskLiveOptions;
714
+ const lookup = new Map(remoteHub.listDevices({ includeDataUrl: false }).map(device => [device.deviceId, device]));
715
+ const started = [];
716
+ const skipped = [];
717
+ for (const deviceId of targetIds.slice(0, 80)) {
718
+ const device = lookup.get(deviceId);
719
+ const liveCapable = device?.liveStreamEnabled === true
720
+ || device?.liveStreamCapable === true
721
+ || device?.capabilities?.liveStream === true
722
+ || device?.capabilities?.stream === true
723
+ || device?.capabilities?.screenStream === true;
724
+ if (!device?.connected || !liveCapable) {
725
+ skipped.push({ deviceId, reason: device?.connected ? 'live-unavailable' : 'not-connected' });
726
+ continue;
192
727
  }
193
- sendJson(ws, {
194
- type: 'RemoteFrameLiveAutoStart',
195
- timestamp: new Date().toISOString(),
196
- started,
197
- skipped: skipped.slice(0, 24)
728
+ const monitorIndex = Object.prototype.hasOwnProperty.call(liveOptions.monitorSelections || {}, deviceId)
729
+ ? liveOptions.monitorSelections[deviceId]
730
+ : liveOptions.monitorIndex;
731
+ const result = remoteHub.startLiveStream(deviceId, {
732
+ ...liveOptions,
733
+ monitorIndex,
734
+ reuseExisting: liveOptions.forceRestart !== true
735
+ && (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
736
+ silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
198
737
  });
738
+ if (result?.ok) {
739
+ ws.liveDeskStreamIdsByDeviceId.set(deviceId, result.streamId);
740
+ started.push({ deviceId, streamId: result.streamId, fps: result.fps, reused: result.reused === true });
741
+ } else {
742
+ ws.liveDeskStreamIdsByDeviceId.delete(deviceId);
743
+ skipped.push({ deviceId, reason: result?.error || 'start-failed' });
744
+ }
745
+ }
746
+ if (reason === 'watchdog' && !started.some(item => item.reused !== true)) {
747
+ return;
748
+ }
749
+ sendJson(ws, {
750
+ type: 'RemoteFrameLiveAutoStart',
751
+ timestamp: new Date().toISOString(),
752
+ reason,
753
+ started,
754
+ skipped: skipped.slice(0, 24)
755
+ });
756
+ }
757
+
758
+ function restartFrameSubscriptionLive(ws, payload = {}) {
759
+ const requestedIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
760
+ const targetIds = requestedIds.length > 0
761
+ ? requestedIds
762
+ : [...(ws.liveDeskDeviceIds || [])];
763
+ if (targetIds.length === 0) {
764
+ sendJson(ws, { type: 'RemoteFrameSubscriptionError', error: 'restart-device-missing' });
765
+ return;
766
+ }
767
+ const restartOptions = {
768
+ ...normalizeLiveOptions({
769
+ ...ws.liveDeskLiveOptions,
770
+ ...payload
771
+ }),
772
+ forceRestart: true,
773
+ restartToken: String(payload.restartToken || `${Date.now()}`).slice(0, 128),
774
+ restartReason: String(payload.restartReason || payload.reason || 'browser-stale').slice(0, 128)
775
+ };
776
+ for (const deviceId of targetIds.slice(0, 16)) {
777
+ startFrameSubscriptionLive(ws, restartOptions.restartReason, deviceId, restartOptions);
199
778
  }
200
779
  }
201
780
 
@@ -209,20 +788,26 @@ function updateAudioSubscription(ws, payload = {}) {
209
788
  });
210
789
  }
211
790
 
212
- function buildRemoteFrameBinaryPacket(frameEvent) {
791
+ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
213
792
  const payload = frameEvent?.payload;
214
793
  if (!Buffer.isBuffer(payload) || payload.length === 0) {
215
794
  return null;
216
795
  }
217
796
  const frame = frameEvent.frame || {};
797
+ const hubPacketEpochMs = Date.now();
798
+ const hubPacketAt = new Date(hubPacketEpochMs).toISOString();
218
799
  const metadata = {
219
800
  type: 'remote.frame.binary',
220
801
  kind: frameEvent.kind || 'live',
221
802
  deviceId: frameEvent.deviceId || frame.deviceId || '',
222
803
  frameSeq: Number(frame.frameSeq || 0) || 0,
804
+ streamFrameSeq: Number(frame.streamFrameSeq || 0) || 0,
223
805
  streamId: frame.streamId || '',
806
+ commandId: frame.commandId || '',
224
807
  width: Number(frame.width || 0) || 0,
225
808
  height: Number(frame.height || 0) || 0,
809
+ sourceWidth: Number(frame.sourceWidth || 0) || 0,
810
+ sourceHeight: Number(frame.sourceHeight || 0) || 0,
226
811
  mimeType: frameEvent.mimeType || frame.mimeType || frame.format || 'image/jpeg',
227
812
  mode: frame.mode || '',
228
813
  frameMode: frame.frameMode || frame.mode || '',
@@ -230,13 +815,52 @@ function buildRemoteFrameBinaryPacket(frameEvent) {
230
815
  encoding: frame.encoding || '',
231
816
  codec: frame.codec || '',
232
817
  compression: frame.compression || frame.mimeType || frame.format || '',
818
+ pixelFormat: frame.pixelFormat || '',
819
+ bytesPerPixel: Number(frame.bytesPerPixel || 0) || 0,
820
+ uncompressedByteLength: Number(frame.uncompressedByteLength || 0) || 0,
821
+ h264Format: frame.h264Format || '',
822
+ isKeyFrame: frame.isKeyFrame === true,
823
+ chunkType: frame.chunkType || '',
824
+ timestampUs: Number(frame.timestampUs || 0) || 0,
825
+ durationUs: Number(frame.durationUs || 0) || 0,
826
+ sourceGapMs: Number(frame.sourceGapMs || 0) || 0,
827
+ agentSendGapMs: Number(frame.agentSendGapMs || 0) || 0,
828
+ agentSendDurationMs: Number(frame.agentSendDurationMs || 0) || 0,
829
+ agentSendDurationFrameSeq: Number(frame.agentSendDurationFrameSeq || 0) || 0,
830
+ agentPaceMs: Number(frame.agentPaceMs || 0) || 0,
831
+ agentQueueBeforePaceMs: Number(frame.agentQueueBeforePaceMs || 0) || 0,
832
+ agentFrameAgeMs: Number(frame.agentFrameAgeMs || 0) || 0,
833
+ droppedByAgent: Number(frame.droppedByAgent || 0) || 0,
834
+ hubIngressDropped: Number(frame.hubIngressDropped || 0) || 0,
835
+ hubClientDropped: Number(diagnostics.hubClientDropped || 0) || 0,
836
+ hardwareEncoder: frame.hardwareEncoder || '',
837
+ platformProfile: frame.platformProfile || '',
838
+ monitorIndex: Number.isFinite(Number(frame.monitorIndex)) ? Number(frame.monitorIndex) : 0,
839
+ monitorCount: Number.isFinite(Number(frame.monitorCount)) ? Number(frame.monitorCount) : 1,
233
840
  fps: Number(frame.fps || 0) || 0,
234
841
  capturedAt: frame.capturedAt || '',
235
842
  receivedAt: frame.receivedAt || '',
843
+ hubPacketAt,
844
+ hubPacketEpochMs,
236
845
  byteLength: Number(frameEvent.byteLength || payload.length) || payload.length,
237
846
  contentHash: frame.contentHash || '',
238
847
  captureMs: Number(frame.captureMs || 0) || 0,
239
- sameContentStreak: Number(frame.sameContentStreak || 0) || 0
848
+ captureStageMs: Number(frame.captureStageMs || 0) || 0,
849
+ convertMs: Number(frame.convertMs || 0) || 0,
850
+ compressMs: Number(frame.compressMs || 0) || 0,
851
+ captureSourceSerial: Number(frame.captureSourceSerial || 0) || 0,
852
+ captureSourceSkipped: Number(frame.captureSourceSkipped || 0) || 0,
853
+ deltaTileSize: Number(frame.deltaTileSize || 0) || 0,
854
+ deltaTileCount: Number(frame.deltaTileCount || 0) || 0,
855
+ deltaVisualTileCount: Number(frame.deltaVisualTileCount || 0) || 0,
856
+ deltaRefreshTileCount: Number(frame.deltaRefreshTileCount || 0) || 0,
857
+ deltaTotalTiles: Number(frame.deltaTotalTiles || 0) || 0,
858
+ sameContentStreak: Number(frame.sameContentStreak || 0) || 0,
859
+ layoutVersion: Number(frame.layoutVersion || 0) || 0,
860
+ atlasComposeMs: Number(frame.atlasComposeMs || 0) || 0,
861
+ atlasPoolStarved: Number(frame.atlasPoolStarved || 0) || 0,
862
+ atlasReadyTiles: Number(frame.atlasReadyTiles || 0) || 0,
863
+ atlasTileCount: Number(frame.atlasTileCount || 0) || 0
240
864
  };
241
865
  const metaBuffer = Buffer.from(JSON.stringify(metadata), 'utf8');
242
866
  const header = Buffer.allocUnsafe(4);
@@ -269,29 +893,60 @@ function buildRemoteAudioBinaryPacket(audioEvent) {
269
893
  }
270
894
 
271
895
  function broadcastRemoteBinaryFrame(frameEvent) {
896
+ for (const session of atlasClients.values()) {
897
+ session.ingest(frameEvent);
898
+ }
899
+ if (!hasHubFeatureAccess()) {
900
+ return;
901
+ }
272
902
  const deviceId = String(frameEvent?.deviceId || frameEvent?.frame?.deviceId || '').trim();
273
903
  if (!deviceId || frameClients.size === 0) {
274
904
  return;
275
905
  }
276
- const packet = buildRemoteFrameBinaryPacket(frameEvent);
277
- if (!packet) {
906
+ const targetClients = new Set([
907
+ ...(frameClientsByDeviceId.get(deviceId) || []),
908
+ ...frameWildcardClients
909
+ ]);
910
+ if (targetClients.size === 0) {
278
911
  return;
279
912
  }
280
- for (const client of frameClients) {
913
+ const frame = frameEvent.frame || {};
914
+ const isH264 = String(frame.codec || frame.mimeType || frame.format || '').toLowerCase().includes('h264')
915
+ || String(frame.frameMode || frame.mode || '').toLowerCase() === 'mode3-h264-hw';
916
+ const isMode5 = String(frame.frameMode || frame.mode || '').toLowerCase() === 'mode5-lzo-delta';
917
+ const isKeyFrame = frame.isKeyFrame === true || String(frame.chunkType || '').toLowerCase() === 'key';
918
+ const frameSeq = Number(frame.frameSeq || 0) || 0;
919
+ for (const client of targetClients) {
281
920
  if (client.readyState !== client.OPEN) {
282
921
  continue;
283
922
  }
284
- if (client.liveDeskDeviceIds?.size > 0 && !client.liveDeskDeviceIds.has(deviceId)) {
923
+ const expectedStreamId = client.liveDeskStreamIdsByDeviceId instanceof Map
924
+ ? client.liveDeskStreamIdsByDeviceId.get(deviceId)
925
+ : '';
926
+ if (expectedStreamId && String(frame.streamId || '') !== expectedStreamId) {
927
+ continue;
928
+ }
929
+ const lane = ensureFrameClientSendLane(client);
930
+ if (client.liveDeskFrameBackpressured && isH264 && !isKeyFrame) {
931
+ lane.dropped += 1;
932
+ lane.awaitingKeyFrames.add(deviceId);
933
+ client.liveDeskFrameBackpressureDrops = Number(client.liveDeskFrameBackpressureDrops || 0) + 1;
285
934
  continue;
286
935
  }
287
- if (client.bufferedAmount > 8 * 1024 * 1024) {
936
+ const packet = buildRemoteFrameBinaryPacket(frameEvent, {
937
+ hubClientDropped: lane.dropped
938
+ });
939
+ if (!packet) {
288
940
  continue;
289
941
  }
290
- client.send(packet, { binary: true });
942
+ enqueueFramePacketForClient(client, packet, { deviceId, frameSeq, isH264, isMode5, isKeyFrame });
291
943
  }
292
944
  }
293
945
 
294
946
  function broadcastRemoteBinaryAudio(audioEvent) {
947
+ if (!hasHubFeatureAccess()) {
948
+ return;
949
+ }
295
950
  const deviceId = String(audioEvent?.deviceId || audioEvent?.frame?.deviceId || '').trim();
296
951
  if (!deviceId || audioClients.size === 0) {
297
952
  return;
@@ -310,10 +965,125 @@ function broadcastRemoteBinaryAudio(audioEvent) {
310
965
  if (client.bufferedAmount > 2 * 1024 * 1024) {
311
966
  continue;
312
967
  }
313
- client.send(packet, { binary: true });
968
+ safeWebSocketSend(client, packet, { binary: true });
314
969
  }
315
970
  }
316
971
 
972
+ function sendMode4AtlasFrame(ws, output) {
973
+ if (!hasHubFeatureAccess() || ws.readyState !== ws.OPEN || !output?.payload?.length) return;
974
+ const metadata = output.metadata || {};
975
+ const frameSeq = Number(metadata.frameSeq || 0) || 0;
976
+ const isKeyFrame = metadata.isKeyFrame === true;
977
+ const workerGeneration = Math.max(1, Number(metadata.workerGeneration || 1) || 1);
978
+ const encoderGeneration = Math.max(1, Number(metadata.encoderGeneration || 1) || 1);
979
+ const streamId = `${ws.liveDeskAtlasStreamId}-w${workerGeneration}-e${encoderGeneration}`;
980
+ const frameEvent = {
981
+ kind: 'live',
982
+ deviceId: '__livedesk_mode4_atlas__',
983
+ mimeType: 'video/h264',
984
+ byteLength: output.payload.length,
985
+ payload: output.payload,
986
+ frame: {
987
+ deviceId: '__livedesk_mode4_atlas__',
988
+ frameSeq,
989
+ streamFrameSeq: Number(metadata.streamFrameSeq || frameSeq) || frameSeq,
990
+ streamId,
991
+ commandId: streamId,
992
+ width: Number(metadata.width || 1920),
993
+ height: Number(metadata.height || 1080),
994
+ sourceWidth: Number(metadata.width || 1920),
995
+ sourceHeight: Number(metadata.height || 1080),
996
+ mimeType: 'video/h264',
997
+ mode: 'mode4-h264-atlas',
998
+ frameMode: 'mode4-h264-atlas',
999
+ frameProfile: 'mode4-h264-atlas-v1',
1000
+ encoding: 'video-atlas',
1001
+ codec: 'h264',
1002
+ compression: 'video/h264',
1003
+ h264Format: 'annexb',
1004
+ isKeyFrame,
1005
+ chunkType: isKeyFrame ? 'key' : 'delta',
1006
+ timestampUs: Number(metadata.timestampUs || 0),
1007
+ durationUs: Number(metadata.durationUs || 0),
1008
+ fps: Number(metadata.fps || 20),
1009
+ capturedAt: new Date().toISOString(),
1010
+ receivedAt: new Date().toISOString(),
1011
+ hardwareEncoder: String(metadata.hardwareEncoder || ''),
1012
+ platformProfile: 'mode4-hub-atlas-worker',
1013
+ workerGeneration,
1014
+ encoderGeneration,
1015
+ layoutVersion: Number(metadata.layoutVersion || 0),
1016
+ atlasComposeMs: Number(metadata.composeMs || 0),
1017
+ atlasPoolStarved: Number(metadata.poolStarved || 0),
1018
+ atlasReadyTiles: Number(metadata.readyTileCount || 0),
1019
+ atlasTileCount: Number(metadata.tileCount || 0)
1020
+ }
1021
+ };
1022
+ const lane = ensureFrameClientSendLane(ws);
1023
+ if (ws.liveDeskFrameBackpressured && !isKeyFrame) {
1024
+ lane.dropped += 1;
1025
+ lane.awaitingKeyFrames.add('__livedesk_mode4_atlas__');
1026
+ return;
1027
+ }
1028
+ const packet = buildRemoteFrameBinaryPacket(frameEvent, { hubClientDropped: lane.dropped });
1029
+ if (packet) {
1030
+ enqueueFramePacketForClient(ws, packet, {
1031
+ deviceId: '__livedesk_mode4_atlas__',
1032
+ frameSeq,
1033
+ isH264: true,
1034
+ isKeyFrame
1035
+ });
1036
+ }
1037
+ }
1038
+
1039
+ function configureMode4Atlas(ws, payload = {}) {
1040
+ const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId).slice(0, 100);
1041
+ const monitorSelections = normalizeMonitorSelections(payload.monitorSelections);
1042
+ const sharpTiles = Number(payload.tileWidth) >= 480 || Number(payload.tileHeight) >= 270;
1043
+ const tileWidth = sharpTiles ? 480 : 320;
1044
+ const tileHeight = sharpTiles ? 270 : 180;
1045
+ ws.liveDeskAtlasDeviceIds = new Set(deviceIds);
1046
+ ws.liveDeskAtlasInputOptions = { tileWidth, tileHeight, monitorSelections };
1047
+ ws.liveDeskAtlasSession.configure({
1048
+ deviceIds,
1049
+ width: clampNumber(payload.width, 640, 3840, 1920),
1050
+ height: clampNumber(payload.height, 360, 2160, 1080),
1051
+ tileWidth,
1052
+ tileHeight,
1053
+ fps: clampNumber(payload.fps, 1, 30, 20)
1054
+ });
1055
+ for (const deviceId of deviceIds) {
1056
+ startMode4AtlasInput(ws, deviceId, 'atlas-configure');
1057
+ }
1058
+ sendJson(ws, {
1059
+ type: 'Mode4AtlasSubscription',
1060
+ deviceIds,
1061
+ inputMode: 'mode2-lzo',
1062
+ outputMode: 'mode4-h264-atlas',
1063
+ tileWidth,
1064
+ tileHeight,
1065
+ timestamp: new Date().toISOString()
1066
+ });
1067
+ }
1068
+
1069
+ function startMode4AtlasInput(ws, deviceId, reason = 'atlas-configure') {
1070
+ const options = ws?.liveDeskAtlasInputOptions;
1071
+ if (!options || !ws.liveDeskAtlasDeviceIds?.has(deviceId)) return { ok: false, error: 'atlas-device-not-configured' };
1072
+ return remoteHub.startLiveStream(deviceId, {
1073
+ fps: 8,
1074
+ maxWidth: options.tileWidth,
1075
+ maxHeight: options.tileHeight,
1076
+ quality: 60,
1077
+ mode: 'mode2-lzo',
1078
+ frameMode: 'mode2-lzo',
1079
+ streamPurpose: 'atlas',
1080
+ monitorIndex: Number(options.monitorSelections?.[deviceId] || 0),
1081
+ reuseExisting: false,
1082
+ silentReuse: true,
1083
+ restartReason: reason
1084
+ });
1085
+ }
1086
+
317
1087
  function wantsBinaryFrame(req) {
318
1088
  return /^(1|true|yes|on|binary|raw)$/i.test(String(req.query?.binary ?? req.query?.raw ?? ''))
319
1089
  || /image\//i.test(String(req.headers.accept || ''));
@@ -350,13 +1120,69 @@ app.get('/api/health', (_req, res) => {
350
1120
 
351
1121
  app.get('/api/remote/status', (_req, res) => {
352
1122
  noStore(res);
1123
+ const secretStatus = remoteHub.getStatus({ includeSecrets: true });
353
1124
  res.json({
354
- ...remoteHub.getStatus({ includeSecrets: true }),
1125
+ ...remoteHub.getStatus({ includeSecrets: false }),
1126
+ pairingPin: secretStatus.pairingPin,
355
1127
  product: 'LiveDesk',
356
1128
  agentPackage: '@livedesk/client'
357
1129
  });
358
1130
  });
359
1131
 
1132
+ app.get('/api/remote/registry-credentials', (_req, res) => {
1133
+ noStore(res);
1134
+ const status = remoteHub.getStatus({ includeSecrets: true });
1135
+ res.json({
1136
+ pairToken: status.pairToken,
1137
+ pairingPin: status.pairingPin,
1138
+ pairingPinUpdatedAt: status.pairingPinUpdatedAt
1139
+ });
1140
+ });
1141
+
1142
+ app.get('/api/remote/license', (_req, res) => {
1143
+ noStore(res);
1144
+ res.json(licenseSnapshot());
1145
+ });
1146
+
1147
+ app.post('/api/remote/license/sync', async (req, res) => {
1148
+ noStore(res);
1149
+ try {
1150
+ const authorization = String(req.headers.authorization || '');
1151
+ const accessToken = authorization.replace(/^Bearer\s+/i, '').trim();
1152
+ res.json(await syncVerifiedLicense(accessToken));
1153
+ } catch (err) {
1154
+ verifiedLicense = {
1155
+ userId: '',
1156
+ plan: 'free',
1157
+ status: 'inactive',
1158
+ expiresAt: '',
1159
+ verifiedAt: Date.now()
1160
+ };
1161
+ res.status(401).json({ ok: false, error: err instanceof Error ? err.message : String(err), license: licenseSnapshot() });
1162
+ }
1163
+ });
1164
+
1165
+ app.delete('/api/remote/license', (_req, res) => {
1166
+ noStore(res);
1167
+ verifiedLicense = {
1168
+ userId: '',
1169
+ plan: 'free',
1170
+ status: 'inactive',
1171
+ expiresAt: '',
1172
+ verifiedAt: Date.now()
1173
+ };
1174
+ res.json(licenseSnapshot());
1175
+ });
1176
+
1177
+ app.post('/api/remote/pairing-pin', (_req, res) => {
1178
+ noStore(res);
1179
+ try {
1180
+ res.json(remoteHub.rotatePairingPin());
1181
+ } catch (err) {
1182
+ res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
1183
+ }
1184
+ });
1185
+
360
1186
  app.get('/api/remote/devices', (req, res) => {
361
1187
  noStore(res);
362
1188
  const includeDataUrl = /^(1|true|yes|on|data|base64)$/i.test(String(req.query?.includeDataUrl ?? ''));
@@ -438,12 +1264,138 @@ app.post('/api/remote/devices/:deviceId/ping', (req, res) => {
438
1264
  }));
439
1265
  });
440
1266
 
441
- app.post('/api/remote/devices/:deviceId/input', (req, res) => {
1267
+ app.post('/api/remote/devices/:deviceId/input', requireHubFeatureAccess, (req, res) => {
442
1268
  noStore(res);
443
1269
  res.json(remoteHub.sendInputControl(req.params.deviceId, req.body || {}));
444
1270
  });
445
1271
 
446
- app.post('/api/remote/files/transfer', (req, res) => {
1272
+ app.get('/api/remote/filesystem/roots', requireHubFeatureAccess, async (req, res) => {
1273
+ noStore(res);
1274
+ try {
1275
+ res.json(await hubFilesystem.getRoots({ refresh: /^(1|true|yes|on)$/i.test(String(req.query?.refresh || '')) }));
1276
+ } catch (error) {
1277
+ sendFilesystemError(res, error);
1278
+ }
1279
+ });
1280
+
1281
+ app.get('/api/remote/filesystem/entries', requireHubFeatureAccess, async (req, res) => {
1282
+ noStore(res);
1283
+ try {
1284
+ res.json(await hubFilesystem.getEntries(req.query?.id));
1285
+ } catch (error) {
1286
+ sendFilesystemError(res, error);
1287
+ }
1288
+ });
1289
+
1290
+ app.post('/api/remote/filesystem/selection/scan', requireHubFeatureAccess, async (req, res) => {
1291
+ noStore(res);
1292
+ try {
1293
+ const scan = await hubFilesystem.scan(req.body?.itemIds);
1294
+ res.json({ files: scan.files.length, folders: scan.folders, totalBytes: scan.totalBytes });
1295
+ } catch (error) {
1296
+ sendFilesystemError(res, error);
1297
+ }
1298
+ });
1299
+
1300
+ app.post('/api/remote/files/from-hub', requireHubFeatureAccess, (req, res) => {
1301
+ noStore(res);
1302
+ try {
1303
+ const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
1304
+ if (deviceIds.length === 0) {
1305
+ res.status(400).json({ ok: false, error: 'no-target-devices' });
1306
+ return;
1307
+ }
1308
+ const itemIds = Array.isArray(req.body?.itemIds) ? req.body.itemIds : [];
1309
+ if (itemIds.length === 0) {
1310
+ res.status(400).json({ ok: false, error: 'no-filesystem-items-selected' });
1311
+ return;
1312
+ }
1313
+ const job = hubTransferJobs.create({
1314
+ itemIds,
1315
+ deviceIds,
1316
+ remoteDirectory: req.body?.remoteDirectory
1317
+ });
1318
+ res.status(202).json({ ok: true, jobId: job.jobId, state: job.state });
1319
+ } catch (error) {
1320
+ sendFilesystemError(res, error);
1321
+ }
1322
+ });
1323
+
1324
+ app.get('/api/remote/files/jobs', requireHubFeatureAccess, (req, res) => {
1325
+ noStore(res);
1326
+ res.json({ jobs: hubTransferJobs.list({ activeOnly: /^(1|true|yes|on)$/i.test(String(req.query?.active || '')) }) });
1327
+ });
1328
+
1329
+ app.get('/api/remote/files/jobs/:jobId', requireHubFeatureAccess, (req, res) => {
1330
+ noStore(res);
1331
+ const job = hubTransferJobs.get(req.params.jobId);
1332
+ if (!job) {
1333
+ res.status(404).json({ ok: false, error: 'transfer-job-not-found' });
1334
+ return;
1335
+ }
1336
+ res.json(job);
1337
+ });
1338
+
1339
+ app.post('/api/remote/files/jobs/:jobId/cancel', requireHubFeatureAccess, (req, res) => {
1340
+ noStore(res);
1341
+ const job = hubTransferJobs.cancel(req.params.jobId);
1342
+ if (!job) {
1343
+ res.status(404).json({ ok: false, error: 'transfer-job-not-found' });
1344
+ return;
1345
+ }
1346
+ res.json({ ok: true, ...job });
1347
+ });
1348
+
1349
+ app.get('/api/remote/filesystem/shared-folders', requireHubFeatureAccess, async (_req, res) => {
1350
+ noStore(res);
1351
+ try {
1352
+ res.json({ folders: await hubSharedFolders.list() });
1353
+ } catch (error) {
1354
+ sendFilesystemError(res, error);
1355
+ }
1356
+ });
1357
+
1358
+ app.post('/api/remote/filesystem/shared-folders', requireHubFeatureAccess, async (req, res) => {
1359
+ noStore(res);
1360
+ try {
1361
+ res.status(201).json({ ok: true, folder: await hubSharedFolders.add(req.body?.sourceId) });
1362
+ } catch (error) {
1363
+ sendFilesystemError(res, error);
1364
+ }
1365
+ });
1366
+
1367
+ app.patch('/api/remote/filesystem/shared-folders/:folderId', requireHubFeatureAccess, async (req, res) => {
1368
+ noStore(res);
1369
+ try {
1370
+ res.json({ ok: true, folder: await hubSharedFolders.update(req.params.folderId, req.body || {}) });
1371
+ } catch (error) {
1372
+ sendFilesystemError(res, error);
1373
+ }
1374
+ });
1375
+
1376
+ app.delete('/api/remote/filesystem/shared-folders/:folderId', requireHubFeatureAccess, async (req, res) => {
1377
+ noStore(res);
1378
+ try {
1379
+ res.json(await hubSharedFolders.remove(req.params.folderId));
1380
+ } catch (error) {
1381
+ sendFilesystemError(res, error);
1382
+ }
1383
+ });
1384
+
1385
+ app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatureAccess, async (req, res) => {
1386
+ noStore(res);
1387
+ try {
1388
+ res.status(202).json(await hubSharedFolders.sync(
1389
+ req.params.folderId,
1390
+ normalizeDeviceIds(req.body?.deviceIds),
1391
+ req.body?.remoteDirectory
1392
+ ));
1393
+ } catch (error) {
1394
+ sendFilesystemError(res, error);
1395
+ }
1396
+ });
1397
+
1398
+ app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
447
1399
  noStore(res);
448
1400
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
449
1401
  if (deviceIds.length === 0) {
@@ -484,7 +1436,49 @@ app.post('/api/remote/files/transfer', (req, res) => {
484
1436
  });
485
1437
  });
486
1438
 
487
- app.post('/api/remote/devices/:deviceId/tasks', (req, res) => {
1439
+ app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
1440
+ noStore(res);
1441
+ const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
1442
+ if (deviceIds.length === 0) {
1443
+ res.status(400).json({ ok: false, error: 'no-target-devices' });
1444
+ return;
1445
+ }
1446
+ const normalized = normalizeTransferChunk(req.body || {});
1447
+ if (!normalized.ok) {
1448
+ res.status(400).json({ ok: false, error: normalized.error, byteLength: normalized.byteLength || 0 });
1449
+ return;
1450
+ }
1451
+
1452
+ const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
1453
+ const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
1454
+ const queuedAt = new Date().toISOString();
1455
+ const results = deviceIds.map(deviceId => ({
1456
+ deviceId,
1457
+ ...remoteHub.sendCommand(deviceId, {
1458
+ command: 'file.transfer.chunk',
1459
+ payload: {
1460
+ transferId,
1461
+ remoteDirectory,
1462
+ ...normalized.chunk,
1463
+ requestedAt: queuedAt
1464
+ }
1465
+ })
1466
+ }));
1467
+ const queued = results.filter(result => result.ok).length;
1468
+ res.json({
1469
+ ok: queued > 0,
1470
+ transferId,
1471
+ queued,
1472
+ total: deviceIds.length,
1473
+ byteLength: normalized.chunk.byteLength,
1474
+ offset: normalized.chunk.offset,
1475
+ final: normalized.chunk.final,
1476
+ results,
1477
+ error: queued > 0 ? undefined : 'no-transfer-queued'
1478
+ });
1479
+ });
1480
+
1481
+ app.post('/api/remote/devices/:deviceId/tasks', requireHubFeatureAccess, (req, res) => {
488
1482
  noStore(res);
489
1483
  res.json(remoteHub.requestAgentTask(req.params.deviceId, {
490
1484
  instruction: req.body?.instruction,
@@ -496,7 +1490,7 @@ app.post('/api/remote/devices/:deviceId/tasks', (req, res) => {
496
1490
  }));
497
1491
  });
498
1492
 
499
- app.post('/api/remote/tasks', (req, res) => {
1493
+ app.post('/api/remote/tasks', requireHubFeatureAccess, (req, res) => {
500
1494
  noStore(res);
501
1495
  const requestedIds = normalizeDeviceIds(req.body?.deviceIds);
502
1496
  const approvalLevel = req.body?.approvalLevel === 'ai-assist' ? 'ai-assist' : 'task-only';
@@ -545,7 +1539,7 @@ app.post('/api/remote/devices/:deviceId/thumbnail/request', (req, res) => {
545
1539
  res.json(remoteHub.requestThumbnail(req.params.deviceId, req.body || {}));
546
1540
  });
547
1541
 
548
- app.get('/api/remote/devices/:deviceId/live/frame', (req, res) => {
1542
+ app.get('/api/remote/devices/:deviceId/live/frame', requireHubFeatureAccess, (req, res) => {
549
1543
  noStore(res);
550
1544
  if (wantsBinaryFrame(req)) {
551
1545
  sendFrameBinary(req, res, 'live');
@@ -561,7 +1555,7 @@ app.get('/api/remote/devices/:deviceId/live/frame', (req, res) => {
561
1555
  res.json({ ok: true, frame });
562
1556
  });
563
1557
 
564
- app.post('/api/remote/devices/:deviceId/live/start', (req, res) => {
1558
+ app.post('/api/remote/devices/:deviceId/live/start', requireHubFeatureAccess, (req, res) => {
565
1559
  noStore(res);
566
1560
  res.json(remoteHub.startLiveStream(req.params.deviceId, req.body || {}));
567
1561
  });
@@ -571,7 +1565,7 @@ app.post('/api/remote/devices/:deviceId/live/stop', (req, res) => {
571
1565
  res.json(remoteHub.stopLiveStream(req.params.deviceId, req.body || {}));
572
1566
  });
573
1567
 
574
- app.post('/api/remote/devices/:deviceId/audio/start', (req, res) => {
1568
+ app.post('/api/remote/devices/:deviceId/audio/start', requireHubFeatureAccess, (req, res) => {
575
1569
  noStore(res);
576
1570
  res.json(remoteHub.startAudioStream(req.params.deviceId, req.body || {}));
577
1571
  });
@@ -597,12 +1591,21 @@ if (existsSync(webIndexPath)) {
597
1591
  }
598
1592
 
599
1593
  httpServer.on('upgrade', (req, socket, head) => {
1594
+ if (!isTrustedBrowserRequest(req)) {
1595
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
1596
+ socket.destroy();
1597
+ return;
1598
+ }
600
1599
  try {
601
1600
  const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
602
1601
  if (parsed.pathname === '/api/remote/frames/ws') {
603
1602
  frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
604
1603
  return;
605
1604
  }
1605
+ if (parsed.pathname === '/api/remote/atlas/ws') {
1606
+ atlasWss.handleUpgrade(req, socket, head, ws => atlasWss.emit('connection', ws, req));
1607
+ return;
1608
+ }
606
1609
  if (parsed.pathname === '/api/remote/input/ws') {
607
1610
  inputWss.handleUpgrade(req, socket, head, ws => inputWss.emit('connection', ws, req));
608
1611
  return;
@@ -622,6 +1625,15 @@ httpServer.on('upgrade', (req, socket, head) => {
622
1625
  frameWss.on('connection', (ws, req) => {
623
1626
  frameClients.add(ws);
624
1627
  ws.liveDeskFrameClientId = `rfws-${++frameClientSeq}`;
1628
+ try {
1629
+ ws._socket?.setNoDelay?.(true);
1630
+ } catch {
1631
+ // Best-effort latency hint for browser frame sockets.
1632
+ }
1633
+ const cleanup = () => {
1634
+ unregisterFrameClient(ws);
1635
+ frameClients.delete(ws);
1636
+ };
625
1637
  try {
626
1638
  const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
627
1639
  updateFrameSubscription(ws, { deviceIds: parsed.searchParams.get('devices') || '' });
@@ -633,13 +1645,15 @@ frameWss.on('connection', (ws, req) => {
633
1645
  const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
634
1646
  if (payload?.type === 'subscribe') {
635
1647
  updateFrameSubscription(ws, payload);
1648
+ } else if (payload?.type === 'restart-live') {
1649
+ restartFrameSubscriptionLive(ws, payload);
636
1650
  }
637
1651
  } catch {
638
1652
  sendJson(ws, { type: 'RemoteFrameSubscriptionError', error: 'invalid-message' });
639
1653
  }
640
1654
  });
641
- ws.on('close', () => frameClients.delete(ws));
642
- ws.on('error', () => frameClients.delete(ws));
1655
+ ws.on('close', cleanup);
1656
+ ws.on('error', cleanup);
643
1657
  sendJson(ws, {
644
1658
  type: 'RemoteFrameSocketReady',
645
1659
  protocol: 'livedesk.remote.frames.binary.v1',
@@ -648,6 +1662,52 @@ frameWss.on('connection', (ws, req) => {
648
1662
  });
649
1663
  });
650
1664
 
1665
+ atlasWss.on('connection', ws => {
1666
+ ws.liveDeskFrameClientId = `atlas-${++frameClientSeq}`;
1667
+ ws.liveDeskAtlasStreamId = `mode4-atlas-${crypto.randomUUID()}`;
1668
+ try { ws._socket?.setNoDelay?.(true); } catch {}
1669
+ const session = new Mode4AtlasSession({
1670
+ onFrame: output => sendMode4AtlasFrame(ws, output),
1671
+ onLayout: layout => sendJson(ws, { ...layout, type: 'Mode4AtlasLayout' }),
1672
+ onStatus: status => {
1673
+ if (status?.type === 'log') {
1674
+ const logger = status.level === 'warn' ? console.warn : console.log;
1675
+ logger(`[LiveDesk Hub] ${status.message}`);
1676
+ }
1677
+ sendJson(ws, { ...status, type: 'Mode4AtlasStatus' });
1678
+ }
1679
+ });
1680
+ ws.liveDeskAtlasSession = session;
1681
+ atlasClients.set(ws, session);
1682
+ const cleanup = () => {
1683
+ atlasClients.delete(ws);
1684
+ session.close();
1685
+ const lane = ws.liveDeskFrameSendLane;
1686
+ if (lane) {
1687
+ lane.queue.length = 0;
1688
+ lane.awaitingKeyFrames.clear();
1689
+ }
1690
+ };
1691
+ ws.on('message', data => {
1692
+ try {
1693
+ const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
1694
+ if (payload?.type === 'subscribe' || payload?.type === 'configure') {
1695
+ configureMode4Atlas(ws, payload);
1696
+ }
1697
+ } catch {
1698
+ sendJson(ws, { type: 'Mode4AtlasError', error: 'invalid-message' });
1699
+ }
1700
+ });
1701
+ ws.on('close', cleanup);
1702
+ ws.on('error', cleanup);
1703
+ sendJson(ws, {
1704
+ type: 'Mode4AtlasSocketReady',
1705
+ protocol: 'livedesk.remote.atlas.h264.v1',
1706
+ clientId: ws.liveDeskFrameClientId,
1707
+ timestamp: new Date().toISOString()
1708
+ });
1709
+ });
1710
+
651
1711
  audioWss.on('connection', (ws, req) => {
652
1712
  audioClients.add(ws);
653
1713
  ws.liveDeskAudioClientId = `raws-${++audioClientSeq}`;
@@ -681,6 +1741,10 @@ inputWss.on('connection', ws => {
681
1741
  inputClients.add(ws);
682
1742
  ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
683
1743
  ws.on('message', data => {
1744
+ if (!hasHubFeatureAccess()) {
1745
+ sendJson(ws, { type: 'RemoteInputError', error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
1746
+ return;
1747
+ }
684
1748
  let payload;
685
1749
  try {
686
1750
  payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
@@ -689,8 +1753,15 @@ inputWss.on('connection', ws => {
689
1753
  return;
690
1754
  }
691
1755
  const deviceId = String(payload?.deviceId || '').trim();
692
- const input = payload?.input && typeof payload.input === 'object' ? payload.input : payload;
1756
+ const input = payload?.input && typeof payload.input === 'object'
1757
+ ? { ...payload.input, hubReceivedAtEpochMs: Date.now() }
1758
+ : { ...payload, hubReceivedAtEpochMs: Date.now() };
693
1759
  const result = remoteHub.sendInputControl(deviceId, input);
1760
+ const fireAndForget = payload?.fireAndForget === true
1761
+ || String(input?.type || '').toLowerCase() === 'pointermove';
1762
+ if (fireAndForget && result?.ok) {
1763
+ return;
1764
+ }
694
1765
  sendJson(ws, {
695
1766
  type: result?.ok ? 'RemoteInputQueued' : 'RemoteInputError',
696
1767
  requestId: payload?.requestId || '',
@@ -710,14 +1781,24 @@ inputWss.on('connection', ws => {
710
1781
  });
711
1782
 
712
1783
  await remoteHub.start();
1784
+ connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
1785
+ hubSharedFolders.startAutoSync(
1786
+ () => remoteHub.listDevices({ includeDataUrl: false }).filter(device => device.connected).map(device => device.deviceId),
1787
+ () => process.env.LIVEDESK_REMOTE_DIRECTORY || 'Desktop/LiveDeskFiles'
1788
+ );
713
1789
  httpServer.listen(httpPort, httpHost, () => {
714
1790
  const status = remoteHub.getStatus({ includeSecrets: true });
1791
+ const managerVersion = String(process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version || 'dev');
1792
+ console.log(`[LiveDesk Hub] Version ${managerVersion}`);
715
1793
  console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
716
- console.log(`[LiveDesk Hub] RemoteHub ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
1794
+ console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
717
1795
  });
718
1796
 
719
1797
  for (const signal of ['SIGINT', 'SIGTERM']) {
720
1798
  process.once(signal, async () => {
1799
+ for (const session of atlasClients.values()) session.close();
1800
+ atlasClients.clear();
1801
+ hubSharedFolders.close();
721
1802
  await remoteHub.close();
722
1803
  httpServer.close(() => process.exit(0));
723
1804
  });