@livedesk/hub 0.1.12 → 0.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
package/src/remote-hub.js CHANGED
@@ -1,6 +1,7 @@
1
- import net from 'net';
2
- import os from 'os';
3
- import crypto from 'crypto';
1
+ import net from 'net';
2
+ import os from 'os';
3
+ import crypto from 'crypto';
4
+ import { createHubRelayControl } from './transport/relay-hub-control.js';
4
5
 
5
6
  const DEFAULT_REMOTE_HUB_PORT = 5197;
6
7
  const DEFAULT_REMOTE_HUB_HOST = '0.0.0.0';
@@ -1026,10 +1027,12 @@ function serializeDevice(device, options = {}) {
1026
1027
  disconnectedAt: device.disconnectedAt,
1027
1028
  lastSeenAt: device.lastSeenAt,
1028
1029
  lastStatusAt: device.lastStatusAt,
1029
- lastDisconnectReason: device.lastDisconnectReason,
1030
- remoteAddress: device.remoteAddress,
1031
- remotePort: device.remotePort,
1032
- channels: {
1030
+ lastDisconnectReason: device.lastDisconnectReason,
1031
+ remoteAddress: device.remoteAddress,
1032
+ remotePort: device.remotePort,
1033
+ controlTransport: device.controlTransport || 'direct-tcp',
1034
+ relayEndpoint: device.relayEndpoint || '',
1035
+ channels: {
1033
1036
  control: !!device.socket && !device.socket.destroyed,
1034
1037
  input: !!device.inputSocket && !device.inputSocket.destroyed,
1035
1038
  frame: !!device.frameSocket && !device.frameSocket.destroyed,
@@ -1560,9 +1563,18 @@ export function createRemoteHub(options = {}) {
1560
1563
  const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
1561
1564
  const publicEndpoint = safeString(env.LIVEDESK_REMOTE_PUBLIC_ENDPOINT || env.MINDEXEC_REMOTE_PUBLIC_ENDPOINT || env.REMOTE_HUB_PUBLIC_ENDPOINT, 256);
1562
1565
  const publicHost = safeString(env.LIVEDESK_REMOTE_PUBLIC_HOST || env.MINDEXEC_REMOTE_PUBLIC_HOST || env.REMOTE_HUB_PUBLIC_HOST, 128);
1563
- const pairToken = safeString(
1564
- options.pairToken || env.REMOTE_HUB_PAIR_TOKEN || env.LIVEDESK_CLIENT_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN || crypto.randomBytes(6).toString('hex'),
1565
- 256);
1566
+ const pairToken = safeString(
1567
+ options.pairToken || env.REMOTE_HUB_PAIR_TOKEN || env.LIVEDESK_CLIENT_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN || crypto.randomBytes(6).toString('hex'),
1568
+ 256);
1569
+ const relayControl = options.relayControl && typeof options.relayControl === 'object'
1570
+ ? options.relayControl
1571
+ : createHubRelayControl({
1572
+ env,
1573
+ pairToken,
1574
+ logEvent,
1575
+ logWarn,
1576
+ onPeerSocket: socket => handleTcpAgentSocket(socket)
1577
+ });
1566
1578
  let pairingPin = /^\d{6}$/.test(String(options.pairingPin || env.LIVEDESK_PAIRING_PIN || '').trim())
1567
1579
  ? String(options.pairingPin || env.LIVEDESK_PAIRING_PIN).trim()
1568
1580
  : generatePairingPin();
@@ -1933,14 +1945,23 @@ export function createRemoteHub(options = {}) {
1933
1945
  publicIPv4Address: publicIPv4Cache.address,
1934
1946
  publicIPv4UpdatedAt: publicIPv4Cache.updatedAt ? new Date(publicIPv4Cache.updatedAt).toISOString() : '',
1935
1947
  publicIPv4Error: publicIPv4Cache.error,
1936
- udp: udpTransport?.getStatus?.() || {
1937
- enabled: false,
1938
- preferred: false,
1939
- started: false,
1940
- port: 0,
1941
- sessionCount: 0,
1942
- readySessionCount: 0
1943
- },
1948
+ udp: udpTransport?.getStatus?.() || {
1949
+ enabled: false,
1950
+ preferred: false,
1951
+ started: false,
1952
+ port: 0,
1953
+ sessionCount: 0,
1954
+ readySessionCount: 0
1955
+ },
1956
+ relay: relayControl?.getStatus?.() || {
1957
+ enabled: false,
1958
+ started: false,
1959
+ connected: false,
1960
+ state: 'unavailable',
1961
+ protocol: 'livedesk.relay.v1',
1962
+ endpoint: '',
1963
+ peerCount: 0
1964
+ },
1944
1965
  externalExposure: isWildcardHost(host) || !isLoopbackHost(host),
1945
1966
  diagnostics,
1946
1967
  lastError
@@ -2151,6 +2172,23 @@ export function createRemoteHub(options = {}) {
2151
2172
  }
2152
2173
  }
2153
2174
 
2175
+ function failPendingCommandResultWaiter(commandId, error = 'command-not-sent') {
2176
+ const key = safeString(commandId, 128);
2177
+ const waiter = pendingCommandResultWaiters.get(key);
2178
+ if (!waiter) {
2179
+ return false;
2180
+ }
2181
+ pendingCommandResultWaiters.delete(key);
2182
+ clearTimeout(waiter.timer);
2183
+ waiter.resolve({
2184
+ ok: false,
2185
+ commandId: key,
2186
+ result: null,
2187
+ error: safeString(error, 500) || 'command-not-sent'
2188
+ });
2189
+ return true;
2190
+ }
2191
+
2154
2192
  function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs) {
2155
2193
  const key = safeString(commandId, 128);
2156
2194
  if (!device?.deviceId || !device?.sessionId || !key) {
@@ -2845,9 +2883,13 @@ export function createRemoteHub(options = {}) {
2845
2883
  const frameModes = Array.isArray(hello.frameModes)
2846
2884
  ? hello.frameModes.map(normalizeIncomingFrameModeProfile).filter(Boolean)
2847
2885
  : [];
2848
- const capabilities = typeof hello.capabilities === 'object' && hello.capabilities
2849
- ? { ...hello.capabilities }
2850
- : {};
2886
+ const capabilities = typeof hello.capabilities === 'object' && hello.capabilities
2887
+ ? { ...hello.capabilities }
2888
+ : {};
2889
+ const relayConnected = socket.__liveDeskRelayControl === true;
2890
+ if (relayConnected) {
2891
+ capabilities.dedicatedInputChannel = false;
2892
+ }
2851
2893
  if (!capabilities.frameProtocol) {
2852
2894
  capabilities.frameProtocol = frameProtocol || buildRemoteFrameProtocolDescriptor();
2853
2895
  }
@@ -2893,10 +2935,14 @@ export function createRemoteHub(options = {}) {
2893
2935
  disconnectedAt: '',
2894
2936
  lastSeenAt: now,
2895
2937
  lastStatusAt: '',
2896
- lastDisconnectReason: '',
2897
- remoteAddress: socket.remoteAddress || '',
2898
- remotePort: socket.remotePort || 0,
2899
- status: {},
2938
+ lastDisconnectReason: '',
2939
+ remoteAddress: socket.remoteAddress || '',
2940
+ remotePort: socket.remotePort || 0,
2941
+ controlTransport: relayConnected ? 'encrypted-rendezvous-relay' : 'direct-tcp',
2942
+ relayEndpoint: relayConnected
2943
+ ? safeString(socket.remoteAddress, 256)
2944
+ : '',
2945
+ status: {},
2900
2946
  latestThumbnail: null,
2901
2947
  latestLiveFrame: null,
2902
2948
  recentFramePayloads: {
@@ -4654,12 +4700,23 @@ export function createRemoteHub(options = {}) {
4654
4700
  scheduleAgentBinaryIngress(socket, state);
4655
4701
  }
4656
4702
 
4657
- function handleAgentMessage(socket, state, message) {
4658
- if (!message || typeof message !== 'object') {
4659
- return;
4660
- }
4661
-
4703
+ function handleAgentMessage(socket, state, message) {
4704
+ if (!message || typeof message !== 'object') {
4705
+ return;
4706
+ }
4707
+
4662
4708
  if (!state.authenticated) {
4709
+ if (message.type === 'hello' && socket.__liveDeskRelayControl === true) {
4710
+ message = {
4711
+ ...message,
4712
+ capabilities: {
4713
+ ...(message.capabilities && typeof message.capabilities === 'object'
4714
+ ? message.capabilities
4715
+ : {}),
4716
+ dedicatedInputChannel: false
4717
+ }
4718
+ };
4719
+ }
4663
4720
  if (message.type === 'slot.assign') {
4664
4721
  if (!timingSafeStringEqual(message.pairToken, pairToken)) {
4665
4722
  writeJsonLine(socket, { type: 'slot.assignment', ok: false, error: 'invalid-pair-token' });
@@ -5230,10 +5287,15 @@ export function createRemoteHub(options = {}) {
5230
5287
  return getStatus({ includeSecrets: false });
5231
5288
  }
5232
5289
 
5233
- try {
5234
- await udpTransport?.start?.();
5235
- await listenOnPort(requestedPort);
5236
- } catch (error) {
5290
+ try {
5291
+ await udpTransport?.start?.();
5292
+ await listenOnPort(requestedPort);
5293
+ try {
5294
+ await relayControl?.start?.();
5295
+ } catch {
5296
+ logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
5297
+ }
5298
+ } catch (error) {
5237
5299
  if (error?.code === 'EADDRINUSE') {
5238
5300
  const conflict = new Error(
5239
5301
  `LiveDesk Hub requires client endpoint TCP ${requestedPort}, but that port is already in use. Close the owning process or start Hub with an explicit --remote-port value.`);
@@ -5266,8 +5328,9 @@ export function createRemoteHub(options = {}) {
5266
5328
  }
5267
5329
  }
5268
5330
 
5269
- sockets.clear();
5270
- await udpTransport?.close?.();
5331
+ sockets.clear();
5332
+ await relayControl?.close?.();
5333
+ await udpTransport?.close?.();
5271
5334
  if (!server) {
5272
5335
  started = false;
5273
5336
  return;
@@ -6790,35 +6853,100 @@ export function createRemoteHub(options = {}) {
6790
6853
 
6791
6854
  if (!device?.socket || device.socket.destroyed || !device.connected) {
6792
6855
  return { ok: false, error: 'device-not-connected' };
6793
- }
6794
-
6795
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
6796
- const result = sendCommand(deviceId, {
6797
- command: 'stream.stop',
6798
- commandId,
6856
+ }
6857
+
6858
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
6859
+ // Register before writing the command. A local/relay Agent can answer
6860
+ // quickly enough that registering after sendCommand loses the only
6861
+ // proof that native capture actually stopped.
6862
+ const acknowledgementPromise = waitForCommandResult(
6863
+ device,
6864
+ commandId,
6865
+ liveCaptureStopAckTimeoutMs);
6866
+ const result = sendCommand(deviceId, {
6867
+ command: 'stream.stop',
6868
+ commandId,
6799
6869
  payload: {
6800
6870
  streamId,
6801
6871
  requestedAt: new Date().toISOString()
6802
6872
  }
6803
6873
  });
6804
-
6805
- if (!result.ok) {
6806
- return result;
6807
- }
6808
6874
 
6809
- if (streamState) {
6810
- streamState.active = false;
6811
- streamState.open = false;
6812
- streamState.stoppedAt = new Date().toISOString();
6813
- streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
6875
+ if (!result.ok) {
6876
+ failPendingCommandResultWaiter(commandId, result.error || 'stream-stop-not-sent');
6877
+ return result;
6814
6878
  }
6815
- device.counters.liveStreamsStopped += 1;
6816
- emitRemoteEvent('RemoteLiveStreamStopped', device, {
6817
- streamId,
6818
- commandId
6819
- });
6820
-
6821
- return { ok: true, commandId, streamId };
6879
+
6880
+ const targetStreams = options.stopAll === true
6881
+ ? [...ensureDeviceLiveStreams(device).values()]
6882
+ : (streamState ? [streamState] : []);
6883
+ for (const targetStream of targetStreams) {
6884
+ targetStream.stopPending = true;
6885
+ targetStream.stopCommandId = commandId;
6886
+ targetStream.stopRequestedAt = new Date().toISOString();
6887
+ targetStream.stopReason = safeString(options.reason, 128) || 'manager-request';
6888
+ targetStream.stopError = '';
6889
+ targetStream.stopUnconfirmed = false;
6890
+ }
6891
+ const sessionId = device.sessionId;
6892
+ const stopPromise = acknowledgementPromise.then(acknowledgement => {
6893
+ const currentDevice = devices.get(String(deviceId || ''));
6894
+ const sessionCurrent = currentDevice?.sessionId === sessionId;
6895
+ const captureStopConfirmed = acknowledgement.ok === true
6896
+ && acknowledgement.result?.stream === true
6897
+ && acknowledgement.result?.stopped === true;
6898
+ const stoppedAt = new Date().toISOString();
6899
+ if (sessionCurrent) {
6900
+ const currentTargets = options.stopAll === true
6901
+ ? [...ensureDeviceLiveStreams(currentDevice).values()]
6902
+ : (() => {
6903
+ const currentStream = getDeviceLiveStream(currentDevice, streamId);
6904
+ return currentStream ? [currentStream] : [];
6905
+ })();
6906
+ for (const targetStream of currentTargets) {
6907
+ if (targetStream.stopCommandId !== commandId) {
6908
+ continue;
6909
+ }
6910
+ targetStream.stopPending = false;
6911
+ targetStream.stopUnconfirmed = !captureStopConfirmed;
6912
+ targetStream.stopError = captureStopConfirmed
6913
+ ? ''
6914
+ : safeString(acknowledgement.error || 'capture-stop-unconfirmed', 500);
6915
+ if (captureStopConfirmed) {
6916
+ targetStream.active = false;
6917
+ targetStream.open = false;
6918
+ targetStream.stoppedAt = stoppedAt;
6919
+ targetStream.stopConfirmedAt = stoppedAt;
6920
+ }
6921
+ }
6922
+ if (captureStopConfirmed) {
6923
+ currentDevice.counters.liveStreamsStopped += 1;
6924
+ emitRemoteEvent('RemoteLiveStreamStopped', currentDevice, {
6925
+ streamId,
6926
+ commandId,
6927
+ captureStopConfirmed: true
6928
+ });
6929
+ } else {
6930
+ emitRemoteEvent('RemoteLiveStreamStopUnconfirmed', currentDevice, {
6931
+ streamId,
6932
+ commandId,
6933
+ error: acknowledgement.error || 'capture-stop-unconfirmed'
6934
+ });
6935
+ }
6936
+ }
6937
+ return {
6938
+ ...acknowledgement,
6939
+ streamId,
6940
+ captureStopConfirmed
6941
+ };
6942
+ });
6943
+
6944
+ const stopResult = { ok: true, commandId, streamId, stopPending: true };
6945
+ Object.defineProperty(stopResult, 'stopPromise', {
6946
+ value: stopPromise,
6947
+ enumerable: false
6948
+ });
6949
+ return stopResult;
6822
6950
  }
6823
6951
 
6824
6952
  function buildLiveCapturePauseResult(pause, extra = {}) {
@@ -6884,10 +7012,14 @@ export function createRemoteHub(options = {}) {
6884
7012
  if (pause.stopCommandId
6885
7013
  && stopResult.alreadyStopped !== true
6886
7014
  && stopResult.synthetic !== true) {
6887
- const acknowledgement = await waitForCommandResult(
6888
- device,
6889
- pause.stopCommandId,
6890
- liveCaptureStopAckTimeoutMs);
7015
+ const acknowledgement = stopResult.stopPromise
7016
+ ? await stopResult.stopPromise
7017
+ : {
7018
+ ok: false,
7019
+ commandId: pause.stopCommandId,
7020
+ result: null,
7021
+ error: 'capture-stop-confirmation-missing'
7022
+ };
6891
7023
  device = devices.get(String(deviceId || '')) || device;
6892
7024
  if (device.liveCapturePause?.token !== pause.token) {
6893
7025
  return buildLiveCapturePauseResult(pause, {
@@ -6999,6 +7131,14 @@ export function createRemoteHub(options = {}) {
6999
7131
  if (!pause?.token) {
7000
7132
  return { ok: true, paused: false, alreadyResumed: true };
7001
7133
  }
7134
+ const pauseToken = safeString(options.pauseToken, 128);
7135
+ if (!pauseToken || pauseToken !== pause.token) {
7136
+ return {
7137
+ ok: false,
7138
+ paused: true,
7139
+ error: 'LIVE_CAPTURE_PAUSE_TOKEN_MISMATCH'
7140
+ };
7141
+ }
7002
7142
  if (pause.phase === 'pausing') {
7003
7143
  return {
7004
7144
  ok: false,
@@ -7007,12 +7147,19 @@ export function createRemoteHub(options = {}) {
7007
7147
  pauseToken: pause.token
7008
7148
  };
7009
7149
  }
7010
- const pauseToken = safeString(options.pauseToken, 128);
7011
- if (!pauseToken || pauseToken !== pause.token) {
7012
- return {
7150
+ if (pause.captureStopConfirmed !== true) {
7151
+ return buildLiveCapturePauseResult(pause, {
7013
7152
  ok: false,
7014
- error: 'LIVE_CAPTURE_PAUSE_TOKEN_MISMATCH'
7015
- };
7153
+ paused: true,
7154
+ error: 'LIVE_CAPTURE_STOP_UNCONFIRMED'
7155
+ });
7156
+ }
7157
+ if (pause.phase !== 'paused') {
7158
+ return buildLiveCapturePauseResult(pause, {
7159
+ ok: false,
7160
+ paused: true,
7161
+ error: 'LIVE_CAPTURE_PAUSE_PHASE_MISMATCH'
7162
+ });
7016
7163
  }
7017
7164
 
7018
7165
  device.liveCapturePause = null;
@@ -7101,32 +7248,81 @@ export function createRemoteHub(options = {}) {
7101
7248
  }
7102
7249
  if (device.synthetic === true || !device.socket || device.socket.destroyed) {
7103
7250
  return { ok: false, error: 'device-audio-unavailable' };
7104
- }
7105
- const streamId = safeString(options.streamId, 128) || device.activeAudioStream?.streamId || '';
7106
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
7107
- const result = sendCommand(deviceId, {
7108
- command: 'audio.stop',
7251
+ }
7252
+ const streamId = safeString(options.streamId, 128) || device.activeAudioStream?.streamId || '';
7253
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
7254
+ const acknowledgementPromise = waitForCommandResult(
7255
+ device,
7256
+ commandId,
7257
+ liveCaptureStopAckTimeoutMs);
7258
+ const result = sendCommand(deviceId, {
7259
+ command: 'audio.stop',
7109
7260
  commandId,
7110
7261
  payload: {
7111
7262
  streamId,
7112
7263
  requestedAt: new Date().toISOString()
7113
7264
  }
7114
- });
7115
- if (!result.ok) {
7116
- return result;
7117
- }
7118
- if (device.activeAudioStream && (!streamId || device.activeAudioStream.streamId === streamId)) {
7119
- device.activeAudioStream.active = false;
7120
- device.activeAudioStream.stoppedAt = new Date().toISOString();
7121
- device.activeAudioStream.stopReason = 'manager-request';
7122
- }
7123
- device.counters.audioStreamsStopped += 1;
7124
- emitRemoteEvent('RemoteAudioStreamStopped', device, {
7125
- streamId,
7126
- commandId
7127
- });
7128
- return { ok: true, commandId, streamId };
7129
- }
7265
+ });
7266
+ if (!result.ok) {
7267
+ failPendingCommandResultWaiter(commandId, result.error || 'audio-stop-not-sent');
7268
+ return result;
7269
+ }
7270
+ if (device.activeAudioStream && (!streamId || device.activeAudioStream.streamId === streamId)) {
7271
+ device.activeAudioStream.stopPending = true;
7272
+ device.activeAudioStream.stopCommandId = commandId;
7273
+ device.activeAudioStream.stopRequestedAt = new Date().toISOString();
7274
+ device.activeAudioStream.stopReason = 'manager-request';
7275
+ device.activeAudioStream.stopError = '';
7276
+ device.activeAudioStream.stopUnconfirmed = false;
7277
+ }
7278
+ const sessionId = device.sessionId;
7279
+ const stopPromise = acknowledgementPromise.then(acknowledgement => {
7280
+ const currentDevice = devices.get(String(deviceId || ''));
7281
+ const audioStopConfirmed = acknowledgement.ok === true
7282
+ && acknowledgement.result?.audio === true
7283
+ && acknowledgement.result?.stopped === true;
7284
+ if (currentDevice?.sessionId === sessionId
7285
+ && currentDevice.activeAudioStream
7286
+ && currentDevice.activeAudioStream.stopCommandId === commandId
7287
+ && (!streamId || currentDevice.activeAudioStream.streamId === streamId)) {
7288
+ const stoppedAt = new Date().toISOString();
7289
+ currentDevice.activeAudioStream.stopPending = false;
7290
+ currentDevice.activeAudioStream.stopUnconfirmed = !audioStopConfirmed;
7291
+ currentDevice.activeAudioStream.stopError = audioStopConfirmed
7292
+ ? ''
7293
+ : safeString(acknowledgement.error || 'audio-stop-unconfirmed', 500);
7294
+ if (audioStopConfirmed) {
7295
+ currentDevice.activeAudioStream.active = false;
7296
+ currentDevice.activeAudioStream.open = false;
7297
+ currentDevice.activeAudioStream.stoppedAt = stoppedAt;
7298
+ currentDevice.activeAudioStream.stopConfirmedAt = stoppedAt;
7299
+ currentDevice.counters.audioStreamsStopped += 1;
7300
+ emitRemoteEvent('RemoteAudioStreamStopped', currentDevice, {
7301
+ streamId,
7302
+ commandId,
7303
+ captureStopConfirmed: true
7304
+ });
7305
+ } else {
7306
+ emitRemoteEvent('RemoteAudioStreamStopUnconfirmed', currentDevice, {
7307
+ streamId,
7308
+ commandId,
7309
+ error: acknowledgement.error || 'audio-stop-unconfirmed'
7310
+ });
7311
+ }
7312
+ }
7313
+ return {
7314
+ ...acknowledgement,
7315
+ streamId,
7316
+ captureStopConfirmed: audioStopConfirmed
7317
+ };
7318
+ });
7319
+ const stopResult = { ok: true, commandId, streamId, stopPending: true };
7320
+ Object.defineProperty(stopResult, 'stopPromise', {
7321
+ value: stopPromise,
7322
+ enumerable: false
7323
+ });
7324
+ return stopResult;
7325
+ }
7130
7326
 
7131
7327
  function getDeviceLiveFrame(deviceId, options = {}) {
7132
7328
  const device = devices.get(String(deviceId || ''));