@livedesk/hub 0.1.47 → 0.1.49

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,
@@ -1162,9 +1167,11 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
1162
1167
  'livedesk.close_application': 'application.close',
1163
1168
  'livedesk.read_file': 'file.read',
1164
1169
  'livedesk.write_file': 'file.write',
1165
- 'livedesk.delete_file': 'file.delete',
1166
- 'livedesk.list_directory': 'file.list',
1167
- 'livedesk.run_command': 'command.run',
1170
+ 'livedesk.delete_file': 'file.delete',
1171
+ 'livedesk.list_directory': 'file.list',
1172
+ 'livedesk.search_files': 'file.search',
1173
+ 'livedesk.create_directory': 'directory.create',
1174
+ 'livedesk.run_command': 'command.run',
1168
1175
  'livedesk.run_script': 'script.run',
1169
1176
  'livedesk.install_software': 'software.install',
1170
1177
  'livedesk.get_network_status': 'network.status',
@@ -1754,12 +1761,40 @@ function normalizeLiveOptions(payload = {}) {
1754
1761
  };
1755
1762
  }
1756
1763
 
1757
- function sendJson(ws, payload) {
1764
+ function sendJson(ws, payload) {
1758
1765
  if (!ws || ws.readyState !== 1) {
1759
1766
  return false;
1760
1767
  }
1761
1768
  return safeWebSocketSend(ws, JSON.stringify(payload));
1762
- }
1769
+ }
1770
+
1771
+ function findLiveRemoteInputClient(hubConnectionId) {
1772
+ const requestedId = String(hubConnectionId || '').trim();
1773
+ if (!requestedId) {
1774
+ return null;
1775
+ }
1776
+ for (const ws of inputClients) {
1777
+ if (ws.readyState === 1 && ws.liveDeskInputClientId === requestedId) {
1778
+ return ws;
1779
+ }
1780
+ }
1781
+ return null;
1782
+ }
1783
+
1784
+ function clipboardHttpStatus(error) {
1785
+ const code = String(error || '');
1786
+ if (code.includes('blocked-by-settings') || code === 'remote-access-blocked-by-settings') return 403;
1787
+ if (code.includes('too-large')) return 413;
1788
+ if (code.includes('unavailable') || code === 'device-not-connected') return 503;
1789
+ if (code.startsWith('STALE_')
1790
+ || code.includes('owner')
1791
+ || code.includes('not-ready')
1792
+ || code.includes('not-found')
1793
+ || code.includes('already-exists')
1794
+ || code.includes('busy')
1795
+ || code.includes('capacity')) return 409;
1796
+ return 400;
1797
+ }
1763
1798
 
1764
1799
  function forgetWebSocketClient(ws) {
1765
1800
  unregisterFrameClient(ws);
@@ -1846,6 +1881,26 @@ function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
1846
1881
  return false;
1847
1882
  }
1848
1883
 
1884
+ function hasOtherFrameStreamDemand(ws, deviceId, streamPurpose = '') {
1885
+ const normalizedPurpose = String(streamPurpose || '').trim().toLowerCase();
1886
+ if (!deviceId || !normalizedPurpose) {
1887
+ return false;
1888
+ }
1889
+ for (const candidate of frameClients) {
1890
+ if (candidate === ws || candidate.readyState !== candidate.OPEN) {
1891
+ continue;
1892
+ }
1893
+ if (candidate.liveDeskAutoStart === true
1894
+ && candidate.liveDeskDeviceIds instanceof Set
1895
+ && candidate.liveDeskDeviceIds.has(deviceId)
1896
+ && String(candidate.liveDeskLiveOptions?.streamPurpose || '').trim().toLowerCase()
1897
+ === normalizedPurpose) {
1898
+ return true;
1899
+ }
1900
+ }
1901
+ return false;
1902
+ }
1903
+
1849
1904
  function frameStreamStopKey(deviceId, streamId, streamPurpose) {
1850
1905
  return `${deviceId}\u0000${streamId}\u0000${streamPurpose}`;
1851
1906
  }
@@ -2147,6 +2202,15 @@ function snapshotFrameLaneResourceHealth() {
2147
2202
  .map(binding => String(binding?.streamPurpose || '').trim().toLowerCase())
2148
2203
  .filter(Boolean))]
2149
2204
  .slice(0, 4),
2205
+ requestedProfile: ws.liveDeskLiveOptions ? {
2206
+ frameMode: String(ws.liveDeskLiveOptions.frameMode || ws.liveDeskLiveOptions.mode || ''),
2207
+ streamPurpose: String(ws.liveDeskLiveOptions.streamPurpose || ''),
2208
+ fps: Math.max(0, Number(ws.liveDeskLiveOptions.fps || 0)),
2209
+ maxWidth: Math.max(0, Number(ws.liveDeskLiveOptions.maxWidth || 0)),
2210
+ maxHeight: Math.max(0, Number(ws.liveDeskLiveOptions.maxHeight || 0)),
2211
+ quality: Math.max(0, Number(ws.liveDeskLiveOptions.quality || 0))
2212
+ } : null,
2213
+ sharedProfileReuseCount: Math.max(0, Number(ws.liveDeskSharedProfileReuseCount || 0)),
2150
2214
  queueDepth: Math.max(0, Number(lane?.queue?.length || 0)),
