@livedesk/hub 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.
package/src/remote-hub.js CHANGED
@@ -8,7 +8,7 @@ const DEFAULT_HEARTBEAT_MS = 5000;
8
8
  const DEFAULT_AGENT_TASK_TIMEOUT_MS = 120000;
9
9
  const MAX_LINE_CHARS = 4 * 1024 * 1024;
10
10
  const MAX_THUMBNAIL_BASE64_CHARS = 3 * 1024 * 1024;
11
- const MAX_STREAM_BASE64_CHARS = 3 * 1024 * 1024;
11
+ const MAX_STREAM_BASE64_CHARS = Math.ceil(8 * 1024 * 1024 * 4 / 3);
12
12
  const MAX_THUMBNAIL_BINARY_BYTES = Math.floor(MAX_THUMBNAIL_BASE64_CHARS * 3 / 4);
13
13
  const MAX_STREAM_BINARY_BYTES = Math.floor(MAX_STREAM_BASE64_CHARS * 3 / 4);
14
14
  const MAX_AUDIO_BASE64_CHARS = 1024 * 1024;
@@ -17,17 +17,22 @@ const MAX_AGENT_TASK_CHARS = 4000;
17
17
  const MAX_AGENT_TASK_RESULT_CHARS = 3000;
18
18
  const RECENT_TASK_LIMIT = 12;
19
19
  const RECENT_TASK_BATCH_LIMIT = 16;
20
- const RECENT_FRAME_CACHE_TTL_MS = 12000;
21
- const RECENT_THUMBNAIL_FRAME_CACHE_LIMIT = 4;
22
- const RECENT_LIVE_FRAME_CACHE_LIMIT = 80;
23
- const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 3 * 1024 * 1024;
24
- const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 24 * 1024 * 1024;
20
+ const RECENT_FRAME_CACHE_TTL_MS = 4000;
21
+ const RECENT_THUMBNAIL_FRAME_CACHE_LIMIT = 2;
22
+ const RECENT_LIVE_FRAME_CACHE_LIMIT = 1;
23
+ const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 2 * 1024 * 1024;
24
+ const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 8 * 1024 * 1024;
25
+ const LIVE_STREAM_PENDING_REUSE_MS = 5000;
26
+ const LIVE_STREAM_MIN_FRESH_MS = 3000;
27
+ const LIVE_STREAM_MAX_FRESH_MS = 12000;
25
28
  const REMOTE_PROTOCOL_VERSION = 2;
26
29
  const REMOTE_AGENT_PROTOCOL = 'mindexec.remote.agent';
27
30
  const REMOTE_FRAME_PROTOCOL = 'mindexec.remote.frame.v2';
28
31
  const REMOTE_FRAME_PROTOCOL_VERSION = 2;
29
32
  const REMOTE_FRAME_HANDSHAKE = 'smartview-request-open-frame';
