@livedesk/hub 0.1.59 → 0.1.61

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.
@@ -10,7 +10,7 @@ export function parseExactLiveStreamMonitorIndex(value) {
10
10
  : null;
11
11
  }
12
12
 
13
- export function isReusedLiveStreamFrameReady(result, activeStream, expectedBinding) {
13
+ export function isReusedLiveStreamFrameReady(result, activeStream, expectedBinding) {
14
14
  const expectedMonitorIndex = parseExactLiveStreamMonitorIndex(expectedBinding?.monitorIndex);
15
15
  const activeMonitorIndex = parseExactLiveStreamMonitorIndex(activeStream?.monitorIndex);
16
16
  const latestFrame = activeStream?.latestFrame;
@@ -34,5 +34,197 @@ export function isReusedLiveStreamFrameReady(result, activeStream, expectedBindi
34
34
  && Number(latestFrame?.captureGeneration || 0) === expectedCaptureGeneration
35
35
  && expectedMonitorIndex !== null
36
36
  && activeMonitorIndex === expectedMonitorIndex
37
- && frameMonitorIndex === expectedMonitorIndex;
38
- }
37
+ && frameMonitorIndex === expectedMonitorIndex;
38
+ }
39
+
40
+ export const READ_ONLY_CONTROL_PRESENTATION_MAX_IDLE_MS = 3_000;
41
+ export const READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS = 250;
42
+
43
+ function normalizedPurpose(value) {
44
+ return String(value || '').trim().toLowerCase();
45
+ }
46
+
47
+ function positiveInteger(value) {
48
+ const parsed = Number(value);
49
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
50
+ }
51
+
52
+ /**
53
+ * Own exactly one short stop-reconcile timer per device. A replacement Control
54
+ * transition cancels that timer before a PWA Wall can claim the released
55
+ * native capture; its Ready event then rebinds observers immediately.
56
+ */
57
+ export function createReadOnlyControlPresentationReconcileCoordinator({
58
+ onReconcile,
59
+ graceMs = READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS,
60
+ setTimeoutFn = setTimeout,
61
+ clearTimeoutFn = clearTimeout
62
+ } = {}) {
63
+ if (typeof onReconcile !== 'function') {
64
+ throw new TypeError('onReconcile must be a function');
65
+ }
66
+ if (typeof setTimeoutFn !== 'function' || typeof clearTimeoutFn !== 'function') {
67
+ throw new TypeError('timer functions must be callable');
68
+ }
69
+ const normalizedGraceMs = Number(graceMs);
70
+ if (!Number.isFinite(normalizedGraceMs)
71
+ || normalizedGraceMs < 1
72
+ || normalizedGraceMs > 5_000) {
73
+ throw new RangeError('graceMs must be between 1 and 5000 milliseconds');
74
+ }
75
+
76
+ const pendingByDeviceId = new Map();
77
+ let closed = false;
78
+
79
+ function normalizeDeviceId(deviceId) {
80
+ return String(deviceId || '').trim();
81
+ }
82
+
83
+ function cancelForControlTransition(deviceId) {
84
+ const normalizedDeviceId = normalizeDeviceId(deviceId);
85
+ const owner = normalizedDeviceId ? pendingByDeviceId.get(normalizedDeviceId) : null;
86
+ if (!owner) return false;
87
+ pendingByDeviceId.delete(normalizedDeviceId);
88
+ clearTimeoutFn(owner.timer);
89
+ return true;
90
+ }
91
+
92
+ function scheduleAfterConfirmedStop(deviceId) {
93
+ const normalizedDeviceId = normalizeDeviceId(deviceId);
94
+ if (closed || !normalizedDeviceId || pendingByDeviceId.has(normalizedDeviceId)) {
95
+ return false;
96
+ }
97
+ const owner = { timer: null };
98
+ pendingByDeviceId.set(normalizedDeviceId, owner);
99
+ try {
100
+ owner.timer = setTimeoutFn(() => {
101
+ if (closed || pendingByDeviceId.get(normalizedDeviceId) !== owner) {
102
+ return;
103
+ }
104
+ pendingByDeviceId.delete(normalizedDeviceId);
105
+ onReconcile(normalizedDeviceId);
106
+ }, normalizedGraceMs);
107
+ owner.timer?.unref?.();
108
+ } catch (error) {
109
+ if (pendingByDeviceId.get(normalizedDeviceId) === owner) {
110
+ pendingByDeviceId.delete(normalizedDeviceId);
111
+ }
112
+ throw error;
113
+ }
114
+ return true;
115
+ }
116
+
117
+ function reconcileReadyControl(deviceId) {
118
+ const normalizedDeviceId = normalizeDeviceId(deviceId);
119
+ if (closed || !normalizedDeviceId) return false;
120
+ cancelForControlTransition(normalizedDeviceId);
121
+ onReconcile(normalizedDeviceId);
122
+ return true;
123
+ }
124
+
125
+ function close() {
126
+ if (closed) return;
127
+ closed = true;
128
+ for (const owner of pendingByDeviceId.values()) {
129
+ clearTimeoutFn(owner.timer);
130
+ }
131
+ pendingByDeviceId.clear();
132
+ }
133
+
134
+ return {
135
+ scheduleAfterConfirmedStop,
136
+ cancelForControlTransition,
137
+ reconcileReadyControl,
138
+ close,
139
+ getPendingCount: () => pendingByDeviceId.size
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Bind a Wall presentation observer to an already healthy Control encoder.
145
+ * This returns metadata only: it never starts, stops, or replaces native work.
146
+ */
147
+ export function resolveReadOnlyControlPresentationBorrow({
148
+ device,
149
+ liveOptions,
150
+ monitorIndex,
151
+ nowEpochMs = Date.now()
152
+ } = {}) {
153
+ if (liveOptions?.allowReadOnlyControlBorrow !== true
154
+ || normalizedPurpose(liveOptions?.streamPurpose) !== 'wall') {
155
+ return null;
156
+ }
157
+
158
+ const active = device?.activeLiveStream;
159
+ const latest = active?.latestFrame;
160
+ const sessionId = String(device?.sessionId || '').trim();
161
+ const streamId = String(active?.streamId || '').trim();
162
+ const commandId = String(active?.commandId || '').trim();
163
+ const captureGeneration = positiveInteger(active?.captureGeneration);
164
+ const activeMonitorIndex = parseExactLiveStreamMonitorIndex(active?.monitorIndex);
165
+ const requestedMonitorIndex = parseExactLiveStreamMonitorIndex(monitorIndex);
166
+ const frameMode = String(active?.frameMode || active?.mode || '').trim().toLowerCase();
167
+ const requestedFrameMode = String(liveOptions?.frameMode || liveOptions?.mode || '').trim().toLowerCase();
168
+ const lastFrameAtEpochMs = Date.parse(String(active?.lastFrameAt || ''));
169
+ const currentEpochMs = Number(nowEpochMs);
170
+ const frameIdleMs = Number.isFinite(lastFrameAtEpochMs) && Number.isFinite(currentEpochMs)
171
+ ? Math.max(0, currentEpochMs - lastFrameAtEpochMs)
172
+ : Number.POSITIVE_INFINITY;
173
+ const latestMonitorIndex = parseExactLiveStreamMonitorIndex(latest?.monitorIndex);
174
+
175
+ if (active?.active !== true
176
+ || active?.open !== true
177
+ || active?.stopPending === true
178
+ || !!String(active?.stopCommandId || '').trim()
179
+ || !!String(active?.pendingDescriptor?.commandId || '').trim()
180
+ || normalizedPurpose(active?.streamPurpose) !== 'control'
181
+ || frameMode !== 'mode3-h264-hw'
182
+ || requestedFrameMode !== 'mode3-h264-hw'
183
+ || !sessionId
184
+ || !streamId
185
+ || !commandId
186
+ || captureGeneration <= 0
187
+ || requestedMonitorIndex === null
188
+ || activeMonitorIndex !== requestedMonitorIndex
189
+ || active?.readyFrameReceived !== true
190
+ || Number(active?.framesReceived || 0) <= 0
191
+ || frameIdleMs > READ_ONLY_CONTROL_PRESENTATION_MAX_IDLE_MS
192
+ || latest?.currentGenerationVerified !== true
193
+ || String(latest?.sessionId || '') !== sessionId
194
+ || String(latest?.streamId || '') !== streamId
195
+ || String(latest?.commandId || '') !== commandId
196
+ || Number(latest?.captureGeneration || 0) !== captureGeneration
197
+ || latestMonitorIndex !== activeMonitorIndex
198
+ || normalizedPurpose(latest?.streamPurpose) !== 'control') {
199
+ return null;
200
+ }
201
+
202
+ return {
203
+ ok: true,
204
+ commandId,
205
+ sessionId,
206
+ streamId,
207
+ streamPurpose: 'control',
208
+ fps: positiveInteger(active?.fps) || 1,
209
+ mode: String(active?.mode || active?.frameMode || 'mode3-h264-hw'),
210
+ frameMode: 'mode3-h264-hw',
211
+ monitorIndex: activeMonitorIndex,
212
+ captureGeneration,
213
+ ready: true,
214
+ reused: true,
215
+ readOnlyControlBorrow: true,
216
+ presentationPurpose: 'wall',
217
+ requestedProfile: {
218
+ fps: Number(liveOptions?.fps || 0),
219
+ maxWidth: Number(liveOptions?.maxWidth || 0),
220
+ maxHeight: Number(liveOptions?.maxHeight || 0),
221
+ quality: Number(liveOptions?.quality || 0)
222
+ },
223
+ effectiveProfile: {
224
+ fps: Number(active?.fps || 0),
225
+ maxWidth: Number(active?.maxWidth || 0),
226
+ maxHeight: Number(active?.maxHeight || 0),
227
+ quality: Number(active?.quality || 0)
228
+ }
229
+ };
230
+ }
package/src/remote-hub.js CHANGED
@@ -9,7 +9,10 @@ import {
9
9
  isSecureDirectHandshakeStart
10
10
  } from '../../runtime-core/src/direct-secure-transport.js';
11
11
  import { createHubRelayControl } from './transport/relay-hub-control.js';
12
- import { parseExactLiveStreamMonitorIndex } from './live-stream-monitor-contract.js';
12
+ import {
13
+ parseExactLiveStreamMonitorIndex,
14
+ resolveReadOnlyControlPresentationBorrow
15
+ } from './live-stream-monitor-contract.js';
13
16
  import {
14
17
  BoundedSegmentedBuffer,
15
18
  createBoundedAgentBinaryIngressLane
@@ -63,9 +66,9 @@ const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 2 * 1024 * 1024;
63
66
  const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 8 * 1024 * 1024;
64
67
  const LIVE_STREAM_PENDING_REUSE_MS = 5000;
65
68
  const LIVE_STREAM_REPLACEMENT_PENDING_MS = 7000;
66
- const LIVE_STREAM_MIN_FRESH_MS = 3000;
67
- const LIVE_STREAM_MAX_FRESH_MS = 12000;
68
- const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
69
+ const LIVE_STREAM_MIN_FRESH_MS = 3000;
70
+ const LIVE_STREAM_MAX_FRESH_MS = 12000;
71
+ const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
69
72
  // RemoteFast owns a shared 7s native stop deadline. Keep four seconds for a
70
73
  // priority command.result to cross either Direct or encrypted Relay transport.
71
74
  const DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS = 11000;
@@ -2303,7 +2306,7 @@ export class RemoteHubWebSocketAgentSocket {
2303
2306
  }
2304
2307
  }
2305
2308
 
2306
- export function createRemoteHub(options = {}) {
2309
+ export function createRemoteHub(options = {}) {
2307
2310
  const env = options.env || process.env;
2308
2311
  const transportDiagnosticCommandTimeoutMs = clampNumber(
2309
2312
  options.transportDiagnosticCommandTimeoutMs,
@@ -10638,7 +10641,7 @@ export function createRemoteHub(options = {}) {
10638
10641
  === normalized.streamPurpose;
10639
10642
  }
10640
10643
 
10641
- function sharedLiveProfileResult(device, activeLiveStream, normalized, extra = {}) {
10644
+ function sharedLiveProfileResult(device, activeLiveStream, normalized, extra = {}) {
10642
10645
  return {
10643
10646
  ok: true,
10644
10647
  commandId: activeLiveStream.commandId,
@@ -10667,9 +10670,40 @@ export function createRemoteHub(options = {}) {
10667
10670
  },
10668
10671
  ...extra
10669
10672
  };
10670
- }
10671
-
10672
- function startLiveStream(deviceId, options = {}) {
10673
+ }
10674
+
10675
+ function readOnlyControlPresentationBorrowResult(device, normalized, options = {}) {
10676
+ const borrowed = resolveReadOnlyControlPresentationBorrow({
10677
+ device,
10678
+ liveOptions: {
10679
+ allowReadOnlyControlBorrow: options.allowReadOnlyControlBorrow === true,
10680
+ streamPurpose: normalized.streamPurpose,
10681
+ mode: normalized.transfer.mode,
10682
+ frameMode: normalized.transfer.frameMode,
10683
+ fps: normalized.fps,
10684
+ maxWidth: normalized.maxWidth,
10685
+ maxHeight: normalized.maxHeight,
10686
+ quality: normalized.quality
10687
+ },
10688
+ monitorIndex: normalized.monitorIndex
10689
+ });
10690
+ if (!borrowed) {
10691
+ return null;
10692
+ }
10693
+ emitRemoteEvent('RemoteLiveStreamControlPresentationBorrowed', device, {
10694
+ streamId: borrowed.streamId,
10695
+ commandId: borrowed.commandId,
10696
+ captureGeneration: borrowed.captureGeneration,
10697
+ streamPurpose: borrowed.streamPurpose,
10698
+ presentationPurpose: borrowed.presentationPurpose,
10699
+ monitorIndex: borrowed.monitorIndex,
10700
+ requestedProfile: borrowed.requestedProfile,
10701
+ effectiveProfile: borrowed.effectiveProfile
10702
+ });
10703
+ return borrowed;
10704
+ }
10705
+
10706
+ function startLiveStream(deviceId, options = {}) {
10673
10707
  const device = devices.get(String(deviceId || ''));
10674
10708
  const denied = policyError(device);
10675
10709
  if (denied) return { ok: false, error: denied };
@@ -10693,11 +10727,13 @@ export function createRemoteHub(options = {}) {
10693
10727
  }
10694
10728
 
10695
10729
  const now = new Date().toISOString();
10696
- const normalized = normalizeLiveStreamStartOptions({ fps: 2, ...options, platform: device.platform });
10697
- const modePolicyError = liveStreamModePolicyError(normalized);
10698
- if (modePolicyError) return { ok: false, error: modePolicyError };
10699
- const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
10700
- const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
10730
+ const normalized = normalizeLiveStreamStartOptions({ fps: 2, ...options, platform: device.platform });
10731
+ const modePolicyError = liveStreamModePolicyError(normalized);
10732
+ if (modePolicyError) return { ok: false, error: modePolicyError };
10733
+ const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
10734
+ const borrowedControl = readOnlyControlPresentationBorrowResult(device, normalized, options);
10735
+ if (borrowedControl) return { ...borrowedControl, synthetic: true };
10736
+ const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
10701
10737
  if (!reserveDeviceLiveStreamDescriptor(device, streamId)) {
10702
10738
  return {
10703
10739
  ok: false,
@@ -10776,12 +10812,13 @@ export function createRemoteHub(options = {}) {
10776
10812
  monitorIndex,
10777
10813
  fps
10778
10814
  });
10779
- emitRemoteEvent('RemoteLiveStreamStarted', device, {
10780
- streamId,
10781
- commandId,
10782
- fps,
10783
- mode: transfer.mode,
10784
- synthetic: true
10815
+ emitRemoteEvent('RemoteLiveStreamStarted', device, {
10816
+ streamId,
10817
+ commandId,
10818
+ streamPurpose,
10819
+ fps,
10820
+ mode: transfer.mode,
10821
+ synthetic: true
10785
10822
  });
10786
10823
  return {
10787
10824
  ok: true,
@@ -10811,14 +10848,16 @@ export function createRemoteHub(options = {}) {
10811
10848
  const modePolicyError = liveStreamModePolicyError(normalized);
10812
10849
  if (modePolicyError) return { ok: false, error: modePolicyError };
10813
10850
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
10814
- if (deviceExplicitlyRejectsFrameMode(device, transfer.frameMode)) {
10815
- return {
10816
- ok: false,
10817
- error: 'device-frame-mode-unavailable',
10818
- frameMode: transfer.frameMode
10819
- };
10820
- }
10821
- const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
10851
+ if (deviceExplicitlyRejectsFrameMode(device, transfer.frameMode)) {
10852
+ return {
10853
+ ok: false,
10854
+ error: 'device-frame-mode-unavailable',
10855
+ frameMode: transfer.frameMode
10856
+ };
10857
+ }
10858
+ const borrowedControl = readOnlyControlPresentationBorrowResult(device, normalized, options);
10859
+ if (borrowedControl) return borrowedControl;
10860
+ const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
10822
10861
  if (!reserveDeviceLiveStreamDescriptor(device, streamId)) {
10823
10862
  return {
10824
10863
  ok: false,
@@ -10875,9 +10914,9 @@ export function createRemoteHub(options = {}) {
10875
10914
  }
10876
10915
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
10877
10916
  const restartToken = safeString(options.restartToken, 128);
10878
- if (activeLiveStream
10879
- && options.forceRestart === true
10880
- && restartToken
10917
+ if (activeLiveStream
10918
+ && options.forceRestart === true
10919
+ && restartToken
10881
10920
  && safeString(activeLiveStream.restartToken, 128) === restartToken
10882
10921
  && liveStreamMatchesOptions(activeLiveStream, normalized)
10883
10922
  && liveStreamIsReusable(activeLiveStream)) {
@@ -10900,11 +10939,11 @@ export function createRemoteHub(options = {}) {
10900
10939
  captureGeneration: Number(activeLiveStream.captureGeneration || 0),
10901
10940
  ready: liveStreamHasCurrentFrame(activeLiveStream),
10902
10941
  pending: activeLiveStream.open !== true,
10903
- reused: true
10904
- };
10905
- }
10906
- if (activeLiveStream
10907
- && options.forceRestart !== true
10942
+ reused: true
10943
+ };
10944
+ }
10945
+ if (activeLiveStream
10946
+ && options.forceRestart !== true
10908
10947
  && options.reuseExisting === true
10909
10948
  && liveStreamMatchesOptions(activeLiveStream, normalized)
10910
10949
  && liveStreamIsReusable(activeLiveStream)) {
@@ -11084,11 +11123,12 @@ export function createRemoteHub(options = {}) {
11084
11123
  setDeviceLiveStream(device, nextStreamState);
11085
11124
  }
11086
11125
  device.counters.liveStreamsStarted += 1;
11087
- emitRemoteEvent('RemoteLiveStreamStarted', device, {
11088
- streamId,
11089
- commandId,
11090
- previousCommandId,
11091
- pending: replacingActiveStream,
11126
+ emitRemoteEvent('RemoteLiveStreamStarted', device, {
11127
+ streamId,
11128
+ commandId,
11129
+ streamPurpose,
11130
+ previousCommandId,
11131
+ pending: replacingActiveStream,
11092
11132
  fps,
11093
11133
  mode: transfer.mode,
11094
11134
  frameMode: transfer.frameMode,
@@ -11277,11 +11317,13 @@ export function createRemoteHub(options = {}) {
11277
11317
  captureLiveStreamStopOwner(device, streamState, commandId));
11278
11318
  }
11279
11319
  device.counters.liveStreamsStopped += 1;
11280
- emitRemoteEvent('RemoteLiveStreamStopped', device, {
11281
- streamId,
11282
- commandId,
11283
- synthetic: true
11284
- });
11320
+ emitRemoteEvent('RemoteLiveStreamStopped', device, {
11321
+ streamId,
11322
+ commandId,
11323
+ streamPurpose: safeString(streamState?.streamPurpose || streamPurpose, 24).toLowerCase() || 'wall',
11324
+ captureStopConfirmed: true,
11325
+ synthetic: true
11326
+ });
11285
11327
  return { ok: true, commandId, streamId, synthetic: true };
11286
11328
  }
11287
11329
 
@@ -11316,7 +11358,7 @@ export function createRemoteHub(options = {}) {
11316
11358
  : (streamState ? [streamState] : []);
11317
11359
  const stopRequestedAt = new Date().toISOString();
11318
11360
  const targetOwners = [];
11319
- for (const targetStream of targetStreams) {
11361
+ for (const targetStream of targetStreams) {
11320
11362
  const pendingTarget = getPendingLiveStreamDescriptor(targetStream);
11321
11363
  const targetDescriptor = pendingTarget || targetStream;
11322
11364
  const targetOwnerKind = pendingTarget ? 'pending' : 'active';
@@ -11340,10 +11382,19 @@ export function createRemoteHub(options = {}) {
11340
11382
  targetOwners.push({
11341
11383
  owner: targetOwner,
11342
11384
  ownerKind: targetOwnerKind
11343
- });
11344
- }
11345
- }
11346
- const stopPromise = acknowledgementPromise.then(acknowledgement => {
11385
+ });
11386
+ }
11387
+ }
11388
+ const stoppedStreamPurposes = new Set(
11389
+ targetOwners
11390
+ .map(targetRecord => safeString(targetRecord.owner?.streamPurpose, 24).toLowerCase())
11391
+ .filter(Boolean));
11392
+ const stoppedStreamPurpose = stoppedStreamPurposes.size === 1
11393
+ ? [...stoppedStreamPurposes][0]
11394
+ : targetOwners.length === 0
11395
+ ? safeString(streamState?.streamPurpose || streamPurpose, 24).toLowerCase()
11396
+ : '';
11397
+ const stopPromise = acknowledgementPromise.then(acknowledgement => {
11347
11398
  const currentDevice = devices.get(String(deviceId || ''));
11348
11399
  const stopReported = acknowledgement.result?.stream === true
11349
11400
  && acknowledgement.result?.stopped === true;
@@ -11411,11 +11462,12 @@ export function createRemoteHub(options = {}) {
11411
11462
  if (captureStopConfirmed
11412
11463
  && (targetOwners.length === 0 || appliedOwnerCount > 0)) {
11413
11464
  currentDevice.counters.liveStreamsStopped += 1;
11414
- emitRemoteEvent('RemoteLiveStreamStopped', currentDevice, {
11415
- streamId,
11416
- commandId,
11417
- appliedOwnerCount,
11418
- captureStopConfirmed: true
11465
+ emitRemoteEvent('RemoteLiveStreamStopped', currentDevice, {
11466
+ streamId,
11467
+ commandId,
11468
+ streamPurpose: stoppedStreamPurpose,
11469
+ appliedOwnerCount,
11470
+ captureStopConfirmed: true
11419
11471
  });
11420
11472
  } else if (!captureStopConfirmed && appliedOwnerCount > 0) {
11421
11473
  emitRemoteEvent('RemoteLiveStreamStopUnconfirmed', currentDevice, {