@livedesk/hub 0.1.47 → 0.1.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js CHANGED
@@ -8,7 +8,12 @@ import { dirname, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import os from 'node:os';
10
10
  import { WebSocketServer } from 'ws';
11
- import { createRemoteHub } from './remote-hub.js';
11
+ import { createRemoteHub } from './remote-hub.js';
12
+ import {
13
+ REMOTE_CLIPBOARD_INPUT_ACTIONS,
14
+ RemoteClipboardContractError,
15
+ normalizeRemoteClipboardRequest
16
+ } from './remote-clipboard-contract.mjs';
12
17
  import { createHubConsoleRelay } from './console-relay.js';
13
18
  import {
14
19
  buildImmutableRemoteFramePacket,
@@ -1754,12 +1759,40 @@ function normalizeLiveOptions(payload = {}) {
1754
1759
  };
1755
1760
  }
1756
1761
 
1757
- function sendJson(ws, payload) {
1762
+ function sendJson(ws, payload) {
1758
1763
  if (!ws || ws.readyState !== 1) {
1759
1764
  return false;
1760
1765
  }
1761
1766
  return safeWebSocketSend(ws, JSON.stringify(payload));
1762
- }
1767
+ }
1768
+
1769
+ function findLiveRemoteInputClient(hubConnectionId) {
1770
+ const requestedId = String(hubConnectionId || '').trim();
1771
+ if (!requestedId) {
1772
+ return null;
1773
+ }
1774
+ for (const ws of inputClients) {
1775
+ if (ws.readyState === 1 && ws.liveDeskInputClientId === requestedId) {
1776
+ return ws;
1777
+ }
1778
+ }
1779
+ return null;
1780
+ }
1781
+
1782
+ function clipboardHttpStatus(error) {
1783
+ const code = String(error || '');
1784
+ if (code.includes('blocked-by-settings') || code === 'remote-access-blocked-by-settings') return 403;
1785
+ if (code.includes('too-large')) return 413;
1786
+ if (code.includes('unavailable') || code === 'device-not-connected') return 503;
1787
+ if (code.startsWith('STALE_')
1788
+ || code.includes('owner')
1789
+ || code.includes('not-ready')
1790
+ || code.includes('not-found')
1791
+ || code.includes('already-exists')
1792
+ || code.includes('busy')
1793
+ || code.includes('capacity')) return 409;
1794
+ return 400;
1795
+ }
1763
1796
 
1764
1797
  function forgetWebSocketClient(ws) {
1765
1798
  unregisterFrameClient(ws);
@@ -1846,6 +1879,26 @@ function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
1846
1879
  return false;
1847
1880
  }
1848
1881
 
1882
+ function hasOtherFrameStreamDemand(ws, deviceId, streamPurpose = '') {
1883
+ const normalizedPurpose = String(streamPurpose || '').trim().toLowerCase();
1884
+ if (!deviceId || !normalizedPurpose) {
1885
+ return false;
1886
+ }
1887
+ for (const candidate of frameClients) {
1888
+ if (candidate === ws || candidate.readyState !== candidate.OPEN) {
1889
+ continue;
1890
+ }
1891
+ if (candidate.liveDeskAutoStart === true
1892
+ && candidate.liveDeskDeviceIds instanceof Set
1893
+ && candidate.liveDeskDeviceIds.has(deviceId)
1894
+ && String(candidate.liveDeskLiveOptions?.streamPurpose || '').trim().toLowerCase()
1895
+ === normalizedPurpose) {
1896
+ return true;
1897
+ }
1898
+ }
1899
+ return false;
1900
+ }
1901
+
1849
1902
  function frameStreamStopKey(deviceId, streamId, streamPurpose) {
1850
1903
  return `${deviceId}\u0000${streamId}\u0000${streamPurpose}`;
1851
1904
  }
@@ -2147,6 +2200,15 @@ function snapshotFrameLaneResourceHealth() {
2147
2200
  .map(binding => String(binding?.streamPurpose || '').trim().toLowerCase())
2148
2201
  .filter(Boolean))]
2149
2202
  .slice(0, 4),