30
- const DEFAULT_REMOTE_FRAME_MODE = 'remote-fast';
33
+ const DEFAULT_REMOTE_FRAME_MODE = 'mode1-jpeg';
34
+ const TCP_BINARY_FRAME_MAGIC = Buffer.from('LDFB', 'ascii');
35
+ const TCP_BINARY_FRAME_HEADER_BYTES = 12;
31
36
  const REMOTE_FRAME_MODE_PROFILES = Object.freeze({
32
37
  thumbnail: Object.freeze({
33
38
  mode: 'thumbnail',
@@ -40,54 +45,102 @@ const REMOTE_FRAME_MODE_PROFILES = Object.freeze({
40
45
  modeVersion: 1,
41
46
  implemented: true
42
47
  }),
43
- 'remote-fast': Object.freeze({
44
- mode: 'remote-fast',
45
- label: 'RemoteFast JPEG Binary',
48
+ 'mode1-jpeg': Object.freeze({
49
+ mode: 'mode1-jpeg',
50
+ label: 'Mode 1 - Test JPEG Binary',
46
51
  transport: 'ws-binary',
47
52
  encoding: 'image',
48
53
  codec: 'jpeg',
49
54
  compression: 'image/jpeg',
50
- profile: 'jpeg-binary-v1',
55
+ profile: 'mode1-jpeg-binary-v1',
51
56
  modeVersion: 1,
52
57
  implemented: true
53
58
  }),
54
- 'remote-quality': Object.freeze({
55
- mode: 'remote-quality',
56
- label: 'RemoteQuality JPEG Binary',
59
+ 'mode2-lzo': Object.freeze({
60
+ mode: 'mode2-lzo',
61
+ label: 'Mode 2 - RGB565 LZO Wall',
57
62
  transport: 'ws-binary',
58
- encoding: 'image',
59
- codec: 'jpeg',
60
- compression: 'image/jpeg',
61
- profile: 'jpeg-quality-binary-v1',
62
- modeVersion: 1,
63
+ encoding: 'image-raw',
64
+ codec: 'rgb565',
65
+ compression: 'lzo1x',
66
+ profile: 'mode2-lzo-rgb565-v1',
67
+ modeVersion: 2,
63
68
  implemented: true
64
69
  }),
65
- 'video-codec': Object.freeze({
66
- mode: 'video-codec',
67
- label: 'Video Codec Stream',
70
+ 'mode3-h264-hw': Object.freeze({
71
+ mode: 'mode3-h264-hw',
72
+ label: 'Mode 3 - Hardware H.264',
68
73
  transport: 'ws-binary',
69
74
  encoding: 'video',
70
- codec: 'video',
71
- compression: 'video/*',
72
- profile: 'future-video-codec',
73
- modeVersion: 0,
74
- implemented: false
75
+ codec: 'h264',
76
+ compression: 'video/h264',
77
+ profile: 'mode3-h264-hardware-v1',
78
+ modeVersion: 3,
79
+ implemented: true
80
+ }),
81
+ 'mode5-lzo-delta': Object.freeze({
82
+ mode: 'mode5-lzo-delta',
83
+ label: 'Mode 5 - RGB565 Tile Delta LZO',
84
+ transport: 'ws-binary',
85
+ encoding: 'image-delta',
86
+ codec: 'rgb565-delta',
87
+ compression: 'lzo1x',
88
+ profile: 'mode5-lzo-rgb565-delta-v1',
89
+ modeVersion: 5,
90
+ implemented: true
75
91
  })
76
92
  });
77
93
  const REMOTE_FRAME_MODE_ALIASES = new Map([
78
- ['fast', 'remote-fast'],
79
- ['remotefast', 'remote-fast'],
80
- ['remote_fast', 'remote-fast'],
81
- ['remote-fast', 'remote-fast'],
82
- ['quality', 'remote-quality'],
83
- ['remotequality', 'remote-quality'],
84
- ['remote_quality', 'remote-quality'],
85
- ['remote-quality', 'remote-quality'],
94
+ ['1', 'mode1-jpeg'],
95
+ ['mode1', 'mode1-jpeg'],
96
+ ['mode-1', 'mode1-jpeg'],
97
+ ['mode1-jpeg', 'mode1-jpeg'],
98
+ ['jpeg', 'mode1-jpeg'],
99
+ ['jpg', 'mode1-jpeg'],
100
+ ['test', 'mode1-jpeg'],
101
+ ['fast', 'mode1-jpeg'],
102
+ ['remotefast', 'mode1-jpeg'],
103
+ ['remote_fast', 'mode1-jpeg'],
104
+ ['remote-fast', 'mode1-jpeg'],
105
+ ['quality', 'mode1-jpeg'],
106
+ ['remotequality', 'mode1-jpeg'],
107
+ ['remote_quality', 'mode1-jpeg'],
108
+ ['remote-quality', 'mode1-jpeg'],
109
+ ['2', 'mode2-lzo'],
110
+ ['mode2', 'mode2-lzo'],
111
+ ['mode-2', 'mode2-lzo'],
112
+ ['mode2-lzo', 'mode2-lzo'],
113
+ ['lzo', 'mode2-lzo'],
114
+ ['lzo1x', 'mode2-lzo'],
115
+ ['rgb565', 'mode2-lzo'],
116
+ ['tile-lzo', 'mode2-lzo'],
117
+ ['tiles-lzo', 'mode2-lzo'],
118
+ ['mode2-lz4', 'mode2-lzo'],
119
+ ['lz4', 'mode2-lzo'],
120
+ ['tile-lz4', 'mode2-lzo'],
121
+ ['tiles-lz4', 'mode2-lzo'],
122
+ ['3', 'mode3-h264-hw'],
123
+ ['mode3', 'mode3-h264-hw'],
124
+ ['mode-3', 'mode3-h264-hw'],
125
+ ['mode3-h264', 'mode3-h264-hw'],
126
+ ['mode3-h264-hw', 'mode3-h264-hw'],
127
+ ['h264', 'mode3-h264-hw'],
128
+ ['h264-hw', 'mode3-h264-hw'],
129
+ ['hardware', 'mode3-h264-hw'],
130
+ ['hardware-h264', 'mode3-h264-hw'],
131
+ ['5', 'mode5-lzo-delta'],
132
+ ['mode5', 'mode5-lzo-delta'],
133
+ ['mode-5', 'mode5-lzo-delta'],
134
+ ['mode5-lzo', 'mode5-lzo-delta'],
135
+ ['mode5-lzo-delta', 'mode5-lzo-delta'],
136
+ ['lzo-delta', 'mode5-lzo-delta'],
137
+ ['tile-delta', 'mode5-lzo-delta'],
138
+ ['control-lzo', 'mode5-lzo-delta'],
86
139
  ['thumb', 'thumbnail'],
87
140
  ['thumbnail', 'thumbnail'],
88
- ['video', 'video-codec'],
89
- ['codec', 'video-codec'],
90
- ['video-codec', 'video-codec']
141
+ ['video', 'mode3-h264-hw'],
142
+ ['codec', 'mode3-h264-hw'],
143
+ ['video-codec', 'mode3-h264-hw']
91
144
  ]);
92
145
  const MAX_SYNTHETIC_DEVICES = 1000;
93
146
  const DEFAULT_HOST_TARGET_LEASE_MS = 30000;
@@ -96,6 +149,9 @@ const DEFAULT_PUBLIC_IP_TIMEOUT_MS = 1800;
96
149
  const DUPLICATE_DEVICE_ACTIVE_REJECT_MS = 15000;
97
150
  const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
98
151
  const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
152
+ const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 256;
153
+ const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
154
+ const AGENT_BINARY_INGRESS_DRAIN_BUDGET_PACKETS = 64;
99
155
  const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
100
156
  const SYNTHETIC_FRAME_PAYLOAD = Buffer.from(SYNTHETIC_FRAME_DATA_URL.split(',')[1], 'base64');
101
157
  const SYNTHETIC_FRAME_HASH = crypto.createHash('sha256').update(SYNTHETIC_FRAME_PAYLOAD).digest('hex').slice(0, 16);
@@ -134,7 +190,11 @@ function safeString(value, maxLength = 200) {
134
190
  return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
135
191
  }
136
192
 
137
- function normalizeRemoteFrameMode(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
193
+ function generatePairingPin() {
194
+ return crypto.randomInt(0, 1000000).toString().padStart(6, '0');
195
+ }
196
+
197
+ function normalizeRemoteFrameMode(value, fallback = DEFAULT_REMOTE_FRAME_MODE, { allowUnimplemented = false } = {}) {
138
198
  const requested = safeString(value, 80).toLowerCase();
139
199
  const fallbackMode = REMOTE_FRAME_MODE_PROFILES[fallback] ? fallback : DEFAULT_REMOTE_FRAME_MODE;
140
200
  if (!requested) {
@@ -143,7 +203,7 @@ function normalizeRemoteFrameMode(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
143
203
 
144
204
  const normalized = REMOTE_FRAME_MODE_ALIASES.get(requested) || requested;
145
205
  const profile = REMOTE_FRAME_MODE_PROFILES[normalized];
146
- if (!profile || profile.implemented !== true) {
206
+ if (!profile || (!allowUnimplemented && profile.implemented !== true)) {
147
207
  return fallbackMode;
148
208
  }
149
209
 
@@ -164,14 +224,13 @@ function serializeRemoteFrameModeProfile(profile) {
164
224
  };
165
225
  }
166
226
 
167
- function getRemoteFrameModeProfile(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
168
- const mode = normalizeRemoteFrameMode(value, fallback);
227
+ function getRemoteFrameModeProfile(value, fallback = DEFAULT_REMOTE_FRAME_MODE, options = {}) {
228
+ const mode = normalizeRemoteFrameMode(value, fallback, options);
169
229
  return serializeRemoteFrameModeProfile(REMOTE_FRAME_MODE_PROFILES[mode]);
170
230
  }
171
231
 
172
232
  function getSupportedRemoteFrameModeProfiles() {
173
233
  return Object.values(REMOTE_FRAME_MODE_PROFILES)
174
- .filter(profile => profile.implemented === true)
175
234
  .map(serializeRemoteFrameModeProfile);
176
235
  }
177
236
 
@@ -222,14 +281,14 @@ function normalizeIncomingFrameModeProfile(value) {
222
281
  }
223
282
 
224
283
  if (typeof value === 'string') {
225
- return getRemoteFrameModeProfile(value);
284
+ return getRemoteFrameModeProfile(value, DEFAULT_REMOTE_FRAME_MODE, { allowUnimplemented: true });
226
285
  }
227
286
 
228
287
  if (typeof value !== 'object') {
229
288
  return null;
230
289
  }
231
290
 
232
- const profile = buildRemoteFrameTransferDescriptor(value.mode || value.frameMode || value.profile);
291
+ const profile = getRemoteFrameModeProfile(value.mode || value.frameMode || value.profile, DEFAULT_REMOTE_FRAME_MODE, { allowUnimplemented: true });
233
292
  return {
234
293
  mode: profile.mode,
235
294
  label: safeString(value.label, 80) || profile.mode,
@@ -452,6 +511,10 @@ function normalizeSlotNumber(value) {
452
511
  return Number.isInteger(number) && number >= 1 && number <= 999 ? number : 0;
453
512
  }
454
513
 
514
+ function normalizeMonitorIndex(value) {
515
+ return clampNumber(value, 0, 63, 0);
516
+ }
517
+
455
518
  function parseManagerEndpoint(value) {
456
519
  const endpoint = safeString(value, 256);
457
520
  const match = endpoint.match(/^(\[[^\]]+\]|[^:\s]+):(\d{1,5})$/);
@@ -505,8 +568,15 @@ function normalizeRemoteInputEvent(value = {}) {
505
568
  const normalizedY = Number(input.normalizedY ?? input.NormalizedY);
506
569
  const deltaX = Number(input.deltaX ?? input.DeltaX);
507
570
  const deltaY = Number(input.deltaY ?? input.DeltaY);
571
+ const keyCode = Number(input.keyCode ?? input.KeyCode);
572
+ const location = Number(input.location ?? input.Location);
573
+ const monitorIndex = Number(input.monitorIndex ?? input.MonitorIndex ?? input.screenIndex ?? input.ScreenIndex ?? input.displayIndex ?? input.DisplayIndex);
574
+ const inputSeq = Number(input.inputSeq ?? input.InputSeq);
575
+ const issuedAtEpochMs = Number(input.issuedAtEpochMs ?? input.IssuedAtEpochMs);
576
+ const hubReceivedAtEpochMs = Number(input.hubReceivedAtEpochMs ?? input.HubReceivedAtEpochMs);
508
577
  return {
509
578
  type,
579
+ monitorIndex: Number.isFinite(monitorIndex) ? normalizeMonitorIndex(monitorIndex) : 0,
510
580
  normalizedX: Number.isFinite(normalizedX) ? Math.max(0, Math.min(1, normalizedX)) : undefined,
511
581
  normalizedY: Number.isFinite(normalizedY) ? Math.max(0, Math.min(1, normalizedY)) : undefined,
512
582
  button: safeString(input.button || input.Button, 24),
@@ -514,9 +584,19 @@ function normalizeRemoteInputEvent(value = {}) {
514
584
  deltaY: Number.isFinite(deltaY) ? Math.max(-4096, Math.min(4096, deltaY)) : 0,
515
585
  key: safeString(input.key || input.Key, 80),
516
586
  code: safeString(input.code || input.Code, 80),
587
+ keyCode: Number.isFinite(keyCode) ? Math.max(0, Math.min(65535, Math.trunc(keyCode))) : 0,
588
+ location: Number.isFinite(location) ? Math.max(0, Math.min(255, Math.trunc(location))) : 0,
517
589
  text: safeText(input.text || input.Text, 256),
518
590
  repeat: input.repeat === true || input.Repeat === true,
591
+ shiftKey: input.shiftKey === true || input.ShiftKey === true,
592
+ ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
593
+ altKey: input.altKey === true || input.AltKey === true,
594
+ metaKey: input.metaKey === true || input.MetaKey === true || input.commandKey === true || input.CommandKey === true,
519
595
  controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
596
+ inputSeq: Number.isSafeInteger(inputSeq) && inputSeq > 0 ? inputSeq : 0,
597
+ requestAck: input.requestAck !== false && input.RequestAck !== false,
598
+ issuedAtEpochMs: Number.isFinite(issuedAtEpochMs) ? issuedAtEpochMs : 0,
599
+ hubReceivedAtEpochMs: Number.isFinite(hubReceivedAtEpochMs) ? hubReceivedAtEpochMs : 0,
520
600
  issuedAt: safeString(input.issuedAt || input.IssuedAt, 80)
521
601
  };
522
602
  }
@@ -811,6 +891,13 @@ function serializeDevice(device, options = {}) {
811
891
  lastDisconnectReason: device.lastDisconnectReason,
812
892
  remoteAddress: device.remoteAddress,
813
893
  remotePort: device.remotePort,
894
+ channels: {
895
+ control: !!device.socket && !device.socket.destroyed,
896
+ input: !!device.inputSocket && !device.inputSocket.destroyed,
897
+ frame: !!device.frameSocket && !device.frameSocket.destroyed,
898
+ audio: !!device.audioSocket && !device.audioSocket.destroyed,
899
+ file: !!device.fileSocket && !device.fileSocket.destroyed
900
+ },
814
901
  latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
815
902
  latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
816
903
  activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
@@ -958,6 +1045,112 @@ function parseRemoteHubWebSocketBinaryFrame(buffer) {
958
1045
  return { header, payload };
959
1046
  }
960
1047
 
1048
+ function hasTcpBinaryFrameMagicPrefix(buffer) {
1049
+ if (!Buffer.isBuffer(buffer) || buffer.length <= 0 || buffer.length > TCP_BINARY_FRAME_MAGIC.length) {
1050
+ return false;
1051
+ }
1052
+
1053
+ for (let index = 0; index < buffer.length; index += 1) {
1054
+ if (buffer[index] !== TCP_BINARY_FRAME_MAGIC[index]) {
1055
+ return false;
1056
+ }
1057
+ }
1058
+ return true;
1059
+ }
1060
+
1061
+ function startsWithTcpBinaryFrameMagic(buffer) {
1062
+ return Buffer.isBuffer(buffer)
1063
+ && buffer.length >= TCP_BINARY_FRAME_MAGIC.length
1064
+ && buffer.subarray(0, TCP_BINARY_FRAME_MAGIC.length).equals(TCP_BINARY_FRAME_MAGIC);
1065
+ }
1066
+
1067
+ function isJsonObjectStartByte(value) {
1068
+ return value === 0x7b;
1069
+ }
1070
+
1071
+ function isWhitespaceByte(value) {
1072
+ return value === 0x20 || value === 0x09 || value === 0x0d || value === 0x0a;
1073
+ }
1074
+
1075
+ function findFirstNonWhitespaceByte(buffer) {
1076
+ if (!Buffer.isBuffer(buffer)) {
1077
+ return -1;
1078
+ }
1079
+
1080
+ for (let index = 0; index < buffer.length; index += 1) {
1081
+ if (!isWhitespaceByte(buffer[index])) {
1082
+ return index;
1083
+ }
1084
+ }
1085
+ return -1;
1086
+ }
1087
+
1088
+ function findTcpFrameOnlySyncIndex(buffer, startIndex = 0) {
1089
+ if (!Buffer.isBuffer(buffer) || buffer.length <= 0) {
1090
+ return -1;
1091
+ }
1092
+
1093
+ const safeStart = Math.max(0, Math.min(buffer.length, Math.floor(Number(startIndex) || 0)));
1094
+ const magicIndex = buffer.indexOf(TCP_BINARY_FRAME_MAGIC, safeStart);
1095
+ const jsonIndex = buffer.indexOf(0x7b, safeStart);
1096
+ if (magicIndex < 0) {
1097
+ return jsonIndex;
1098
+ }
1099
+ if (jsonIndex < 0) {
1100
+ return magicIndex;
1101
+ }
1102
+ return Math.min(magicIndex, jsonIndex);
1103
+ }
1104
+
1105
+ function keepTcpFrameOnlySyncTail(buffer) {
1106
+ if (!Buffer.isBuffer(buffer) || buffer.length <= 0) {
1107
+ return Buffer.alloc(0);
1108
+ }
1109
+
1110
+ const maxTailLength = Math.min(buffer.length, TCP_BINARY_FRAME_MAGIC.length - 1);
1111
+ for (let tailLength = maxTailLength; tailLength > 0; tailLength -= 1) {
1112
+ const tail = buffer.subarray(buffer.length - tailLength);
1113
+ if (hasTcpBinaryFrameMagicPrefix(tail)) {
1114
+ return tail;
1115
+ }
1116
+ }
1117
+ return Buffer.alloc(0);
1118
+ }
1119
+
1120
+ function parseTcpBinaryFramePacket(buffer) {
1121
+ if (!Buffer.isBuffer(buffer) || buffer.length < TCP_BINARY_FRAME_HEADER_BYTES) {
1122
+ return { incomplete: true };
1123
+ }
1124
+ if (!startsWithTcpBinaryFrameMagic(buffer)) {
1125
+ return null;
1126
+ }
1127
+
1128
+ const metaLength = buffer.readUInt32BE(4);
1129
+ const payloadLength = buffer.readUInt32BE(8);
1130
+ const maxPayloadBytes = Math.max(MAX_THUMBNAIL_BINARY_BYTES, MAX_STREAM_BINARY_BYTES, MAX_AUDIO_BINARY_BYTES);
1131
+ if (!Number.isFinite(metaLength)
1132
+ || !Number.isFinite(payloadLength)
1133
+ || metaLength <= 0
1134
+ || metaLength > 64 * 1024
1135
+ || payloadLength <= 0
1136
+ || payloadLength > maxPayloadBytes) {
1137
+ return { invalid: true, reason: 'invalid-tcp-binary-frame-size' };
1138
+ }
1139
+
1140
+ const packetLength = TCP_BINARY_FRAME_HEADER_BYTES + metaLength + payloadLength;
1141
+ if (buffer.length < packetLength) {
1142
+ return { incomplete: true };
1143
+ }
1144
+
1145
+ try {
1146
+ const header = JSON.parse(buffer.subarray(TCP_BINARY_FRAME_HEADER_BYTES, TCP_BINARY_FRAME_HEADER_BYTES + metaLength).toString('utf8'));
1147
+ const payload = buffer.subarray(TCP_BINARY_FRAME_HEADER_BYTES + metaLength, packetLength);
1148
+ return { header, payload, packetLength };
1149
+ } catch (err) {
1150
+ return { invalid: true, reason: err?.message || 'invalid-tcp-binary-frame-header' };
1151
+ }
1152
+ }
1153
+
961
1154
  class RemoteHubWebSocketAgentSocket {
962
1155
  constructor(socket) {
963
1156
  this.__remoteHubWebSocket = true;
@@ -973,9 +1166,21 @@ class RemoteHubWebSocketAgentSocket {
973
1166
  this.onCloseMessage = () => {};
974
1167
  }
975
1168
 
976
- setNoDelay() {}
1169
+ setNoDelay(value = true) {
1170
+ try {
1171
+ this.socket?.setNoDelay?.(value);
1172
+ } catch {
1173
+ // Best-effort latency hint.
1174
+ }
1175
+ }
977
1176
 
978
- setKeepAlive() {}
1177
+ setKeepAlive(value = true, initialDelay = 0) {
1178
+ try {
1179
+ this.socket?.setKeepAlive?.(value, initialDelay);
1180
+ } catch {
1181
+ // Best-effort connection liveness hint.
1182
+ }
1183
+ }
979
1184
 
980
1185
  sendJson(payload) {
981
1186
  return this.sendText(JSON.stringify(payload));
@@ -1153,6 +1358,10 @@ export function createRemoteHub(options = {}) {
1153
1358
  const pairToken = safeString(
1154
1359
  options.pairToken || env.REMOTE_HUB_PAIR_TOKEN || env.LIVEDESK_CLIENT_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN || crypto.randomBytes(6).toString('hex'),
1155
1360
  256);
1361
+ let pairingPin = /^\d{6}$/.test(String(options.pairingPin || env.LIVEDESK_PAIRING_PIN || '').trim())
1362
+ ? String(options.pairingPin || env.LIVEDESK_PAIRING_PIN).trim()
1363
+ : generatePairingPin();
1364
+ let pairingPinUpdatedAt = new Date().toISOString();
1156
1365
  const duplicateDeviceLogThrottleMs = clampNumber(
1157
1366
  env.MINDEXEC_REMOTE_DUPLICATE_DEVICE_LOG_MS || env.REMOTE_HUB_DUPLICATE_DEVICE_LOG_MS,
1158
1367
  5000,
@@ -1167,6 +1376,10 @@ export function createRemoteHub(options = {}) {
1167
1376
  const devices = new Map();
1168
1377
  const taskBatches = new Map();
1169
1378
  const sockets = new Map();
1379
+ const inputSockets = new Map();
1380
+ const frameSockets = new Map();
1381
+ const audioSockets = new Map();
1382
+ const fileSockets = new Map();
1170
1383
  const allSockets = new Set();
1171
1384
  const duplicateDeviceLogAt = new Map();
1172
1385
  let server = null;
@@ -1485,6 +1698,8 @@ export function createRemoteHub(options = {}) {
1485
1698
  agentEndpointCandidateDetails: routeInfo.candidateDetails,
1486
1699
  pairToken: includeSecrets ? pairToken : undefined,
1487
1700
  pairTokenPreview: maskToken(pairToken),
1701
+ pairingPin: includeSecrets ? pairingPin : '',
1702
+ pairingPinUpdatedAt,
1488
1703
  deviceCount: devices.size,
1489
1704
  connectedDeviceCount: connectedDevices,
1490
1705
  canvasDeviceListMode: 'all-devices',
@@ -1506,6 +1721,19 @@ export function createRemoteHub(options = {}) {
1506
1721
  };
1507
1722
  }
1508
1723
 
1724
+ function rotatePairingPin() {
1725
+ pairingPin = generatePairingPin();
1726
+ pairingPinUpdatedAt = new Date().toISOString();
1727
+ emitRemoteEvent('RemotePairingPinRotated', null, {
1728
+ pairingPinUpdatedAt
1729
+ });
1730
+ return {
1731
+ ok: true,
1732
+ pairingPin,
1733
+ pairingPinUpdatedAt
1734
+ };
1735
+ }
1736
+
1509
1737
  function listDevices(options = {}) {
1510
1738
  const serializeOptions = {
1511
1739
  includeDataUrl: options.includeDataUrl === true
@@ -1593,8 +1821,8 @@ export function createRemoteHub(options = {}) {
1593
1821
  streamId: safeString(streamId, 128) || `${mode}-${Date.now()}`,
1594
1822
  frameSeq,
1595
1823
  commandId: safeString(options.commandId, 128),
1596
- width: clampNumber(options.width, 2, 2560, mode === 'remote-fast' ? 960 : 360),
1597
- height: clampNumber(options.height, 1, 1440, mode === 'remote-fast' ? 540 : 220),
1824
+ width: clampNumber(options.width, 2, 3840, mode === 'thumbnail' ? 360 : 640),
1825
+ height: clampNumber(options.height, 1, 2160, mode === 'thumbnail' ? 220 : 360),
1598
1826
  mimeType: 'image/png',
1599
1827
  format: 'image/png',
1600
1828
  mode,
@@ -1606,7 +1834,7 @@ export function createRemoteHub(options = {}) {
1606
1834
  transferProtocol: descriptor.transferProtocol,
1607
1835
  transferProtocolVersion: descriptor.transferProtocolVersion,
1608
1836
  handshake: descriptor.handshake,
1609
- fps: clampNumber(options.fps, 1, 24, mode === 'remote-fast' ? 20 : 1),
1837
+ fps: clampNumber(options.fps, 1, 20, mode === 'thumbnail' ? 1 : 2),
1610
1838
  capturedAt: now,
1611
1839
  receivedAt: now,
1612
1840
  byteLength: 68,
@@ -1614,6 +1842,8 @@ export function createRemoteHub(options = {}) {
1614
1842
  contentHash: SYNTHETIC_FRAME_HASH,
1615
1843
  captureMs: 0,
1616
1844
  sameContentStreak: 0,
1845
+ monitorIndex: normalizeMonitorIndex(options.monitorIndex ?? 0),
1846
+ monitorCount: clampNumber(options.monitorCount, 1, 64, 1),
1617
1847
  dataUrl: SYNTHETIC_FRAME_DATA_URL,
1618
1848
  payload: SYNTHETIC_FRAME_PAYLOAD,
1619
1849
  accessToken: createFrameAccessToken()
@@ -1646,15 +1876,18 @@ export function createRemoteHub(options = {}) {
1646
1876
  }
1647
1877
 
1648
1878
  function applySyntheticLiveFrame(device, streamId, options = {}) {
1649
- if (!device?.activeLiveStream?.active) {
1879
+ const streamState = getDeviceLiveStream(device, streamId);
1880
+ if (!streamState?.active) {
1650
1881
  return null;
1651
1882
  }
1652
1883
 
1653
- const frame = makeSyntheticFrame(device, streamId || device.activeLiveStream.streamId, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE, {
1654
- commandId: options.commandId || device.activeLiveStream.commandId,
1884
+ const frame = makeSyntheticFrame(device, streamId || streamState.streamId, streamState.mode || DEFAULT_REMOTE_FRAME_MODE, {
1885
+ commandId: options.commandId || streamState.commandId,
1655
1886
  width: options.maxWidth || 960,
1656
1887
  height: options.maxHeight || 540,
1657
- fps: options.fps || device.activeLiveStream.fps
1888
+ monitorIndex: options.monitorIndex ?? streamState.monitorIndex ?? 0,
1889
+ monitorCount: options.monitorCount ?? streamState.monitorCount ?? 1,
1890
+ fps: options.fps || streamState.fps
1658
1891
  });
1659
1892
  device.latestLiveFrame = frame;
1660
1893
  rememberRecentFramePayload(device, 'live', frame);
@@ -1666,9 +1899,10 @@ export function createRemoteHub(options = {}) {
1666
1899
  mimeType: frame.mimeType,
1667
1900
  byteLength: frame.byteLength
1668
1901
  });
1669
- device.activeLiveStream.lastFrameAt = frame.receivedAt;
1670
- device.activeLiveStream.lastFrameSeq = frame.frameSeq;
1671
- device.activeLiveStream.framesReceived = (device.activeLiveStream.framesReceived || 0) + 1;
1902
+ streamState.latestFrame = frame;
1903
+ streamState.lastFrameAt = frame.receivedAt;
1904
+ streamState.lastFrameSeq = frame.frameSeq;
1905
+ streamState.framesReceived = (streamState.framesReceived || 0) + 1;
1672
1906
  device.lastSeenAt = frame.receivedAt;
1673
1907
  device.counters.liveFramesReceived += 1;
1674
1908
  emitRemoteEvent('RemoteFrameReceived', device, {
@@ -1681,6 +1915,10 @@ export function createRemoteHub(options = {}) {
1681
1915
 
1682
1916
  function closeExistingDeviceSocket(deviceId, nextSessionId) {
1683
1917
  const existing = devices.get(deviceId);
1918
+ closeInputSocket(existing, 'replaced-by-new-session');
1919
+ closeFrameSocket(existing, 'replaced-by-new-session');
1920
+ closeAudioSocket(existing, 'replaced-by-new-session');
1921
+ closeFileSocket(existing, 'replaced-by-new-session');
1684
1922
  if (!existing?.socket || existing.socket.destroyed) {
1685
1923
  return;
1686
1924
  }
@@ -1695,6 +1933,154 @@ export function createRemoteHub(options = {}) {
1695
1933
  existing.socket.destroy();
1696
1934
  }
1697
1935
 
1936
+ function closeFrameSocket(device, reason = 'frame-socket-closed') {
1937
+ const socket = device?.frameSocket;
1938
+ if (!socket) {
1939
+ return;
1940
+ }
1941
+
1942
+ device.frameSocket = null;
1943
+ frameSockets.delete(socket);
1944
+ try {
1945
+ writeJsonLine(socket, { type: 'disconnect', channel: 'frame', reason });
1946
+ } catch {
1947
+ // Best-effort notice before closing the side channel.
1948
+ }
1949
+ try {
1950
+ socket.destroy?.();
1951
+ } catch {
1952
+ // Ignore close failures.
1953
+ }
1954
+ }
1955
+
1956
+ function closeAudioSocket(device, reason = 'audio-socket-closed') {
1957
+ const socket = device?.audioSocket;
1958
+ if (!socket) {
1959
+ return;
1960
+ }
1961
+
1962
+ device.audioSocket = null;
1963
+ audioSockets.delete(socket);
1964
+ try {
1965
+ writeJsonLine(socket, { type: 'disconnect', channel: 'audio', reason });
1966
+ } catch {
1967
+ // Best-effort notice before closing the side channel.
1968
+ }
1969
+ try {
1970
+ socket.destroy?.();
1971
+ } catch {
1972
+ // Ignore close failures.
1973
+ }
1974
+ }
1975
+
1976
+ function closeFileSocket(device, reason = 'file-socket-closed') {
1977
+ const socket = device?.fileSocket;
1978
+ if (!socket) {
1979
+ return;
1980
+ }
1981
+
1982
+ device.fileSocket = null;
1983
+ fileSockets.delete(socket);
1984
+ try {
1985
+ writeJsonLine(socket, { type: 'disconnect', channel: 'file', reason });
1986
+ } catch {
1987
+ // Best-effort notice before closing the side channel.
1988
+ }
1989
+ try {
1990
+ socket.destroy?.();
1991
+ } catch {
1992
+ // Ignore close failures.
1993
+ }
1994
+ }
1995
+
1996
+ function closeInputSocket(device, reason = 'input-socket-closed') {
1997
+ const socket = device?.inputSocket;
1998
+ if (!socket) {
1999
+ return;
2000
+ }
2001
+
2002
+ device.inputSocket = null;
2003
+ inputSockets.delete(socket);
2004
+ try {
2005
+ writeJsonLine(socket, { type: 'disconnect', reason });
2006
+ } catch {
2007
+ // Best-effort notice before closing the side channel.
2008
+ }
2009
+ try {
2010
+ socket.destroy?.();
2011
+ } catch {
2012
+ // Ignore close failures.
2013
+ }
2014
+ }
2015
+
2016
+ function detachInputSocket(socket, reason = 'input-socket-closed') {
2017
+ const deviceId = inputSockets.get(socket);
2018
+ inputSockets.delete(socket);
2019
+ if (!deviceId) {
2020
+ return;
2021
+ }
2022
+
2023
+ const device = devices.get(deviceId);
2024
+ if (!device || device.inputSocket !== socket) {
2025
+ return;
2026
+ }
2027
+
2028
+ device.inputSocket = null;
2029
+ device.inputLastSeenAt = new Date().toISOString();
2030
+ emitRemoteEvent('RemoteInputSocketDisconnected', device, { reason });
2031
+ }
2032
+
2033
+ function detachFrameSocket(socket, reason = 'frame-socket-closed') {
2034
+ const deviceId = frameSockets.get(socket);
2035
+ frameSockets.delete(socket);
2036
+ if (!deviceId) {
2037
+ return;
2038
+ }
2039
+
2040
+ const device = devices.get(deviceId);
2041
+ if (!device || device.frameSocket !== socket) {
2042
+ return;
2043
+ }
2044
+
2045
+ device.frameSocket = null;
2046
+ device.frameLastSeenAt = new Date().toISOString();
2047
+ emitRemoteEvent('RemoteFrameSocketDisconnected', device, { reason });
2048
+ }
2049
+
2050
+ function detachAudioSocket(socket, reason = 'audio-socket-closed') {
2051
+ const deviceId = audioSockets.get(socket);
2052
+ audioSockets.delete(socket);
2053
+ if (!deviceId) {
2054
+ return;
2055
+ }
2056
+
2057
+ const device = devices.get(deviceId);
2058
+ if (!device || device.audioSocket !== socket) {
2059
+ return;
2060
+ }
2061
+
2062
+ device.audioSocket = null;
2063
+ device.audioLastSeenAt = new Date().toISOString();
2064
+ emitRemoteEvent('RemoteAudioSocketDisconnected', device, { reason });
2065
+ }
2066
+
2067
+ function detachFileSocket(socket, reason = 'file-socket-closed') {
2068
+ const deviceId = fileSockets.get(socket);
2069
+ fileSockets.delete(socket);
2070
+ if (!deviceId) {
2071
+ return;
2072
+ }
2073
+
2074
+ const device = devices.get(deviceId);
2075
+ if (!device || device.fileSocket !== socket) {
2076
+ return;
2077
+ }
2078
+
2079
+ device.fileSocket = null;
2080
+ device.fileLastSeenAt = new Date().toISOString();
2081
+ emitRemoteEvent('RemoteFileSocketDisconnected', device, { reason });
2082
+ }
2083
+
1698
2084
  function getDeviceActivityMs(device) {
1699
2085
  return Math.max(
1700
2086
  Date.parse(device?.lastSeenAt || '') || 0,
@@ -2003,7 +2389,7 @@ export function createRemoteHub(options = {}) {
2003
2389
  transferProtocol: transfer.transferProtocol,
2004
2390
  transferProtocolVersion: transfer.transferProtocolVersion,
2005
2391
  handshake: transfer.handshake,
2006
- fps: 20,
2392
+ fps: 2,
2007
2393
  startedAt: seenAt,
2008
2394
  stoppedAt: '',
2009
2395
  stopReason: '',
@@ -2013,7 +2399,7 @@ export function createRemoteHub(options = {}) {
2013
2399
  };
2014
2400
  applySyntheticLiveFrame(device, device.activeLiveStream.streamId, {
2015
2401
  commandId: device.activeLiveStream.commandId,
2016
- fps: 20
2402
+ fps: 2
2017
2403
  });
2018
2404
  live += 1;
2019
2405
  }
@@ -2079,6 +2465,10 @@ export function createRemoteHub(options = {}) {
2079
2465
 
2080
2466
  const device = {
2081
2467
  socket,
2468
+ inputSocket: null,
2469
+ frameSocket: null,
2470
+ audioSocket: null,
2471
+ fileSocket: null,
2082
2472
  deviceId,
2083
2473
  sessionId,
2084
2474
  deviceName: safeString(hello.deviceName || hello.hostname || deviceId, 120),
@@ -2095,6 +2485,14 @@ export function createRemoteHub(options = {}) {
2095
2485
  capabilities,
2096
2486
  connected: true,
2097
2487
  connectedAt: now,
2488
+ inputConnectedAt: '',
2489
+ inputLastSeenAt: '',
2490
+ frameConnectedAt: '',
2491
+ frameLastSeenAt: '',
2492
+ audioConnectedAt: '',
2493
+ audioLastSeenAt: '',
2494
+ fileConnectedAt: '',
2495
+ fileLastSeenAt: '',
2098
2496
  disconnectedAt: '',
2099
2497
  lastSeenAt: now,
2100
2498
  lastStatusAt: '',
@@ -2138,6 +2536,126 @@ export function createRemoteHub(options = {}) {
2138
2536
  return device;
2139
2537
  }
2140
2538
 
2539
+ function attachInputSocket(socket, hello) {
2540
+ const deviceId = normalizeDeviceId(hello.deviceId);
2541
+ const device = devices.get(deviceId);
2542
+ if (!device || !device.connected || !device.socket || device.socket.destroyed) {
2543
+ writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
2544
+ socket.destroy();
2545
+ return null;
2546
+ }
2547
+
2548
+ closeInputSocket(device, 'replaced-by-new-input-socket');
2549
+
2550
+ const now = new Date().toISOString();
2551
+ device.inputSocket = socket;
2552
+ device.inputConnectedAt = now;
2553
+ device.inputLastSeenAt = now;
2554
+ inputSockets.set(socket, deviceId);
2555
+
2556
+ writeJsonLine(socket, {
2557
+ type: 'welcome',
2558
+ channel: 'input',
2559
+ protocol: REMOTE_AGENT_PROTOCOL,
2560
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
2561
+ sessionId: device.sessionId,
2562
+ deviceId,
2563
+ serverTime: now
2564
+ });
2565
+
2566
+ emitRemoteEvent('RemoteInputSocketConnected', device);
2567
+ return device;
2568
+ }
2569
+
2570
+ function attachFrameSocket(socket, hello) {
2571
+ const deviceId = normalizeDeviceId(hello.deviceId);
2572
+ const device = devices.get(deviceId);
2573
+ if (!device || !device.connected || !device.socket || device.socket.destroyed) {
2574
+ writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
2575
+ socket.destroy();
2576
+ return null;
2577
+ }
2578
+
2579
+ closeFrameSocket(device, 'replaced-by-new-frame-socket');
2580
+
2581
+ const now = new Date().toISOString();
2582
+ device.frameSocket = socket;
2583
+ device.frameConnectedAt = now;
2584
+ device.frameLastSeenAt = now;
2585
+ frameSockets.set(socket, deviceId);
2586
+
2587
+ writeJsonLine(socket, {
2588
+ type: 'welcome',
2589
+ channel: 'frame',
2590
+ protocol: REMOTE_AGENT_PROTOCOL,
2591
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
2592
+ sessionId: device.sessionId,
2593
+ deviceId,
2594
+ frameProtocol: buildRemoteFrameProtocolDescriptor(),
2595
+ frameModes: getSupportedRemoteFrameModeProfiles(),
2596
+ serverTime: now
2597
+ });
2598
+
2599
+ emitRemoteEvent('RemoteFrameSocketConnected', device);
2600
+ return device;
2601
+ }
2602
+
2603
+ function attachAudioSocket(socket, hello) {
2604
+ const deviceId = normalizeDeviceId(hello.deviceId);
2605
+ const device = devices.get(deviceId);
2606
+ if (!device || !device.connected || !device.socket || device.socket.destroyed) {
2607
+ writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
2608
+ socket.destroy();
2609
+ return null;
2610
+ }
2611
+
2612
+ closeAudioSocket(device, 'replaced-by-new-audio-socket');
2613
+ const now = new Date().toISOString();
2614
+ device.audioSocket = socket;
2615
+ device.audioConnectedAt = now;
2616
+ device.audioLastSeenAt = now;
2617
+ audioSockets.set(socket, deviceId);
2618
+ writeJsonLine(socket, {
2619
+ type: 'welcome',
2620
+ channel: 'audio',
2621
+ protocol: REMOTE_AGENT_PROTOCOL,
2622
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
2623
+ sessionId: device.sessionId,
2624
+ deviceId,
2625
+ serverTime: now
2626
+ });
2627
+ emitRemoteEvent('RemoteAudioSocketConnected', device);
2628
+ return device;
2629
+ }
2630
+
2631
+ function attachFileSocket(socket, hello) {
2632
+ const deviceId = normalizeDeviceId(hello.deviceId);
2633
+ const device = devices.get(deviceId);
2634
+ if (!device || !device.connected || !device.socket || device.socket.destroyed) {
2635
+ writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
2636
+ socket.destroy();
2637
+ return null;
2638
+ }
2639
+
2640
+ closeFileSocket(device, 'replaced-by-new-file-socket');
2641
+ const now = new Date().toISOString();
2642
+ device.fileSocket = socket;
2643
+ device.fileConnectedAt = now;
2644
+ device.fileLastSeenAt = now;
2645
+ fileSockets.set(socket, deviceId);
2646
+ writeJsonLine(socket, {
2647
+ type: 'welcome',
2648
+ channel: 'file',
2649
+ protocol: REMOTE_AGENT_PROTOCOL,
2650
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
2651
+ sessionId: device.sessionId,
2652
+ deviceId,
2653
+ serverTime: now
2654
+ });
2655
+ emitRemoteEvent('RemoteFileSocketConnected', device);
2656
+ return device;
2657
+ }
2658
+
2141
2659
  function detachSocket(socket, reason = 'socket-closed') {
2142
2660
  const deviceId = sockets.get(socket);
2143
2661
  sockets.delete(socket);
@@ -2152,13 +2670,11 @@ export function createRemoteHub(options = {}) {
2152
2670
 
2153
2671
  device.connected = false;
2154
2672
  device.socket = null;
2673
+ closeInputSocket(device, reason);
2674
+ closeFrameSocket(device, reason);
2155
2675
  device.disconnectedAt = new Date().toISOString();
2156
2676
  device.lastDisconnectReason = reason;
2157
- if (device.activeLiveStream) {
2158
- device.activeLiveStream.active = false;
2159
- device.activeLiveStream.stoppedAt = device.disconnectedAt;
2160
- device.activeLiveStream.stopReason = reason;
2161
- }
2677
+ deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
2162
2678
  failAllPendingTasks(device, 'device-disconnected');
2163
2679
  logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
2164
2680
  emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
@@ -2500,7 +3016,7 @@ export function createRemoteHub(options = {}) {
2500
3016
 
2501
3017
  function buildFramePayloadBuffer(framePayload, frameData) {
2502
3018
  if (Buffer.isBuffer(framePayload)) {
2503
- return Buffer.from(framePayload);
3019
+ return framePayload;
2504
3020
  }
2505
3021
 
2506
3022
  const raw = String(frameData || '');
@@ -2534,6 +3050,13 @@ export function createRemoteHub(options = {}) {
2534
3050
  : 0;
2535
3051
  }
2536
3052
 
3053
+ function readFrameStageMs(message, property) {
3054
+ const value = Number(message?.[property]);
3055
+ return Number.isFinite(value) && value >= 0
3056
+ ? Math.round(value)
3057
+ : 0;
3058
+ }
3059
+
2537
3060
  function computeSameContentStreak(previousFrame, contentHash) {
2538
3061
  if (!contentHash || !previousFrame?.contentHash || previousFrame.contentHash !== contentHash) {
2539
3062
  return 0;
@@ -2572,6 +3095,8 @@ export function createRemoteHub(options = {}) {
2572
3095
  commandId: safeString(message.commandId, 128),
2573
3096
  width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
2574
3097
  height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
3098
+ sourceWidth: Number.isFinite(Number(message.sourceWidth)) ? Number(message.sourceWidth) : Number(message.width || 0),
3099
+ sourceHeight: Number.isFinite(Number(message.sourceHeight)) ? Number(message.sourceHeight) : Number(message.height || 0),
2575
3100
  mimeType,
2576
3101
  format: mimeType,
2577
3102
  frameMode: transfer.frameMode,
@@ -2579,6 +3104,24 @@ export function createRemoteHub(options = {}) {
2579
3104
  encoding: transfer.encoding,
2580
3105
  codec: transfer.codec,
2581
3106
  compression: transfer.compression || mimeType,
3107
+ pixelFormat: safeString(message.pixelFormat, 40),
3108
+ bytesPerPixel: Number.isFinite(Number(message.bytesPerPixel)) ? Number(message.bytesPerPixel) : 0,
3109
+ uncompressedByteLength: Number.isFinite(Number(message.uncompressedByteLength)) ? Number(message.uncompressedByteLength) : 0,
3110
+ h264Format: safeString(message.h264Format, 40),
3111
+ isKeyFrame: message.isKeyFrame === true || safeString(message.chunkType, 20).toLowerCase() === 'key',
3112
+ chunkType: safeString(message.chunkType, 20),
3113
+ timestampUs: Number.isFinite(Number(message.timestampUs)) ? Number(message.timestampUs) : 0,
3114
+ durationUs: Number.isFinite(Number(message.durationUs)) ? Number(message.durationUs) : 0,
3115
+ sourceGapMs: Number.isFinite(Number(message.sourceGapMs)) ? Number(message.sourceGapMs) : 0,
3116
+ agentSendGapMs: Number.isFinite(Number(message.agentSendGapMs)) ? Number(message.agentSendGapMs) : 0,
3117
+ agentSendDurationMs: Number.isFinite(Number(message.agentSendDurationMs)) ? Number(message.agentSendDurationMs) : 0,
3118
+ agentSendDurationFrameSeq: Number.isFinite(Number(message.agentSendDurationFrameSeq)) ? Number(message.agentSendDurationFrameSeq) : 0,
3119
+ agentPaceMs: Number.isFinite(Number(message.agentPaceMs)) ? Number(message.agentPaceMs) : 0,
3120
+ agentQueueBeforePaceMs: Number.isFinite(Number(message.agentQueueBeforePaceMs)) ? Number(message.agentQueueBeforePaceMs) : 0,
3121
+ agentFrameAgeMs: Number.isFinite(Number(message.agentFrameAgeMs)) ? Number(message.agentFrameAgeMs) : 0,
3122
+ droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
3123
+ hardwareEncoder: safeString(message.hardwareEncoder, 80),
3124
+ platformProfile: safeString(message.platformProfile, 80),
2582
3125
  transferProtocol: transfer.transferProtocol,
2583
3126
  transferProtocolVersion: transfer.transferProtocolVersion,
2584
3127
  handshake: transfer.handshake,
@@ -2588,6 +3131,16 @@ export function createRemoteHub(options = {}) {
2588
3131
  transport,
2589
3132
  contentHash,
2590
3133
  captureMs: readFrameCaptureMs(message),
3134
+ captureStageMs: readFrameStageMs(message, 'captureStageMs'),
3135
+ convertMs: readFrameStageMs(message, 'convertMs'),
3136
+ compressMs: readFrameStageMs(message, 'compressMs'),
3137
+ captureSourceSerial: Number.isFinite(Number(message.captureSourceSerial)) ? Number(message.captureSourceSerial) : 0,
3138
+ captureSourceSkipped: Number.isFinite(Number(message.captureSourceSkipped)) ? Number(message.captureSourceSkipped) : 0,
3139
+ deltaTileSize: Number.isFinite(Number(message.deltaTileSize)) ? Number(message.deltaTileSize) : 0,
3140
+ deltaTileCount: Number.isFinite(Number(message.deltaTileCount)) ? Number(message.deltaTileCount) : 0,
3141
+ deltaVisualTileCount: Number.isFinite(Number(message.deltaVisualTileCount)) ? Number(message.deltaVisualTileCount) : 0,
3142
+ deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
3143
+ deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
2591
3144
  sameContentStreak,
2592
3145
  payload,
2593
3146
  accessToken: createFrameAccessToken()
@@ -2608,7 +3161,8 @@ export function createRemoteHub(options = {}) {
2608
3161
 
2609
3162
  function applyLiveStreamOpen(device, message, transport = 'json') {
2610
3163
  const streamId = safeString(message.streamId, 128) || 'live';
2611
- if (!device.activeLiveStream?.active || device.activeLiveStream.streamId !== streamId) {
3164
+ const streamState = getDeviceLiveStream(device, streamId);
3165
+ if (!streamState?.active) {
2612
3166
  emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
2613
3167
  reason: 'stale-live-stream-open',
2614
3168
  streamId,
@@ -2617,10 +3171,23 @@ export function createRemoteHub(options = {}) {
2617
3171
  return false;
2618
3172
  }
2619
3173
 
2620
- const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE);
3174
+ const commandId = safeString(message.commandId, 128);
3175
+ const activeCommandId = safeString(streamState.commandId, 128);
3176
+ if (commandId && activeCommandId && commandId !== activeCommandId) {
3177
+ emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
3178
+ reason: 'stale-live-stream-generation',
3179
+ streamId,
3180
+ commandId,
3181
+ activeCommandId,
3182
+ transport
3183
+ });
3184
+ return false;
3185
+ }
3186
+
3187
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, streamState.mode || DEFAULT_REMOTE_FRAME_MODE);
2621
3188
  const openedAt = safeString(message.openedAt, 80) || device.lastSeenAt || new Date().toISOString();
2622
- device.activeLiveStream = {
2623
- ...device.activeLiveStream,
3189
+ const nextStreamState = {
3190
+ ...streamState,
2624
3191
  open: true,
2625
3192
  openedAt,
2626
3193
  openTransport: transport,
@@ -2633,10 +3200,15 @@ export function createRemoteHub(options = {}) {
2633
3200
  transferProtocol: transfer.transferProtocol,
2634
3201
  transferProtocolVersion: transfer.transferProtocolVersion,
2635
3202
  handshake: transfer.handshake,
2636
- width: Number.isFinite(Number(message.width)) ? Number(message.width) : Number(device.activeLiveStream.width || 0),
2637
- height: Number.isFinite(Number(message.height)) ? Number(message.height) : Number(device.activeLiveStream.height || 0),
2638
- openedFrameSeq: Number.isFinite(Number(message.frameSeq)) ? Number(message.frameSeq) : Number(device.activeLiveStream.openedFrameSeq || 0)
3203
+ width: Number.isFinite(Number(message.width)) ? Number(message.width) : Number(streamState.width || 0),
3204
+ height: Number.isFinite(Number(message.height)) ? Number(message.height) : Number(streamState.height || 0),
3205
+ sourceWidth: Number.isFinite(Number(message.sourceWidth)) ? Number(message.sourceWidth) : Number(streamState.sourceWidth || 0),
3206
+ sourceHeight: Number.isFinite(Number(message.sourceHeight)) ? Number(message.sourceHeight) : Number(streamState.sourceHeight || 0),
3207
+ monitorIndex: Number.isFinite(Number(message.monitorIndex)) ? normalizeMonitorIndex(message.monitorIndex) : Number(streamState.monitorIndex || 0),
3208
+ monitorCount: Number.isFinite(Number(message.monitorCount)) ? clampNumber(message.monitorCount, 1, 64, 1) : Number(streamState.monitorCount || 1),
3209
+ openedFrameSeq: Number.isFinite(Number(message.frameSeq)) ? Number(message.frameSeq) : Number(streamState.openedFrameSeq || 0)
2639
3210
  };
3211
+ setDeviceLiveStream(device, nextStreamState);
2640
3212
  emitRemoteEvent('RemoteLiveStreamOpened', device, {
2641
3213
  streamId,
2642
3214
  mode: transfer.mode,
@@ -2655,7 +3227,9 @@ export function createRemoteHub(options = {}) {
2655
3227
  : safeString(framePayload, MAX_STREAM_BASE64_CHARS + 1);
2656
3228
  const frameSeq = Number(message.frameSeq);
2657
3229
  const streamId = safeString(message.streamId, 128) || 'live';
2658
- if (!device.activeLiveStream?.active || device.activeLiveStream.streamId !== streamId) {
3230
+ const commandId = safeString(message.commandId, 128);
3231
+ const streamState = getDeviceLiveStream(device, streamId);
3232
+ if (!streamState?.active) {
2659
3233
  device.counters.liveFramesDropped += 1;
2660
3234
  emitRemoteEvent('RemoteFrameDropped', device, {
2661
3235
  reason: 'stale-live-stream-frame',
@@ -2665,6 +3239,19 @@ export function createRemoteHub(options = {}) {
2665
3239
  });
2666
3240
  return false;
2667
3241
  }
3242
+ const activeCommandId = safeString(streamState.commandId, 128);
3243
+ if (commandId && activeCommandId && commandId !== activeCommandId) {
3244
+ device.counters.liveFramesDropped += 1;
3245
+ emitRemoteEvent('RemoteFrameDropped', device, {
3246
+ reason: 'stale-live-stream-generation-frame',
3247
+ streamId,
3248
+ commandId,
3249
+ activeCommandId,
3250
+ frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
3251
+ transport
3252
+ });
3253
+ return false;
3254
+ }
2668
3255
 
2669
3256
  const byteLength = normalizeFrameByteLength(framePayload, frameData);
2670
3257
  if ((!Buffer.isBuffer(framePayload) && !frameData)
@@ -2684,28 +3271,31 @@ export function createRemoteHub(options = {}) {
2684
3271
  const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
2685
3272
  const payload = buildFramePayloadBuffer(framePayload, frameData);
2686
3273
  const contentHash = buildFrameContentHash(message, payload);
2687
- const sameContentStreak = computeSameContentStreak(device.latestLiveFrame, contentHash);
2688
- const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE);
2689
- if (device.activeLiveStream.open !== true) {
2690
- device.activeLiveStream.open = true;
2691
- device.activeLiveStream.openedAt = device.lastSeenAt;
2692
- device.activeLiveStream.openTransport = `${transport}-implicit`;
2693
- device.activeLiveStream.mode = transfer.mode;
2694
- device.activeLiveStream.frameMode = transfer.frameMode;
2695
- device.activeLiveStream.frameProfile = transfer.frameProfile;
2696
- device.activeLiveStream.encoding = transfer.encoding;
2697
- device.activeLiveStream.codec = transfer.codec;
2698
- device.activeLiveStream.compression = transfer.compression;
2699
- device.activeLiveStream.transferProtocol = transfer.transferProtocol;
2700
- device.activeLiveStream.transferProtocolVersion = transfer.transferProtocolVersion;
2701
- device.activeLiveStream.handshake = transfer.handshake;
3274
+ const sameContentStreak = computeSameContentStreak(streamState.latestFrame, contentHash);
3275
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, streamState.mode || DEFAULT_REMOTE_FRAME_MODE);
3276
+ if (streamState.open !== true) {
3277
+ streamState.open = true;
3278
+ streamState.openedAt = device.lastSeenAt;
3279
+ streamState.openTransport = `${transport}-implicit`;
3280
+ streamState.mode = transfer.mode;
3281
+ streamState.frameMode = transfer.frameMode;
3282
+ streamState.frameProfile = transfer.frameProfile;
3283
+ streamState.encoding = transfer.encoding;
3284
+ streamState.codec = transfer.codec;
3285
+ streamState.compression = transfer.compression;
3286
+ streamState.transferProtocol = transfer.transferProtocol;
3287
+ streamState.transferProtocolVersion = transfer.transferProtocolVersion;
3288
+ streamState.handshake = transfer.handshake;
2702
3289
  }
2703
3290
  device.latestLiveFrame = {
2704
3291
  streamId,
2705
3292
  frameSeq,
2706
- commandId: safeString(message.commandId, 128),
3293
+ streamFrameSeq: Number.isFinite(Number(message.streamFrameSeq)) ? Number(message.streamFrameSeq) : frameSeq,
3294
+ commandId,
2707
3295
  width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
2708
3296
  height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
3297
+ sourceWidth: Number.isFinite(Number(message.sourceWidth)) ? Number(message.sourceWidth) : Number(message.width || 0),
3298
+ sourceHeight: Number.isFinite(Number(message.sourceHeight)) ? Number(message.sourceHeight) : Number(message.height || 0),
2709
3299
  mimeType,
2710
3300
  format: mimeType,
2711
3301
  mode: transfer.mode,
@@ -2714,20 +3304,52 @@ export function createRemoteHub(options = {}) {
2714
3304
  encoding: transfer.encoding,
2715
3305
  codec: transfer.codec,
2716
3306
  compression: transfer.compression || mimeType,
3307
+ pixelFormat: safeString(message.pixelFormat, 40),
3308
+ bytesPerPixel: Number.isFinite(Number(message.bytesPerPixel)) ? Number(message.bytesPerPixel) : 0,
3309
+ uncompressedByteLength: Number.isFinite(Number(message.uncompressedByteLength)) ? Number(message.uncompressedByteLength) : 0,
3310
+ h264Format: safeString(message.h264Format, 40),
3311
+ isKeyFrame: message.isKeyFrame === true || safeString(message.chunkType, 20).toLowerCase() === 'key',
3312
+ chunkType: safeString(message.chunkType, 20),
3313
+ timestampUs: Number.isFinite(Number(message.timestampUs)) ? Number(message.timestampUs) : 0,
3314
+ durationUs: Number.isFinite(Number(message.durationUs)) ? Number(message.durationUs) : 0,
3315
+ sourceGapMs: Number.isFinite(Number(message.sourceGapMs)) ? Number(message.sourceGapMs) : 0,
3316
+ agentSendGapMs: Number.isFinite(Number(message.agentSendGapMs)) ? Number(message.agentSendGapMs) : 0,
3317
+ agentSendDurationMs: Number.isFinite(Number(message.agentSendDurationMs)) ? Number(message.agentSendDurationMs) : 0,
3318
+ agentSendDurationFrameSeq: Number.isFinite(Number(message.agentSendDurationFrameSeq)) ? Number(message.agentSendDurationFrameSeq) : 0,
3319
+ agentPaceMs: Number.isFinite(Number(message.agentPaceMs)) ? Number(message.agentPaceMs) : 0,
3320
+ agentQueueBeforePaceMs: Number.isFinite(Number(message.agentQueueBeforePaceMs)) ? Number(message.agentQueueBeforePaceMs) : 0,
3321
+ agentFrameAgeMs: Number.isFinite(Number(message.agentFrameAgeMs)) ? Number(message.agentFrameAgeMs) : 0,
3322
+ droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
3323
+ hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
3324
+ hardwareEncoder: safeString(message.hardwareEncoder, 80),
3325
+ platformProfile: safeString(message.platformProfile, 80),
3326
+ monitorIndex: Number.isFinite(Number(message.monitorIndex)) ? normalizeMonitorIndex(message.monitorIndex) : Number(streamState.monitorIndex || 0),
3327
+ monitorCount: Number.isFinite(Number(message.monitorCount)) ? clampNumber(message.monitorCount, 1, 64, 1) : Number(streamState.monitorCount || 1),
2717
3328
  transferProtocol: transfer.transferProtocol,
2718
3329
  transferProtocolVersion: transfer.transferProtocolVersion,
2719
3330
  handshake: transfer.handshake,
2720
- fps: Number.isFinite(Number(message.fps)) ? Number(message.fps) : device.activeLiveStream.fps,
3331
+ fps: Number.isFinite(Number(message.fps)) ? Number(message.fps) : streamState.fps,
2721
3332
  capturedAt,
2722
3333
  receivedAt: device.lastSeenAt,
2723
3334
  byteLength,
2724
3335
  transport,
2725
3336
  contentHash,
2726
3337
  captureMs: readFrameCaptureMs(message),
3338
+ captureStageMs: readFrameStageMs(message, 'captureStageMs'),
3339
+ convertMs: readFrameStageMs(message, 'convertMs'),
3340
+ compressMs: readFrameStageMs(message, 'compressMs'),
3341
+ captureSourceSerial: Number.isFinite(Number(message.captureSourceSerial)) ? Number(message.captureSourceSerial) : 0,
3342
+ captureSourceSkipped: Number.isFinite(Number(message.captureSourceSkipped)) ? Number(message.captureSourceSkipped) : 0,
3343
+ deltaTileSize: Number.isFinite(Number(message.deltaTileSize)) ? Number(message.deltaTileSize) : 0,
3344
+ deltaTileCount: Number.isFinite(Number(message.deltaTileCount)) ? Number(message.deltaTileCount) : 0,
3345
+ deltaVisualTileCount: Number.isFinite(Number(message.deltaVisualTileCount)) ? Number(message.deltaVisualTileCount) : 0,
3346
+ deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
3347
+ deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
2727
3348
  sameContentStreak,
2728
3349
  payload,
2729
3350
  accessToken: createFrameAccessToken()
2730
3351
  };
3352
+ streamState.latestFrame = device.latestLiveFrame;
2731
3353
  rememberRecentFramePayload(device, 'live', device.latestLiveFrame);
2732
3354
  emitFrame({
2733
3355
  kind: 'live',
@@ -2737,29 +3359,26 @@ export function createRemoteHub(options = {}) {
2737
3359
  mimeType: device.latestLiveFrame.mimeType,
2738
3360
  byteLength: device.latestLiveFrame.byteLength
2739
3361
  });
2740
- device.activeLiveStream.lastFrameAt = device.lastSeenAt;
2741
- device.activeLiveStream.lastFrameSeq = frameSeq;
2742
- device.activeLiveStream.framesReceived = (device.activeLiveStream.framesReceived || 0) + 1;
2743
- device.activeLiveStream.mode = transfer.mode;
2744
- device.activeLiveStream.frameMode = transfer.frameMode;
2745
- device.activeLiveStream.frameProfile = transfer.frameProfile;
2746
- device.activeLiveStream.encoding = transfer.encoding;
2747
- device.activeLiveStream.codec = transfer.codec;
2748
- device.activeLiveStream.compression = transfer.compression;
2749
- device.activeLiveStream.transferProtocol = transfer.transferProtocol;
2750
- device.activeLiveStream.transferProtocolVersion = transfer.transferProtocolVersion;
2751
- device.activeLiveStream.handshake = transfer.handshake;
3362
+ streamState.lastFrameAt = device.lastSeenAt;
3363
+ streamState.lastFrameSeq = frameSeq;
3364
+ streamState.framesReceived = (streamState.framesReceived || 0) + 1;
3365
+ streamState.mode = transfer.mode;
3366
+ streamState.frameMode = transfer.frameMode;
3367
+ streamState.frameProfile = transfer.frameProfile;
3368
+ streamState.encoding = transfer.encoding;
3369
+ streamState.codec = transfer.codec;
3370
+ streamState.compression = transfer.compression;
3371
+ streamState.transferProtocol = transfer.transferProtocol;
3372
+ streamState.transferProtocolVersion = transfer.transferProtocolVersion;
3373
+ streamState.handshake = transfer.handshake;
3374
+ streamState.width = device.latestLiveFrame.width;
3375
+ streamState.height = device.latestLiveFrame.height;
3376
+ streamState.sourceWidth = device.latestLiveFrame.sourceWidth;
3377
+ streamState.sourceHeight = device.latestLiveFrame.sourceHeight;
3378
+ streamState.monitorIndex = device.latestLiveFrame.monitorIndex;
3379
+ streamState.monitorCount = device.latestLiveFrame.monitorCount;
3380
+ ensureDeviceLiveStreams(device).set(streamId, streamState);
2752
3381
  device.counters.liveFramesReceived += 1;
2753
- emitRemoteEvent('RemoteFrameReceived', device, {
2754
- streamId,
2755
- frameSeq,
2756
- width: device.latestLiveFrame.width,
2757
- height: device.latestLiveFrame.height,
2758
- mode: device.latestLiveFrame.mode,
2759
- transport,
2760
- contentHash,
2761
- sameContentStreak
2762
- });
2763
3382
  return true;
2764
3383
  }
2765
3384
 
@@ -2827,13 +3446,6 @@ export function createRemoteHub(options = {}) {
2827
3446
  mimeType,
2828
3447
  byteLength
2829
3448
  });
2830
- emitRemoteEvent('RemoteAudioFrameReceived', device, {
2831
- streamId,
2832
- frameSeq,
2833
- mimeType,
2834
- byteLength,
2835
- transport
2836
- });
2837
3449
  return true;
2838
3450
  }
2839
3451
 
@@ -2845,8 +3457,20 @@ export function createRemoteHub(options = {}) {
2845
3457
  }
2846
3458
 
2847
3459
  const device = state.device;
3460
+ if (state.inputOnly || state.fileOnly) {
3461
+ writeJsonLine(socket, { type: 'error', error: 'input-channel-binary-not-supported' });
3462
+ socket.destroy();
3463
+ return;
3464
+ }
3465
+
2848
3466
  device.counters.messagesReceived += 1;
2849
3467
  device.lastSeenAt = new Date().toISOString();
3468
+ if (state.frameOnly) {
3469
+ device.frameLastSeenAt = device.lastSeenAt;
3470
+ }
3471
+ if (state.audioOnly) {
3472
+ device.audioLastSeenAt = device.lastSeenAt;
3473
+ }
2850
3474
 
2851
3475
  const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
2852
3476
  if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
@@ -2855,7 +3479,10 @@ export function createRemoteHub(options = {}) {
2855
3479
  }
2856
3480
 
2857
3481
  if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
2858
- applyLiveFrame(device, header, framePayload, 'binary');
3482
+ applyLiveFrame(device, {
3483
+ ...header,
3484
+ hubIngressDropped: Number(state.binaryQueueDrops || 0)
3485
+ }, framePayload, 'binary');
2859
3486
  return;
2860
3487
  }
2861
3488
 
@@ -2870,6 +3497,103 @@ export function createRemoteHub(options = {}) {
2870
3497
  });
2871
3498
  }
2872
3499
 
3500
+ function dropQueuedAgentBinaryFrame(state) {
3501
+ if (!Array.isArray(state.binaryQueue) || state.binaryQueue.length === 0) {
3502
+ return;
3503
+ }
3504
+ const deltaIndex = state.binaryQueue.findIndex(item => {
3505
+ const header = item?.header || {};
3506
+ const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
3507
+ const chunkType = safeString(header.chunkType, 20).toLowerCase();
3508
+ return (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary')
3509
+ && header.isKeyFrame !== true
3510
+ && chunkType !== 'key';
3511
+ });
3512
+ const index = deltaIndex >= 0 ? deltaIndex : 0;
3513
+ state.binaryQueue.splice(index, 1);
3514
+ state.binaryQueueDrops = Number(state.binaryQueueDrops || 0) + 1;
3515
+ }
3516
+
3517
+ function isMode5BinaryHeader(header) {
3518
+ const mode = safeString(header?.frameMode || header?.mode, 40).toLowerCase();
3519
+ return mode === 'mode5-lzo-delta';
3520
+ }
3521
+
3522
+ function dropQueuedMode5AgentBinaryFrame(state, header) {
3523
+ const streamId = safeString(header?.streamId, 160);
3524
+ const dropIndex = state.binaryQueue.findIndex(item => isMode5BinaryHeader(item?.header)
3525
+ && (!streamId || safeString(item?.header?.streamId, 160) === streamId));
3526
+ if (dropIndex < 0) {
3527
+ return false;
3528
+ }
3529
+ state.binaryQueue.splice(dropIndex, 1);
3530
+ state.binaryQueueDrops = Number(state.binaryQueueDrops || 0) + 1;
3531
+ return true;
3532
+ }
3533
+
3534
+ function scheduleAgentBinaryIngress(socket, state) {
3535
+ if (!state || state.closed || !socket || socket.destroyed) {
3536
+ return;
3537
+ }
3538
+
3539
+ state.binaryQueueSocket = socket;
3540
+ if (state.binaryQueueScheduled === true) {
3541
+ return;
3542
+ }
3543
+ state.binaryQueueScheduled = true;
3544
+ setImmediate(() => drainAgentBinaryIngressLane(state));
3545
+ }
3546
+
3547
+ function drainAgentBinaryIngressLane(state) {
3548
+ state.binaryQueueScheduled = false;
3549
+ const socket = state.binaryQueueSocket;
3550
+ if (state.closed || !socket || socket.destroyed) {
3551
+ if (Array.isArray(state.binaryQueue)) {
3552
+ state.binaryQueue.length = 0;
3553
+ }
3554
+ return;
3555
+ }
3556
+
3557
+ let processed = 0;
3558
+ while (state.binaryQueue.length > 0 && processed < AGENT_BINARY_INGRESS_DRAIN_BUDGET_PACKETS) {
3559
+ const item = state.binaryQueue.shift();
3560
+ if (!item) {
3561
+ continue;
3562
+ }
3563
+ handleAgentBinaryFrame(socket, state, item.header, item.payload);
3564
+ processed += 1;
3565
+ }
3566
+
3567
+ if (state.binaryQueue.length > 0 && !state.closed && !socket.destroyed) {
3568
+ scheduleAgentBinaryIngress(socket, state);
3569
+ }
3570
+ }
3571
+
3572
+ function enqueueAgentBinaryFrame(socket, state, header, payload) {
3573
+ if (state.closed || socket.destroyed) {
3574
+ return;
3575
+ }
3576
+ if (!Array.isArray(state.binaryQueue)) {
3577
+ state.binaryQueue = [];
3578
+ }
3579
+ if (isMode5BinaryHeader(header)) {
3580
+ const streamId = safeString(header?.streamId, 160);
3581
+ let queuedMode5Packets = state.binaryQueue.filter(item => isMode5BinaryHeader(item?.header)
3582
+ && (!streamId || safeString(item?.header?.streamId, 160) === streamId)).length;
3583
+ while (queuedMode5Packets >= MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS) {
3584
+ if (!dropQueuedMode5AgentBinaryFrame(state, header)) {
3585
+ break;
3586
+ }
3587
+ queuedMode5Packets -= 1;
3588
+ }
3589
+ }
3590
+ while (state.binaryQueue.length >= AGENT_BINARY_INGRESS_QUEUE_PACKETS) {
3591
+ dropQueuedAgentBinaryFrame(state);
3592
+ }
3593
+ state.binaryQueue.push({ header, payload });
3594
+ scheduleAgentBinaryIngress(socket, state);
3595
+ }
3596
+
2873
3597
  function handleAgentMessage(socket, state, message) {
2874
3598
  if (!message || typeof message !== 'object') {
2875
3599
  return;
@@ -2888,6 +3612,52 @@ export function createRemoteHub(options = {}) {
2888
3612
  return;
2889
3613
  }
2890
3614
 
3615
+ const channel = safeString(message.channel || message.Channel, 40).toLowerCase();
3616
+ if (channel === 'input') {
3617
+ const device = attachInputSocket(socket, message);
3618
+ if (!device) {
3619
+ return;
3620
+ }
3621
+
3622
+ state.authenticated = true;
3623
+ state.device = device;
3624
+ state.inputOnly = true;
3625
+ return;
3626
+ }
3627
+ if (channel === 'frame') {
3628
+ const device = attachFrameSocket(socket, message);
3629
+ if (!device) {
3630
+ return;
3631
+ }
3632
+
3633
+ state.authenticated = true;
3634
+ state.device = device;
3635
+ state.frameOnly = true;
3636
+ return;
3637
+ }
3638
+ if (channel === 'audio') {
3639
+ const device = attachAudioSocket(socket, message);
3640
+ if (!device) {
3641
+ return;
3642
+ }
3643
+
3644
+ state.authenticated = true;
3645
+ state.device = device;
3646
+ state.audioOnly = true;
3647
+ return;
3648
+ }
3649
+ if (channel === 'file') {
3650
+ const device = attachFileSocket(socket, message);
3651
+ if (!device) {
3652
+ return;
3653
+ }
3654
+
3655
+ state.authenticated = true;
3656
+ state.device = device;
3657
+ state.fileOnly = true;
3658
+ return;
3659
+ }
3660
+
2891
3661
  const device = attachDevice(socket, message);
2892
3662
  if (!device) {
2893
3663
  return;
@@ -2904,6 +3674,34 @@ export function createRemoteHub(options = {}) {
2904
3674
  return;
2905
3675
  }
2906
3676
 
3677
+ if (state.inputOnly) {
3678
+ device.inputLastSeenAt = new Date().toISOString();
3679
+ if (message.type === 'input.applied') {
3680
+ emitEvent('RemoteInputApplied', {
3681
+ deviceId: device.deviceId,
3682
+ inputSeq: Number(message.inputSeq || 0) || 0,
3683
+ inputType: safeString(message.inputType, 48),
3684
+ issuedAtEpochMs: Number(message.issuedAtEpochMs || 0) || 0,
3685
+ hubReceivedAtEpochMs: Number(message.hubReceivedAtEpochMs || 0) || 0,
3686
+ hubForwardedAtEpochMs: Number(message.hubForwardedAtEpochMs || 0) || 0,
3687
+ agentApplyMs: Number(message.agentApplyMs || 0) || 0,
3688
+ hubAcknowledgedAtEpochMs: Date.now()
3689
+ });
3690
+ }
3691
+ return;
3692
+ }
3693
+ if (state.frameOnly) {
3694
+ device.frameLastSeenAt = new Date().toISOString();
3695
+ return;
3696
+ }
3697
+ if (state.audioOnly) {
3698
+ device.audioLastSeenAt = new Date().toISOString();
3699
+ return;
3700
+ }
3701
+ if (state.fileOnly) {
3702
+ device.fileLastSeenAt = new Date().toISOString();
3703
+ }
3704
+
2907
3705
  device.counters.messagesReceived += 1;
2908
3706
  device.lastSeenAt = new Date().toISOString();
2909
3707
 
@@ -2975,7 +3773,17 @@ export function createRemoteHub(options = {}) {
2975
3773
 
2976
3774
  const state = {
2977
3775
  authenticated: false,
2978
- device: null
3776
+ device: null,
3777
+ inputOnly: false,
3778
+ frameOnly: false,
3779
+ audioOnly: false,
3780
+ fileOnly: false,
3781
+ binaryQueue: [],
3782
+ binaryQueueDraining: false,
3783
+ binaryQueueScheduled: false,
3784
+ binaryQueueSocket: null,
3785
+ binaryQueueDrops: 0,
3786
+ closed: false
2979
3787
  };
2980
3788
 
2981
3789
  const helloTimer = setTimeout(() => {
@@ -3016,7 +3824,7 @@ export function createRemoteHub(options = {}) {
3016
3824
  return;
3017
3825
  }
3018
3826
 
3019
- handleAgentBinaryFrame(socket, state, packet.header, packet.payload);
3827
+ enqueueAgentBinaryFrame(socket, state, packet.header, packet.payload);
3020
3828
  } catch (err) {
3021
3829
  writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
3022
3830
  logWarn('remote', `invalid websocket binary frame: ${err?.message || err}`);
@@ -3024,8 +3832,16 @@ export function createRemoteHub(options = {}) {
3024
3832
  };
3025
3833
 
3026
3834
  const detach = reason => {
3835
+ state.closed = true;
3836
+ state.binaryQueue.length = 0;
3837
+ state.binaryQueueScheduled = false;
3838
+ state.binaryQueueSocket = null;
3027
3839
  allSockets.delete(socket);
3028
3840
  clearTimeout(helloTimer);
3841
+ detachInputSocket(socket, reason);
3842
+ detachFrameSocket(socket, reason);
3843
+ detachAudioSocket(socket, reason);
3844
+ detachFileSocket(socket, reason);
3029
3845
  detachSocket(socket, reason);
3030
3846
  };
3031
3847
 
@@ -3115,8 +3931,18 @@ export function createRemoteHub(options = {}) {
3115
3931
  const state = {
3116
3932
  authenticated: false,
3117
3933
  device: null,
3934
+ inputOnly: false,
3935
+ frameOnly: false,
3936
+ audioOnly: false,
3937
+ fileOnly: false,
3118
3938
  buffer: Buffer.alloc(0),
3119
- pendingBinaryFrame: null
3939
+ pendingBinaryFrame: null,
3940
+ binaryQueue: [],
3941
+ binaryQueueDraining: false,
3942
+ binaryQueueScheduled: false,
3943
+ binaryQueueSocket: null,
3944
+ binaryQueueDrops: 0,
3945
+ closed: false
3120
3946
  };
3121
3947
 
3122
3948
  const helloTimer = setTimeout(() => {
@@ -3142,10 +3968,57 @@ export function createRemoteHub(options = {}) {
3142
3968
  const framePayload = state.buffer.subarray(0, byteLength);
3143
3969
  state.buffer = state.buffer.subarray(byteLength);
3144
3970
  state.pendingBinaryFrame = null;
3145
- handleAgentBinaryFrame(socket, state, header, framePayload);
3971
+ enqueueAgentBinaryFrame(socket, state, header, framePayload);
3146
3972
  continue;
3147
3973
  }
3148
3974
 
3975
+ if (state.authenticated && (state.frameOnly || state.audioOnly) && state.buffer.length > 0) {
3976
+ const firstNonWhitespaceIndex = findFirstNonWhitespaceByte(state.buffer);
3977
+ if (firstNonWhitespaceIndex < 0) {
3978
+ state.buffer = Buffer.alloc(0);
3979
+ return;
3980
+ }
3981
+ if (firstNonWhitespaceIndex > 0) {
3982
+ state.buffer = state.buffer.subarray(firstNonWhitespaceIndex);
3983
+ }
3984
+
3985
+ if (state.buffer.length < TCP_BINARY_FRAME_MAGIC.length && hasTcpBinaryFrameMagicPrefix(state.buffer)) {
3986
+ return;
3987
+ }
3988
+
3989
+ if (startsWithTcpBinaryFrameMagic(state.buffer)) {
3990
+ const packet = parseTcpBinaryFramePacket(state.buffer);
3991
+ if (packet?.incomplete) {
3992
+ return;
3993
+ }
3994
+ if (!packet || packet.invalid) {
3995
+ const syncIndex = findTcpFrameOnlySyncIndex(state.buffer, 1);
3996
+ if (syncIndex > 0) {
3997
+ state.buffer = state.buffer.subarray(syncIndex);
3998
+ continue;
3999
+ }
4000
+
4001
+ state.buffer = keepTcpFrameOnlySyncTail(state.buffer);
4002
+ return;
4003
+ }
4004
+
4005
+ state.buffer = state.buffer.subarray(packet.packetLength);
4006
+ enqueueAgentBinaryFrame(socket, state, packet.header, packet.payload);
4007
+ continue;
4008
+ }
4009
+
4010
+ if (!isJsonObjectStartByte(state.buffer[0])) {
4011
+ const syncIndex = findTcpFrameOnlySyncIndex(state.buffer, 1);
4012
+ if (syncIndex > 0) {
4013
+ state.buffer = state.buffer.subarray(syncIndex);
4014
+ continue;
4015
+ }
4016
+
4017
+ state.buffer = keepTcpFrameOnlySyncTail(state.buffer);
4018
+ return;
4019
+ }
4020
+ }
4021
+
3149
4022
  const newlineIndex = state.buffer.indexOf(0x0a);
3150
4023
  if (newlineIndex < 0) {
3151
4024
  if (state.buffer.length > MAX_LINE_CHARS) {
@@ -3181,6 +4054,9 @@ export function createRemoteHub(options = {}) {
3181
4054
 
3182
4055
  handleAgentMessage(socket, state, message);
3183
4056
  } catch (err) {
4057
+ if (state.authenticated && (state.frameOnly || state.audioOnly)) {
4058
+ continue;
4059
+ }
3184
4060
  writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
3185
4061
  logWarn('remote', `invalid agent message: ${err?.message || err}`);
3186
4062
  }
@@ -3193,13 +4069,29 @@ export function createRemoteHub(options = {}) {
3193
4069
  }
3194
4070
 
3195
4071
  socket.on('close', () => {
4072
+ state.closed = true;
4073
+ state.binaryQueue.length = 0;
4074
+ state.binaryQueueScheduled = false;
4075
+ state.binaryQueueSocket = null;
3196
4076
  allSockets.delete(socket);
3197
4077
  clearTimeout(helloTimer);
4078
+ detachInputSocket(socket);
4079
+ detachFrameSocket(socket);
4080
+ detachAudioSocket(socket);
4081
+ detachFileSocket(socket);
3198
4082
  detachSocket(socket);
3199
4083
  });
3200
4084
  socket.on('error', err => {
4085
+ state.closed = true;
4086
+ state.binaryQueue.length = 0;
4087
+ state.binaryQueueScheduled = false;
4088
+ state.binaryQueueSocket = null;
3201
4089
  allSockets.delete(socket);
3202
4090
  clearTimeout(helloTimer);
4091
+ detachInputSocket(socket, err?.message || 'socket-error');
4092
+ detachFrameSocket(socket, err?.message || 'socket-error');
4093
+ detachAudioSocket(socket, err?.message || 'socket-error');
4094
+ detachFileSocket(socket, err?.message || 'socket-error');
3203
4095
  detachSocket(socket, err?.message || 'socket-error');
3204
4096
  });
3205
4097
  }
@@ -3236,9 +4128,9 @@ export function createRemoteHub(options = {}) {
3236
4128
  started = true;
3237
4129
  boundPort = server.address()?.port || requestedPort;
3238
4130
  lastError = '';
3239
- logEvent('remote', `RemoteHub listening on tcp://${host}:${boundPort}`, 'success');
4131
+ logEvent('remote', `LiveDesk Hub client endpoint listening on tcp://${host}:${boundPort}`, 'success');
3240
4132
  if (host === '0.0.0.0' || host === '::') {
3241
- logWarn('remote', 'RemoteHub is externally reachable. Use a strong pairing token and trusted network.');
4133
+ logWarn('remote', 'LiveDesk Hub client endpoint is externally reachable. Use a strong pairing token and trusted network.');
3242
4134
  }
3243
4135
  emitRemoteEvent('RemoteHubStarted', null);
3244
4136
  resolve();
@@ -3301,11 +4193,7 @@ export function createRemoteHub(options = {}) {
3301
4193
  device.connected = false;
3302
4194
  device.disconnectedAt = new Date().toISOString();
3303
4195
  device.lastDisconnectReason = reason;
3304
- if (device.activeLiveStream) {
3305
- device.activeLiveStream.active = false;
3306
- device.activeLiveStream.stoppedAt = device.disconnectedAt;
3307
- device.activeLiveStream.stopReason = reason;
3308
- }
4196
+ deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
3309
4197
  failAllPendingTasks(device, 'device-disconnected');
3310
4198
  emitRemoteEvent('RemoteDeviceDisconnected', device, { reason, synthetic: true });
3311
4199
  return true;
@@ -3350,19 +4238,26 @@ export function createRemoteHub(options = {}) {
3350
4238
  }
3351
4239
 
3352
4240
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
4241
+ const commandName = safeString(command?.command || 'ping', 80);
3353
4242
  const payload = {
3354
4243
  type: 'command',
3355
4244
  commandId,
3356
- command: safeString(command?.command || 'ping', 80),
4245
+ command: commandName,
3357
4246
  payload: command?.payload ?? null,
3358
4247
  issuedAt: new Date().toISOString()
3359
4248
  };
3360
4249
 
3361
- writeJsonLine(device.socket, payload);
4250
+ const dedicatedFileSocket = commandName.startsWith('file.transfer')
4251
+ && device.fileSocket
4252
+ && !device.fileSocket.destroyed
4253
+ ? device.fileSocket
4254
+ : null;
4255
+ writeJsonLine(dedicatedFileSocket || device.socket, payload);
3362
4256
  device.counters.commandsSent += 1;
3363
4257
  emitRemoteEvent('RemoteCommandQueued', device, {
3364
4258
  commandId,
3365
- command: payload.command
4259
+ command: payload.command,
4260
+ channel: dedicatedFileSocket ? 'file' : 'control'
3366
4261
  });
3367
4262
  return { ok: true, commandId };
3368
4263
  }
@@ -3386,10 +4281,30 @@ export function createRemoteHub(options = {}) {
3386
4281
  return { ok: false, error: 'missing-input-type' };
3387
4282
  }
3388
4283
 
4284
+ const inputSocket = device.inputSocket;
4285
+ if (inputSocket && !inputSocket.destroyed) {
4286
+ const sent = writeJsonLine(inputSocket, {
4287
+ type: 'input.control',
4288
+ payload: {
4289
+ ...normalized,
4290
+ hubForwardedAtEpochMs: Date.now(),
4291
+ issuedAt: normalized.issuedAt || new Date().toISOString()
4292
+ }
4293
+ });
4294
+ if (sent) {
4295
+ device.counters.commandsSent += 1;
4296
+ device.inputLastSeenAt = new Date().toISOString();
4297
+ return { ok: true, inputSocket: true };
4298
+ }
4299
+
4300
+ detachInputSocket(inputSocket, 'input-socket-write-failed');
4301
+ }
4302
+
3389
4303
  return sendCommand(deviceId, {
3390
4304
  command: 'input.control',
3391
4305
  payload: {
3392
4306
  ...normalized,
4307
+ hubForwardedAtEpochMs: Date.now(),
3393
4308
  issuedAt: normalized.issuedAt || new Date().toISOString()
3394
4309
  }
3395
4310
  });
@@ -3603,6 +4518,121 @@ export function createRemoteHub(options = {}) {
3603
4518
  });
3604
4519
  }
3605
4520
 
4521
+ function ensureDeviceLiveStreams(device) {
4522
+ if (!(device?.activeLiveStreams instanceof Map)) {
4523
+ Object.defineProperty(device, 'activeLiveStreams', {
4524
+ value: new Map(),
4525
+ enumerable: false,
4526
+ configurable: true,
4527
+ writable: true
4528
+ });
4529
+ if (device?.activeLiveStream?.streamId) {
4530
+ device.activeLiveStreams.set(device.activeLiveStream.streamId, device.activeLiveStream);
4531
+ }
4532
+ }
4533
+ return device.activeLiveStreams;
4534
+ }
4535
+
4536
+ function getDeviceLiveStream(device, streamId) {
4537
+ const id = safeString(streamId, 128);
4538
+ if (!id) {
4539
+ return device?.activeLiveStream || null;
4540
+ }
4541
+ return ensureDeviceLiveStreams(device).get(id)
4542
+ || (device?.activeLiveStream?.streamId === id ? device.activeLiveStream : null);
4543
+ }
4544
+
4545
+ function setDeviceLiveStream(device, stream) {
4546
+ if (!device || !stream?.streamId) {
4547
+ return stream;
4548
+ }
4549
+ ensureDeviceLiveStreams(device).set(stream.streamId, stream);
4550
+ device.activeLiveStream = stream;
4551
+ return stream;
4552
+ }
4553
+
4554
+ function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
4555
+ const streams = ensureDeviceLiveStreams(device);
4556
+ for (const stream of streams.values()) {
4557
+ stream.active = false;
4558
+ stream.stoppedAt = stoppedAt;
4559
+ stream.stopReason = reason;
4560
+ }
4561
+ if (device?.activeLiveStream) {
4562
+ device.activeLiveStream.active = false;
4563
+ device.activeLiveStream.stoppedAt = stoppedAt;
4564
+ device.activeLiveStream.stopReason = reason;
4565
+ }
4566
+ }
4567
+
4568
+ function makeStableLiveStreamId(deviceId, purpose = 'wall') {
4569
+ const hash = crypto.createHash('sha1').update(String(deviceId || 'device')).digest('hex').slice(0, 16);
4570
+ const role = safeString(purpose, 24).toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || 'wall';
4571
+ return `${role}-${hash}`;
4572
+ }
4573
+
4574
+ function normalizeLiveStreamStartOptions(options = {}) {
4575
+ const transfer = buildRemoteFrameTransferDescriptor(options.mode || options.frameMode || DEFAULT_REMOTE_FRAME_MODE);
4576
+ const streamPurpose = safeString(options.streamPurpose || options.purpose || 'wall', 24) || 'wall';
4577
+ const maxFps = streamPurpose === 'control' ? 60 : 30;
4578
+ const fps = clampNumber(options.fps, 1, maxFps, 8);
4579
+ const maxWidth = clampNumber(options.maxWidth, 320, 3840, 640);
4580
+ const maxHeight = clampNumber(options.maxHeight, 180, 2160, 360);
4581
+ const quality = clampNumber(options.quality, 20, 95, 45);
4582
+ const monitorIndex = normalizeMonitorIndex(options.monitorIndex ?? options.screenIndex ?? options.displayIndex);
4583
+ return { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose };
4584
+ }
4585
+
4586
+ function liveStreamStartStillPending(activeLiveStream) {
4587
+ if (!activeLiveStream?.active || activeLiveStream.open === true) {
4588
+ return false;
4589
+ }
4590
+ const startedAt = Date.parse(activeLiveStream.startedAt || '');
4591
+ return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_PENDING_REUSE_MS;
4592
+ }
4593
+
4594
+ function getLiveStreamFreshWindowMs(activeLiveStream) {
4595
+ const fps = Number(activeLiveStream?.fps || 0);
4596
+ const frameMs = Number.isFinite(fps) && fps > 0 ? 1000 / fps : 500;
4597
+ return Math.max(
4598
+ LIVE_STREAM_MIN_FRESH_MS,
4599
+ Math.min(LIVE_STREAM_MAX_FRESH_MS, Math.round(frameMs * 20))
4600
+ );
4601
+ }
4602
+
4603
+ function getLiveStreamIdleMs(activeLiveStream) {
4604
+ const lastFrameAt = Date.parse(activeLiveStream?.lastFrameAt || activeLiveStream?.openedAt || activeLiveStream?.startedAt || '');
4605
+ if (!Number.isFinite(lastFrameAt)) {
4606
+ return Infinity;
4607
+ }
4608
+ return Math.max(0, Date.now() - lastFrameAt);
4609
+ }
4610
+
4611
+ function liveStreamIsReusable(activeLiveStream) {
4612
+ if (!activeLiveStream?.active) {
4613
+ return false;
4614
+ }
4615
+ if (liveStreamStartStillPending(activeLiveStream)) {
4616
+ return true;
4617
+ }
4618
+ if (activeLiveStream.open !== true) {
4619
+ return false;
4620
+ }
4621
+ return getLiveStreamIdleMs(activeLiveStream) <= getLiveStreamFreshWindowMs(activeLiveStream);
4622
+ }
4623
+
4624
+ function liveStreamMatchesOptions(activeLiveStream, normalized) {
4625
+ if (!activeLiveStream?.active) {
4626
+ return false;
4627
+ }
4628
+ return activeLiveStream.frameMode === normalized.transfer.frameMode
4629
+ && Number(activeLiveStream.fps || 0) === normalized.fps
4630
+ && Number(activeLiveStream.maxWidth || 0) === normalized.maxWidth
4631
+ && Number(activeLiveStream.maxHeight || 0) === normalized.maxHeight
4632
+ && Number(activeLiveStream.quality || 0) === normalized.quality
4633
+ && Number(activeLiveStream.monitorIndex || 0) === normalized.monitorIndex;
4634
+ }
4635
+
3606
4636
  function startLiveStream(deviceId, options = {}) {
3607
4637
  const device = devices.get(String(deviceId || ''));
3608
4638
  if (device?.synthetic === true && device.connected) {
@@ -3611,12 +4641,28 @@ export function createRemoteHub(options = {}) {
3611
4641
  }
3612
4642
 
3613
4643
  const now = new Date().toISOString();
3614
- const streamId = safeString(options.streamId, 128) || `live-${Date.now()}`;
3615
- const fps = clampNumber(options.fps, 1, 24, 20);
4644
+ const normalized = normalizeLiveStreamStartOptions({ fps: 2, ...options });
4645
+ const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
4646
+ const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
4647
+ const activeLiveStream = getDeviceLiveStream(device, streamId);
3616
4648
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3617
- const transfer = buildRemoteFrameTransferDescriptor(options.mode || DEFAULT_REMOTE_FRAME_MODE);
4649
+ if (activeLiveStream
4650
+ && liveStreamMatchesOptions(activeLiveStream, normalized)
4651
+ && (activeLiveStream.open === true || liveStreamStartStillPending(activeLiveStream))) {
4652
+ return {
4653
+ ok: true,
4654
+ commandId: activeLiveStream.commandId,
4655
+ streamId,
4656
+ fps,
4657
+ mode: transfer.mode,
4658
+ frameMode: transfer.frameMode,
4659
+ monitorIndex,
4660
+ synthetic: true,
4661
+ reused: true
4662
+ };
4663
+ }
3618
4664
  device.counters.commandsSent += 1;
3619
- device.activeLiveStream = {
4665
+ setDeviceLiveStream(device, {
3620
4666
  streamId,
3621
4667
  commandId,
3622
4668
  active: true,
@@ -3633,18 +4679,25 @@ export function createRemoteHub(options = {}) {
3633
4679
  transferProtocolVersion: transfer.transferProtocolVersion,
3634
4680
  handshake: transfer.handshake,
3635
4681
  fps,
4682
+ maxWidth,
4683
+ maxHeight,
4684
+ quality,
4685
+ monitorIndex,
4686
+ monitorCount: 1,
3636
4687
  startedAt: now,
3637
4688
  stoppedAt: '',
3638
4689
  stopReason: '',
3639
4690
  lastFrameAt: '',
3640
4691
  lastFrameSeq: 0,
3641
- framesReceived: 0
3642
- };
4692
+ framesReceived: 0,
4693
+ streamPurpose
4694
+ });
3643
4695
  device.counters.liveStreamsStarted += 1;
3644
4696
  applySyntheticLiveFrame(device, streamId, {
3645
4697
  commandId,
3646
- maxWidth: options.maxWidth,
3647
- maxHeight: options.maxHeight,
4698
+ maxWidth,
4699
+ maxHeight,
4700
+ monitorIndex,
3648
4701
  fps
3649
4702
  });
3650
4703
  emitRemoteEvent('RemoteLiveStreamStarted', device, {
@@ -3654,7 +4707,7 @@ export function createRemoteHub(options = {}) {
3654
4707
  mode: transfer.mode,
3655
4708
  synthetic: true
3656
4709
  });
3657
- return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode, synthetic: true };
4710
+ return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode, monitorIndex, synthetic: true };
3658
4711
  }
3659
4712
 
3660
4713
  if (!device?.socket || device.socket.destroyed || !device.connected) {
@@ -3665,10 +4718,76 @@ export function createRemoteHub(options = {}) {
3665
4718
  }
3666
4719
 
3667
4720
  const now = new Date().toISOString();
3668
- const streamId = safeString(options.streamId, 128) || `live-${Date.now()}`;
3669
- const fps = clampNumber(options.fps, 1, 24, 12);
4721
+ const normalized = normalizeLiveStreamStartOptions(options);
4722
+ const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
4723
+ const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
4724
+ const activeLiveStream = getDeviceLiveStream(device, streamId);
3670
4725
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3671
- const transfer = buildRemoteFrameTransferDescriptor(options.mode || DEFAULT_REMOTE_FRAME_MODE);
4726
+ if (activeLiveStream
4727
+ && options.forceRestart !== true
4728
+ && options.reuseExisting === true
4729
+ && liveStreamMatchesOptions(activeLiveStream, normalized)
4730
+ && liveStreamIsReusable(activeLiveStream)) {
4731
+ return {
4732
+ ok: true,
4733
+ commandId: activeLiveStream.commandId,
4734
+ streamId,
4735
+ fps: Number(activeLiveStream.fps || fps),
4736
+ mode: activeLiveStream.mode || transfer.mode,
4737
+ frameMode: activeLiveStream.frameMode || transfer.frameMode,
4738
+ monitorIndex: Number(activeLiveStream.monitorIndex || monitorIndex),
4739
+ reused: true
4740
+ };
4741
+ }
4742
+ if (activeLiveStream
4743
+ && options.forceRestart !== true
4744
+ && liveStreamMatchesOptions(activeLiveStream, normalized)
4745
+ && liveStreamIsReusable(activeLiveStream)) {
4746
+ if (options.silentReuse !== true) {
4747
+ emitRemoteEvent('RemoteLiveStreamStartReused', device, {
4748
+ streamId,
4749
+ fps,
4750
+ mode: transfer.mode,
4751
+ frameMode: transfer.frameMode,
4752
+ monitorIndex
4753
+ });
4754
+ }
4755
+ return {
4756
+ ok: true,
4757
+ commandId: activeLiveStream.commandId,
4758
+ streamId,
4759
+ fps,
4760
+ mode: transfer.mode,
4761
+ frameMode: transfer.frameMode,
4762
+ monitorIndex,
4763
+ reused: true
4764
+ };
4765
+ }
4766
+ if (options.forceRestart === true && activeLiveStream) {
4767
+ emitRemoteEvent('RemoteLiveStreamForcedRestart', device, {
4768
+ streamId,
4769
+ fps,
4770
+ mode: transfer.mode,
4771
+ frameMode: transfer.frameMode,
4772
+ monitorIndex,
4773
+ reason: safeString(options.restartReason || 'browser-stale', 128)
4774
+ });
4775
+ }
4776
+ if (activeLiveStream
4777
+ && liveStreamMatchesOptions(activeLiveStream, normalized)
4778
+ && activeLiveStream.open === true
4779
+ && !liveStreamIsReusable(activeLiveStream)) {
4780
+ emitRemoteEvent('RemoteLiveStreamStaleRestart', device, {
4781
+ streamId,
4782
+ fps,
4783
+ mode: transfer.mode,
4784
+ frameMode: transfer.frameMode,
4785
+ monitorIndex,
4786
+ idleMs: getLiveStreamIdleMs(activeLiveStream),
4787
+ staleAfterMs: getLiveStreamFreshWindowMs(activeLiveStream),
4788
+ lastFrameAt: activeLiveStream.lastFrameAt || ''
4789
+ });
4790
+ }
3672
4791
  const result = sendCommand(deviceId, {
3673
4792
  command: 'stream.start',
3674
4793
  commandId,
@@ -3684,9 +4803,11 @@ export function createRemoteHub(options = {}) {
3684
4803
  transferProtocolVersion: transfer.transferProtocolVersion,
3685
4804
  handshake: transfer.handshake,
3686
4805
  fps,
3687
- maxWidth: clampNumber(options.maxWidth, 320, 2560, 960),
3688
- maxHeight: clampNumber(options.maxHeight, 180, 1440, 540),
3689
- quality: clampNumber(options.quality, 20, 95, 60),
4806
+ maxWidth,
4807
+ maxHeight,
4808
+ quality,
4809
+ monitorIndex,
4810
+ streamPurpose,
3690
4811
  requestedAt: now
3691
4812
  }
3692
4813
  });
@@ -3695,7 +4816,7 @@ export function createRemoteHub(options = {}) {
3695
4816
  return result;
3696
4817
  }
3697
4818
 
3698
- device.activeLiveStream = {
4819
+ setDeviceLiveStream(device, {
3699
4820
  streamId,
3700
4821
  commandId,
3701
4822
  active: true,
@@ -3712,23 +4833,30 @@ export function createRemoteHub(options = {}) {
3712
4833
  transferProtocolVersion: transfer.transferProtocolVersion,
3713
4834
  handshake: transfer.handshake,
3714
4835
  fps,
4836
+ maxWidth,
4837
+ maxHeight,
4838
+ quality,
4839
+ monitorIndex,
4840
+ monitorCount: 1,
3715
4841
  startedAt: now,
3716
4842
  stoppedAt: '',
3717
4843
  stopReason: '',
3718
4844
  lastFrameAt: '',
3719
4845
  lastFrameSeq: 0,
3720
- framesReceived: 0
3721
- };
4846
+ framesReceived: 0,
4847
+ streamPurpose
4848
+ });
3722
4849
  device.counters.liveStreamsStarted += 1;
3723
4850
  emitRemoteEvent('RemoteLiveStreamStarted', device, {
3724
4851
  streamId,
3725
4852
  commandId,
3726
4853
  fps,
3727
4854
  mode: transfer.mode,
3728
- frameMode: transfer.frameMode
4855
+ frameMode: transfer.frameMode,
4856
+ monitorIndex
3729
4857
  });
3730
4858
 
3731
- return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode };
4859
+ return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode, monitorIndex };
3732
4860
  }
3733
4861
 
3734
4862
  function stopLiveStream(deviceId, options = {}) {
@@ -3737,10 +4865,11 @@ export function createRemoteHub(options = {}) {
3737
4865
  const streamId = safeString(options.streamId, 128) || device.activeLiveStream?.streamId || '';
3738
4866
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3739
4867
  device.counters.commandsSent += 1;
3740
- if (device.activeLiveStream && (!streamId || device.activeLiveStream.streamId === streamId)) {
3741
- device.activeLiveStream.active = false;
3742
- device.activeLiveStream.stoppedAt = new Date().toISOString();
3743
- device.activeLiveStream.stopReason = 'manager-request';
4868
+ const streamState = getDeviceLiveStream(device, streamId);
4869
+ if (streamState) {
4870
+ streamState.active = false;
4871
+ streamState.stoppedAt = new Date().toISOString();
4872
+ streamState.stopReason = 'manager-request';
3744
4873
  }
3745
4874
  device.counters.liveStreamsStopped += 1;
3746
4875
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
@@ -3770,10 +4899,11 @@ export function createRemoteHub(options = {}) {
3770
4899
  return result;
3771
4900
  }
3772
4901
 
3773
- if (device.activeLiveStream && (!streamId || device.activeLiveStream.streamId === streamId)) {
3774
- device.activeLiveStream.active = false;
3775
- device.activeLiveStream.stoppedAt = new Date().toISOString();
3776
- device.activeLiveStream.stopReason = 'manager-request';
4902
+ const streamState = getDeviceLiveStream(device, streamId);
4903
+ if (streamState) {
4904
+ streamState.active = false;
4905
+ streamState.stoppedAt = new Date().toISOString();
4906
+ streamState.stopReason = 'manager-request';
3777
4907
  }
3778
4908
  device.counters.liveStreamsStopped += 1;
3779
4909
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
@@ -3925,6 +5055,7 @@ export function createRemoteHub(options = {}) {
3925
5055
  requestAgentTask,
3926
5056
  requestAgentTaskBatch,
3927
5057
  setHostTarget,
5058
+ rotatePairingPin,
3928
5059
  requestThumbnail,
3929
5060
  startLiveStream,
3930
5061
  stopLiveStream,
@@ -3935,7 +5066,8 @@ export function createRemoteHub(options = {}) {
3935
5066
  getFramePayload,
3936
5067
  seedSyntheticFleet,
3937
5068
  clearSyntheticFleet,
3938
- getPairToken: () => pairToken
5069
+ getPairToken: () => pairToken,
5070
+ getPairingPin: () => pairingPin
3939
5071
  };
3940
5072
  }
3941
5073