livedesk 0.1.81 → 0.1.83

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.
@@ -22,6 +22,9 @@ const RECENT_THUMBNAIL_FRAME_CACHE_LIMIT = 4;
22
22
  const RECENT_LIVE_FRAME_CACHE_LIMIT = 80;
23
23
  const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 3 * 1024 * 1024;
24
24
  const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 24 * 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';
@@ -1205,6 +1208,7 @@ export function createRemoteHub(options = {}) {
1205
1208
  const devices = new Map();
1206
1209
  const taskBatches = new Map();
1207
1210
  const sockets = new Map();
1211
+ const inputSockets = new Map();
1208
1212
  const allSockets = new Set();
1209
1213
  const duplicateDeviceLogAt = new Map();
1210
1214
  let server = null;
@@ -1738,6 +1742,7 @@ export function createRemoteHub(options = {}) {
1738
1742
 
1739
1743
  function closeExistingDeviceSocket(deviceId, nextSessionId) {
1740
1744
  const existing = devices.get(deviceId);
1745
+ closeInputSocket(existing, 'replaced-by-new-session');
1741
1746
  if (!existing?.socket || existing.socket.destroyed) {
1742
1747
  return;
1743
1748
  }
@@ -1752,6 +1757,43 @@ export function createRemoteHub(options = {}) {
1752
1757
  existing.socket.destroy();
1753
1758
  }
1754
1759
 
1760
+ function closeInputSocket(device, reason = 'input-socket-closed') {
1761
+ const socket = device?.inputSocket;
1762
+ if (!socket) {
1763
+ return;
1764
+ }
1765
+
1766
+ device.inputSocket = null;
1767
+ inputSockets.delete(socket);
1768
+ try {
1769
+ writeJsonLine(socket, { type: 'disconnect', reason });
1770
+ } catch {
1771
+ // Best-effort notice before closing the side channel.
1772
+ }
1773
+ try {
1774
+ socket.destroy?.();
1775
+ } catch {
1776
+ // Ignore close failures.
1777
+ }
1778
+ }
1779
+
1780
+ function detachInputSocket(socket, reason = 'input-socket-closed') {
1781
+ const deviceId = inputSockets.get(socket);
1782
+ inputSockets.delete(socket);
1783
+ if (!deviceId) {
1784
+ return;
1785
+ }
1786
+
1787
+ const device = devices.get(deviceId);
1788
+ if (!device || device.inputSocket !== socket) {
1789
+ return;
1790
+ }
1791
+
1792
+ device.inputSocket = null;
1793
+ device.inputLastSeenAt = new Date().toISOString();
1794
+ emitRemoteEvent('RemoteInputSocketDisconnected', device, { reason });
1795
+ }
1796
+
1755
1797
  function getDeviceActivityMs(device) {
1756
1798
  return Math.max(
1757
1799
  Date.parse(device?.lastSeenAt || '') || 0,
@@ -2136,6 +2178,7 @@ export function createRemoteHub(options = {}) {
2136
2178
 
2137
2179
  const device = {
2138
2180
  socket,
2181
+ inputSocket: null,
2139
2182
  deviceId,
2140
2183
  sessionId,
2141
2184
  deviceName: safeString(hello.deviceName || hello.hostname || deviceId, 120),
@@ -2152,6 +2195,8 @@ export function createRemoteHub(options = {}) {
2152
2195
  capabilities,
2153
2196
  connected: true,
2154
2197
  connectedAt: now,
2198
+ inputConnectedAt: '',
2199
+ inputLastSeenAt: '',
2155
2200
  disconnectedAt: '',
2156
2201
  lastSeenAt: now,
2157
2202
  lastStatusAt: '',
@@ -2195,6 +2240,37 @@ export function createRemoteHub(options = {}) {
2195
2240
  return device;
2196
2241
  }
2197
2242
 
2243
+ function attachInputSocket(socket, hello) {
2244
+ const deviceId = normalizeDeviceId(hello.deviceId);
2245
+ const device = devices.get(deviceId);
2246
+ if (!device || !device.connected || !device.socket || device.socket.destroyed) {
2247
+ writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
2248
+ socket.destroy();
2249
+ return null;
2250
+ }
2251
+
2252
+ closeInputSocket(device, 'replaced-by-new-input-socket');
2253
+
2254
+ const now = new Date().toISOString();
2255
+ device.inputSocket = socket;
2256
+ device.inputConnectedAt = now;
2257
+ device.inputLastSeenAt = now;
2258
+ inputSockets.set(socket, deviceId);
2259
+
2260
+ writeJsonLine(socket, {
2261
+ type: 'welcome',
2262
+ channel: 'input',
2263
+ protocol: REMOTE_AGENT_PROTOCOL,
2264
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
2265
+ sessionId: device.sessionId,
2266
+ deviceId,
2267
+ serverTime: now
2268
+ });
2269
+
2270
+ emitRemoteEvent('RemoteInputSocketConnected', device);
2271
+ return device;
2272
+ }
2273
+
2198
2274
  function detachSocket(socket, reason = 'socket-closed') {
2199
2275
  const deviceId = sockets.get(socket);
2200
2276
  sockets.delete(socket);
@@ -2209,6 +2285,7 @@ export function createRemoteHub(options = {}) {
2209
2285
 
2210
2286
  device.connected = false;
2211
2287
  device.socket = null;
2288
+ closeInputSocket(device, reason);
2212
2289
  device.disconnectedAt = new Date().toISOString();
2213
2290
  device.lastDisconnectReason = reason;
2214
2291
  if (device.activeLiveStream) {
@@ -2973,6 +3050,12 @@ export function createRemoteHub(options = {}) {
2973
3050
  }
2974
3051
 
2975
3052
  const device = state.device;
3053
+ if (state.inputOnly) {
3054
+ writeJsonLine(socket, { type: 'error', error: 'input-channel-binary-not-supported' });
3055
+ socket.destroy();
3056
+ return;
3057
+ }
3058
+
2976
3059
  device.counters.messagesReceived += 1;
2977
3060
  device.lastSeenAt = new Date().toISOString();
2978
3061
 
@@ -3016,6 +3099,19 @@ export function createRemoteHub(options = {}) {
3016
3099
  return;
3017
3100
  }
3018
3101
 
3102
+ const channel = safeString(message.channel || message.Channel, 40).toLowerCase();
3103
+ if (channel === 'input') {
3104
+ const device = attachInputSocket(socket, message);
3105
+ if (!device) {
3106
+ return;
3107
+ }
3108
+
3109
+ state.authenticated = true;
3110
+ state.device = device;
3111
+ state.inputOnly = true;
3112
+ return;
3113
+ }
3114
+
3019
3115
  const device = attachDevice(socket, message);
3020
3116
  if (!device) {
3021
3117
  return;
@@ -3032,6 +3128,11 @@ export function createRemoteHub(options = {}) {
3032
3128
  return;
3033
3129
  }
3034
3130
 
3131
+ if (state.inputOnly) {
3132
+ device.inputLastSeenAt = new Date().toISOString();
3133
+ return;
3134
+ }
3135
+
3035
3136
  device.counters.messagesReceived += 1;
3036
3137
  device.lastSeenAt = new Date().toISOString();
3037
3138
 
@@ -3103,7 +3204,8 @@ export function createRemoteHub(options = {}) {
3103
3204
 
3104
3205
  const state = {
3105
3206
  authenticated: false,
3106
- device: null
3207
+ device: null,
3208
+ inputOnly: false
3107
3209
  };
3108
3210
 
3109
3211
  const helloTimer = setTimeout(() => {
@@ -3154,6 +3256,7 @@ export function createRemoteHub(options = {}) {
3154
3256
  const detach = reason => {
3155
3257
  allSockets.delete(socket);
3156
3258
  clearTimeout(helloTimer);
3259
+ detachInputSocket(socket, reason);
3157
3260
  detachSocket(socket, reason);
3158
3261
  };
3159
3262
 
@@ -3243,6 +3346,7 @@ export function createRemoteHub(options = {}) {
3243
3346
  const state = {
3244
3347
  authenticated: false,
3245
3348
  device: null,
3349
+ inputOnly: false,
3246
3350
  buffer: Buffer.alloc(0),
3247
3351
  pendingBinaryFrame: null
3248
3352
  };
@@ -3323,11 +3427,13 @@ export function createRemoteHub(options = {}) {
3323
3427
  socket.on('close', () => {
3324
3428
  allSockets.delete(socket);
3325
3429
  clearTimeout(helloTimer);
3430
+ detachInputSocket(socket);
3326
3431
  detachSocket(socket);
3327
3432
  });
3328
3433
  socket.on('error', err => {
3329
3434
  allSockets.delete(socket);
3330
3435
  clearTimeout(helloTimer);
3436
+ detachInputSocket(socket, err?.message || 'socket-error');
3331
3437
  detachSocket(socket, err?.message || 'socket-error');
3332
3438
  });
3333
3439
  }
@@ -3514,6 +3620,24 @@ export function createRemoteHub(options = {}) {
3514
3620
  return { ok: false, error: 'missing-input-type' };
3515
3621
  }
3516
3622
 
3623
+ const inputSocket = device.inputSocket;
3624
+ if (inputSocket && !inputSocket.destroyed) {
3625
+ const sent = writeJsonLine(inputSocket, {
3626
+ type: 'input.control',
3627
+ payload: {
3628
+ ...normalized,
3629
+ issuedAt: normalized.issuedAt || new Date().toISOString()
3630
+ }
3631
+ });
3632
+ if (sent) {
3633
+ device.counters.commandsSent += 1;
3634
+ device.inputLastSeenAt = new Date().toISOString();
3635
+ return { ok: true, inputSocket: true };
3636
+ }
3637
+
3638
+ detachInputSocket(inputSocket, 'input-socket-write-failed');
3639
+ }
3640
+
3517
3641
  return sendCommand(deviceId, {
3518
3642
  command: 'input.control',
3519
3643
  payload: {
@@ -3751,7 +3875,37 @@ export function createRemoteHub(options = {}) {
3751
3875
  return false;
3752
3876
  }
3753
3877
  const startedAt = Date.parse(activeLiveStream.startedAt || '');
3754
- return Number.isFinite(startedAt) && Date.now() - startedAt < 5000;
3878
+ return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_PENDING_REUSE_MS;
3879
+ }
3880
+
3881
+ function getLiveStreamFreshWindowMs(activeLiveStream) {
3882
+ const fps = Number(activeLiveStream?.fps || 0);
3883
+ const frameMs = Number.isFinite(fps) && fps > 0 ? 1000 / fps : 500;
3884
+ return Math.max(
3885
+ LIVE_STREAM_MIN_FRESH_MS,
3886
+ Math.min(LIVE_STREAM_MAX_FRESH_MS, Math.round(frameMs * 20))
3887
+ );
3888
+ }
3889
+
3890
+ function getLiveStreamIdleMs(activeLiveStream) {
3891
+ const lastFrameAt = Date.parse(activeLiveStream?.lastFrameAt || activeLiveStream?.openedAt || activeLiveStream?.startedAt || '');
3892
+ if (!Number.isFinite(lastFrameAt)) {
3893
+ return Infinity;
3894
+ }
3895
+ return Math.max(0, Date.now() - lastFrameAt);
3896
+ }
3897
+
3898
+ function liveStreamIsReusable(activeLiveStream) {
3899
+ if (!activeLiveStream?.active) {
3900
+ return false;
3901
+ }
3902
+ if (liveStreamStartStillPending(activeLiveStream)) {
3903
+ return true;
3904
+ }
3905
+ if (activeLiveStream.open !== true) {
3906
+ return false;
3907
+ }
3908
+ return getLiveStreamIdleMs(activeLiveStream) <= getLiveStreamFreshWindowMs(activeLiveStream);
3755
3909
  }
3756
3910
 
3757
3911
  function liveStreamMatchesOptions(activeLiveStream, normalized) {
@@ -3855,7 +4009,7 @@ export function createRemoteHub(options = {}) {
3855
4009
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3856
4010
  if (device.activeLiveStream?.streamId === streamId
3857
4011
  && liveStreamMatchesOptions(device.activeLiveStream, normalized)
3858
- && (device.activeLiveStream.open === true || liveStreamStartStillPending(device.activeLiveStream))) {
4012
+ && liveStreamIsReusable(device.activeLiveStream)) {
3859
4013
  emitRemoteEvent('RemoteLiveStreamStartReused', device, {
3860
4014
  streamId,
3861
4015
  fps,
@@ -3874,6 +4028,21 @@ export function createRemoteHub(options = {}) {
3874
4028
  reused: true
3875
4029
  };
3876
4030
  }
4031
+ if (device.activeLiveStream?.streamId === streamId
4032
+ && liveStreamMatchesOptions(device.activeLiveStream, normalized)
4033
+ && device.activeLiveStream.open === true
4034
+ && !liveStreamIsReusable(device.activeLiveStream)) {
4035
+ emitRemoteEvent('RemoteLiveStreamStaleRestart', device, {
4036
+ streamId,
4037
+ fps,
4038
+ mode: transfer.mode,
4039
+ frameMode: transfer.frameMode,
4040
+ monitorIndex,
4041
+ idleMs: getLiveStreamIdleMs(device.activeLiveStream),
4042
+ staleAfterMs: getLiveStreamFreshWindowMs(device.activeLiveStream),
4043
+ lastFrameAt: device.activeLiveStream.lastFrameAt || ''
4044
+ });
4045
+ }
3877
4046
  if (device.activeLiveStream?.active && device.activeLiveStream.streamId && device.activeLiveStream.streamId !== streamId) {
3878
4047
  sendCommand(deviceId, {
3879
4048
  command: 'stream.stop',
package/hub/src/server.js CHANGED
@@ -27,6 +27,7 @@ const audioClients = new Set();
27
27
  let frameClientSeq = 0;
28
28
  let inputClientSeq = 0;
29
29
  let audioClientSeq = 0;
30
+ const FRAME_LIVE_WATCHDOG_MS = 2500;
30
31
 
31
32
  function handleRemoteHubEvent(type, event) {
32
33
  if (type !== 'RemoteDeviceConnected') {
@@ -246,6 +247,9 @@ function startFrameSubscriptionLive(ws, reason = 'subscribe', onlyDeviceId = '')
246
247
  skipped.push({ deviceId, reason: result?.error || 'start-failed' });
247
248
  }
248
249
  }
250
+ if (reason === 'watchdog' && !started.some(item => item.reused !== true)) {
251
+ return;
252
+ }
249
253
  sendJson(ws, {
250
254
  type: 'RemoteFrameLiveAutoStart',
251
255
  timestamp: new Date().toISOString(),
@@ -702,6 +706,16 @@ httpServer.on('upgrade', (req, socket, head) => {
702
706
  frameWss.on('connection', (ws, req) => {
703
707
  frameClients.add(ws);
704
708
  ws.liveDeskFrameClientId = `rfws-${++frameClientSeq}`;
709
+ const liveWatchdog = setInterval(() => {
710
+ if (ws.readyState === 1) {
711
+ startFrameSubscriptionLive(ws, 'watchdog');
712
+ }
713
+ }, FRAME_LIVE_WATCHDOG_MS);
714
+ liveWatchdog.unref?.();
715
+ const cleanup = () => {
716
+ clearInterval(liveWatchdog);
717
+ frameClients.delete(ws);
718
+ };
705
719
  try {
706
720
  const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
707
721
  updateFrameSubscription(ws, { deviceIds: parsed.searchParams.get('devices') || '' });
@@ -718,8 +732,8 @@ frameWss.on('connection', (ws, req) => {
718
732
  sendJson(ws, { type: 'RemoteFrameSubscriptionError', error: 'invalid-message' });
719
733
  }
720
734
  });
721
- ws.on('close', () => frameClients.delete(ws));
722
- ws.on('error', () => frameClients.delete(ws));
735
+ ws.on('close', cleanup);
736
+ ws.on('error', cleanup);
723
737
  sendJson(ws, {
724
738
  type: 'RemoteFrameSocketReady',
725
739
  protocol: 'livedesk.remote.frames.binary.v1',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.81",
3
+ "version": "0.1.83",
4
4
  "description": "LiveDesk Hub and client launcher",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "node": ">=20"
31
31
  },
32
32
  "dependencies": {
33
- "@livedesk/client": "0.1.61",
33
+ "@livedesk/client": "0.1.62",
34
34
  "cors": "^2.8.5",
35
35
  "express": "^4.21.2",
36
36
  "ws": "^8.18.3"