2203
+ requestedProfile: ws.liveDeskLiveOptions ? {
2204
+ frameMode: String(ws.liveDeskLiveOptions.frameMode || ws.liveDeskLiveOptions.mode || ''),
2205
+ streamPurpose: String(ws.liveDeskLiveOptions.streamPurpose || ''),
2206
+ fps: Math.max(0, Number(ws.liveDeskLiveOptions.fps || 0)),
2207
+ maxWidth: Math.max(0, Number(ws.liveDeskLiveOptions.maxWidth || 0)),
2208
+ maxHeight: Math.max(0, Number(ws.liveDeskLiveOptions.maxHeight || 0)),
2209
+ quality: Math.max(0, Number(ws.liveDeskLiveOptions.quality || 0))
2210
+ } : null,
2211
+ sharedProfileReuseCount: Math.max(0, Number(ws.liveDeskSharedProfileReuseCount || 0)),
2150
2212
  queueDepth: Math.max(0, Number(lane?.queue?.length || 0)),
2151
2213
  queueLimit: frameClientQueueLimit(ws),
2152
2214
  queueHighWaterPackets: Math.max(0, Number(lane?.queuedPacketsHighWater || 0)),
@@ -2939,9 +3001,23 @@ function startFrameSubscriptionLive(
2939
3001
  monitorIndex,
2940
3002
  reuseExisting: liveOptions.forceRestart !== true
2941
3003
  && (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
3004
+ // Wall capture is one shared native encoder per device. Two open browser
3005
+ // views may ask for different soft profiles (for example mobile 5 fps at
3006
+ // 1080p and desktop 20 fps at 540p). Once a healthy shared owner exists,
3007
+ // those preferences must bind to that owner instead of replacing it back
3008
+ // and forth every time either view refreshes its subscription.
3009
+ reuseSharedExisting: liveOptions.forceRestart !== true
3010
+ && String(liveOptions.streamPurpose || '').trim().toLowerCase() === 'wall'
3011
+ && hasOtherFrameStreamDemand(ws, deviceId, 'wall'),
2942
3012
  silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
2943
3013
  });
2944
3014
  if (result?.ok) {
3015
+ if (result.sharedProfileReused === true) {
3016
+ ws.liveDeskSharedProfileReuseCount = Math.max(
3017
+ 0,
3018
+ Number(ws.liveDeskSharedProfileReuseCount || 0)
3019
+ ) + 1;
3020
+ }
2945
3021
  frameCaptureTransitionRetries.complete(ws, deviceId, intentGeneration, 'capture-started');
2946
3022
  const expectedBinding = {
2947
3023
  deviceId,
@@ -5106,12 +5182,64 @@ app.post('/api/remote/devices/:deviceId/status-diagnostic', (req, res) => {
5106
5182
  }));
5107
5183
  });
5108
5184
 