2151
2215
  queueLimit: frameClientQueueLimit(ws),
2152
2216
  queueHighWaterPackets: Math.max(0, Number(lane?.queuedPacketsHighWater || 0)),
@@ -2939,9 +3003,23 @@ function startFrameSubscriptionLive(
2939
3003
  monitorIndex,
2940
3004
  reuseExisting: liveOptions.forceRestart !== true
2941
3005
  && (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
3006
+ // Wall capture is one shared native encoder per device. Two open browser
3007
+ // views may ask for different soft profiles (for example mobile 5 fps at
3008
+ // 1080p and desktop 20 fps at 540p). Once a healthy shared owner exists,
3009
+ // those preferences must bind to that owner instead of replacing it back
3010
+ // and forth every time either view refreshes its subscription.
3011
+ reuseSharedExisting: liveOptions.forceRestart !== true
3012
+ && String(liveOptions.streamPurpose || '').trim().toLowerCase() === 'wall'
3013
+ && hasOtherFrameStreamDemand(ws, deviceId, 'wall'),
2942
3014
  silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
2943
3015
  });
2944
3016
  if (result?.ok) {
3017
+ if (result.sharedProfileReused === true) {
3018
+ ws.liveDeskSharedProfileReuseCount = Math.max(
3019
+ 0,
3020
+ Number(ws.liveDeskSharedProfileReuseCount || 0)
3021
+ ) + 1;
3022
+ }
2945
3023
  frameCaptureTransitionRetries.complete(ws, deviceId, intentGeneration, 'capture-started');
2946
3024
  const expectedBinding = {
2947
3025
  deviceId,
@@ -5106,12 +5184,64 @@ app.post('/api/remote/devices/:deviceId/status-diagnostic', (req, res) => {
5106
5184
  }));
5107
5185
  });
5108
5186
 
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) => {
5187
+ app.post('/api/remote/devices/:deviceId/input', requireHubFeatureAccess, (req, res) => {
5188
+ noStore(res);
5189
+ res.json(remoteHub.sendInputControl(req.params.deviceId, req.body || {}));
5190
+ });
5191
+
5192
+ app.post('/api/remote/devices/:deviceId/clipboard', requireHubFeatureAccess, async (req, res) => {
5193
+ noStore(res);
5194
+ try {
5195
+ const request = normalizeRemoteClipboardRequest(req.body || {});
5196
+ const inputClient = findLiveRemoteInputClient(request.hubConnectionId);
5197
+ if (!inputClient) {
5198
+ res.status(409).json({ ok: false, error: 'clipboard-input-owner-not-live' });
5199
+ return;
5200
+ }
5201
+ const deviceId = String(req.params.deviceId || '').trim();
5202
+ inputClient.liveDeskInputDeviceIds?.add?.(deviceId);
5203
+
5204
+ if (REMOTE_CLIPBOARD_INPUT_ACTIONS.has(request.action)) {
5205
+ const result = remoteHub.sendInputControl(deviceId, {
5206
+ type: request.action,
5207
+ clipboardOperationId: request.operationId,
5208
+ operationId: request.operationId,
5209
+ clipboardDirection: request.clipboardDirection,
5210
+ manifest: request.payload.manifest || null,
5211
+ contentKind: request.payload.contentKind || request.payload.manifest?.contentKind || '',
5212
+ controlSessionId: request.binding.controlSessionId,
5213
+ controlCommandId: request.binding.controlCommandId,
5214
+ captureGeneration: request.binding.captureGeneration,
5215
+ monitorIndex: request.binding.monitorIndex,
5216
+ hubConnectionId: request.hubConnectionId,
5217
+ requestAck: true
5218
+ });
5219
+ res.status(result.ok ? 200 : clipboardHttpStatus(result.error)).json({
5220
+ ok: result.ok === true,
5221
+ action: request.action,
5222
+ operationId: request.operationId,
5223
+ input: result,
5224
+ ...(result.ok ? {} : { error: result.error || 'clipboard-input-failed' })
5225
+ });
5226
+ return;
5227
+ }
5228
+
5229
+ const result = await remoteHub.sendClipboardCommand(deviceId, request);
5230
+ res.status(result.ok ? 200 : clipboardHttpStatus(result.error)).json(result);
5231
+ } catch (error) {
5232
+ const status = error instanceof RemoteClipboardContractError
5233
+ ? error.status
5234
+ : 500;
5235
+ res.status(status).json({
5236
+ ok: false,
5237
+ error: error instanceof RemoteClipboardContractError
5238
+ ? error.code
5239
+ : 'clipboard-request-failed'
5240
+ });
5241
+ }
5242
+ });
5243
+
5244
+ app.get('/api/remote/filesystem/roots', requireHubFeatureAccess, async (req, res) => {
5115
5245
  noStore(res);
5116
5246
  try {
5117
5247
  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
  }