5109
- app.post('/api/remote/devices/:deviceId/input', requireHubFeatureAccess, (req, res) => {
5110
- noStore(res);
5111
- res.json(remoteHub.sendInputControl(req.params.deviceId, req.body || {}));
5112
- });
5113
-
5114
- app.get('/api/remote/filesystem/roots', requireHubFeatureAccess, async (req, res) => {
5185
+ app.post('/api/remote/devices/:deviceId/input', requireHubFeatureAccess, (req, res) => {
5186
+ noStore(res);
5187
+ res.json(remoteHub.sendInputControl(req.params.deviceId, req.body || {}));
5188
+ });
5189
+
5190
+ app.post('/api/remote/devices/:deviceId/clipboard', requireHubFeatureAccess, async (req, res) => {
5191
+ noStore(res);
5192
+ try {
5193
+ const request = normalizeRemoteClipboardRequest(req.body || {});
5194
+ const inputClient = findLiveRemoteInputClient(request.hubConnectionId);
5195
+ if (!inputClient) {
5196
+ res.status(409).json({ ok: false, error: 'clipboard-input-owner-not-live' });
5197
+ return;
5198
+ }
5199
+ const deviceId = String(req.params.deviceId || '').trim();
5200
+ inputClient.liveDeskInputDeviceIds?.add?.(deviceId);
5201
+
5202
+ if (REMOTE_CLIPBOARD_INPUT_ACTIONS.has(request.action)) {
5203
+ const result = remoteHub.sendInputControl(deviceId, {
5204
+ type: request.action,
5205
+ clipboardOperationId: request.operationId,
5206
+ operationId: request.operationId,
5207
+ clipboardDirection: request.clipboardDirection,
5208
+ manifest: request.payload.manifest || null,
5209
+ contentKind: request.payload.contentKind || request.payload.manifest?.contentKind || '',
5210
+ controlSessionId: request.binding.controlSessionId,
5211
+ controlCommandId: request.binding.controlCommandId,
5212
+ captureGeneration: request.binding.captureGeneration,
5213
+ monitorIndex: request.binding.monitorIndex,
5214
+ hubConnectionId: request.hubConnectionId,
5215
+ requestAck: true
5216
+ });
5217
+ res.status(result.ok ? 200 : clipboardHttpStatus(result.error)).json({
5218
+ ok: result.ok === true,
5219
+ action: request.action,
5220
+ operationId: request.operationId,
5221
+ input: result,
5222
+ ...(result.ok ? {} : { error: result.error || 'clipboard-input-failed' })
5223
+ });
5224
+ return;
5225
+ }
5226
+
5227
+ const result = await remoteHub.sendClipboardCommand(deviceId, request);
5228
+ res.status(result.ok ? 200 : clipboardHttpStatus(result.error)).json(result);
5229
+ } catch (error) {
5230
+ const status = error instanceof RemoteClipboardContractError
5231
+ ? error.status
5232
+ : 500;
5233
+ res.status(status).json({
5234
+ ok: false,
5235
+ error: error instanceof RemoteClipboardContractError
5236
+ ? error.code
5237
+ : 'clipboard-request-failed'
5238
+ });
5239
+ }
5240
+ });
5241
+
5242
+ app.get('/api/remote/filesystem/roots', requireHubFeatureAccess, async (req, res) => {
5115
5243
  noStore(res);
5116
5244
  try {
5117
5245
  res.json(await hubFilesystem.getRoots({ refresh: /^(1|true|yes|on)$/i.test(String(req.query?.refresh || '')) }));
@@ -1,14 +1,36 @@
1
- import { effectiveDevicePolicy } from './settings-schema.js';
1
+ import { effectiveDevicePolicy } from './settings-schema.js';
2
+ import { REMOTE_CLIPBOARD_LIMITS } from '../remote-clipboard-contract.mjs';
2
3
 
3
- export function buildEffectiveDevicePolicy(settings, { deviceId = '', capabilities = {} } = {}) {
4
- const policy = effectiveDevicePolicy(settings);
5
- return {
4
+ export function buildEffectiveDevicePolicy(settings, { deviceId = '', capabilities = {} } = {}) {
5
+ const policy = effectiveDevicePolicy(settings);
6
+ const clipboardV1 = capabilities.clipboardProtocol === 'livedesk.clipboard.v1';
7
+ const clipboardCommonLimits = clipboardV1
8
+ && capabilities.sessionBoundSideChannels === true
9
+ && Number.isSafeInteger(Number(capabilities.clipboardMaxTextBytes))
10
+ && Number(capabilities.clipboardMaxTextBytes) > 0
11
+ && Number.isSafeInteger(Number(capabilities.clipboardMaxTotalBytes))
12
+ && Number(capabilities.clipboardMaxTotalBytes) > 0
13
+ && Number.isSafeInteger(Number(capabilities.clipboardMaxChunkBytes))
14
+ && Number(capabilities.clipboardMaxChunkBytes) >= REMOTE_CLIPBOARD_LIMITS.maxChunkBytes;
15
+ const clipboardMaxImageBytes = Number(
16
+ capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes);
17
+ return {
6
18
  ...policy,
7
- deviceId: String(deviceId || ''),
8
- supported: {
9
- control: capabilities.control !== false,
10
- clipboardText: capabilities.clipboardText !== false,
11
- fileTransfer: capabilities.fileTransfer !== false,
19
+ deviceId: String(deviceId || ''),
20
+ supported: {
21
+ control: capabilities.control !== false,
22
+ clipboardText: clipboardCommonLimits && capabilities.clipboardText === true,
23
+ clipboardImage: clipboardCommonLimits
24
+ && capabilities.clipboardImage === true
25
+ && Number.isSafeInteger(clipboardMaxImageBytes)
26
+ && clipboardMaxImageBytes > 0,
27
+ clipboardFiles: clipboardCommonLimits
28
+ && capabilities.clipboardFiles === true
29
+ && Number.isSafeInteger(Number(capabilities.clipboardMaxFiles))
30
+ && Number(capabilities.clipboardMaxFiles) > 0,
31
+ clipboardCopy: clipboardCommonLimits && capabilities.clipboardCopy === true,
32
+ clipboardPaste: clipboardCommonLimits && capabilities.clipboardPaste === true,
33
+ fileTransfer: capabilities.fileTransfer !== false,
12
34
  remoteAudio: capabilities.audio === true || capabilities.remoteAudio === true,
13
35
  agent: Array.isArray(capabilities.agentTools) && capabilities.agentTools.length > 0
14
36
  }