@livedesk/hub 0.1.46 → 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
@@ -7,9 +7,14 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, wri
7
7
  import { dirname, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import os from 'node:os';
10
- import { WebSocketServer } from 'ws';
10
+ import { WebSocketServer } from 'ws';
11
11
  import { createRemoteHub } from './remote-hub.js';
12
- import { createHubConsoleRelay } from './console-relay.js';
12
+ import {
13
+ REMOTE_CLIPBOARD_INPUT_ACTIONS,
14
+ RemoteClipboardContractError,
15
+ normalizeRemoteClipboardRequest
16
+ } from './remote-clipboard-contract.mjs';
17
+ import { createHubConsoleRelay } from './console-relay.js';
13
18
  import {
14
19
  buildImmutableRemoteFramePacket,
15
20
  createRemoteFramePacketMetrics,
@@ -40,9 +45,9 @@ import { createAgentDeviceScope, resolveAgentTargetIds } from './agents/agent-de
40
45
  import { AgentRuntimeError } from './agents/agent-runtime-error.js';
41
46
  import { AGENT_PERMISSION_MODES, createAgentPermissionPolicy, evaluateAgentToolPermission, hashAgentPermissionPolicy } from './agents/agent-permissions.js';
42
47
  import { createAgentPermissionStore } from './agents/agent-permission-store.js';
43
- import { createAgentAuditStore } from './agents/agent-audit-store.js';
44
- import { getAgentToolDefinition } from './agents/agent-tool-registry.js';
45
- import { enrichAgentTaskResults } from './agents/agent-result-enrichment.js';
48
+ import { createAgentAuditStore } from './agents/agent-audit-store.js';
49
+ import { getAgentToolDefinition } from './agents/agent-tool-registry.js';
50
+ import { enrichAgentTaskResults } from './agents/agent-result-enrichment.js';
46
51
  import { LiveDeskSettingsStore, SettingsConflictError } from './settings/settings-store.js';
47
52
  import { effectiveDevicePolicy } from './settings/settings-schema.js';
48
53
  import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
@@ -204,22 +209,22 @@ let verifiedLicense = {
204
209
  let frameClientSeq = 0;
205
210
  let inputClientSeq = 0;
206
211
  let audioClientSeq = 0;
207
- let liveDeskUpdateManager = null;
208
- let hubTransferJobs = null;
209
- let hubConsoleRelay = null;
212
+ let liveDeskUpdateManager = null;
213
+ let hubTransferJobs = null;
214
+ let hubConsoleRelay = null;
210
215
  const HUB_HOST_TARGET_LEASE_MS = Math.max(5000, readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_LEASE_MS', 60_000));
211
216
  const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
212
217
  readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
213
218
  Math.max(1000, HUB_HOST_TARGET_LEASE_MS - 1000)
214
219
  ));
215
- const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
216
- const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
217
- const hubConsoleRelayBaseUrl = String(
218
- process.env.LIVEDESK_CONSOLE_RELAY_URL
219
- || (process.env.LIVEDESK_TEST_MODE === '1' || process.env.LIVEDESK_AUTH_TEST_MODE === '1'
220
- ? 'off'
221
- : 'https://livedesk-wake.lovecrdm.workers.dev')
222
- ).trim();
220
+ const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
221
+ const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
222
+ const hubConsoleRelayBaseUrl = String(
223
+ process.env.LIVEDESK_CONSOLE_RELAY_URL
224
+ || (process.env.LIVEDESK_TEST_MODE === '1' || process.env.LIVEDESK_AUTH_TEST_MODE === '1'
225
+ ? 'off'
226
+ : 'https://livedesk-wake.lovecrdm.workers.dev')
227
+ ).trim();
223
228
  const HUB_WAKE_NOTIFY_RETRY_MS = 60_000;
224
229
  let hubHostTargetRenewTimer = null;
225
230
  let hubHostTargetRenewInFlight = false;
@@ -513,19 +518,19 @@ async function getRuntimeAccessToken() {
513
518
  const accessToken = String(runtimeAccessToken || '').trim();
514
519
  const expiresSoon = runtimeAccessTokenExpiresAt > 0
515
520
  && runtimeAccessTokenExpiresAt <= Date.now() + HUB_ACCESS_TOKEN_REFRESH_SKEW_MS;
516
- if (accessToken && !expiresSoon) {
517
- return accessToken;
518
- }
519
-
520
- // The packaged Electron main process owns the encrypted refresh token and
521
- // proactively sends each rotation to this child runtime. A second refresh
522
- // owner here could consume the one-time token first and make the desktop
523
- // shell appear signed out after sleep.
524
- if (desktopMainAuthConfig) {
525
- return accessToken;
526
- }
527
-
528
- const refreshToken = String(runtimeRefreshToken || '').trim();
521
+ if (accessToken && !expiresSoon) {
522
+ return accessToken;
523
+ }
524
+
525
+ // The packaged Electron main process owns the encrypted refresh token and
526
+ // proactively sends each rotation to this child runtime. A second refresh
527
+ // owner here could consume the one-time token first and make the desktop
528
+ // shell appear signed out after sleep.
529
+ if (desktopMainAuthConfig) {
530
+ return accessToken;
531
+ }
532
+
533
+ const refreshToken = String(runtimeRefreshToken || '').trim();
529
534
  if (!refreshToken) {
530
535
  return accessToken;
531
536
  }
@@ -629,9 +634,9 @@ async function queryAuthoritativeRuntimeRole() {
629
634
  },
630
635
  SUPABASE_AUTH_TIMEOUT_MS
631
636
  );
632
- if (response.status === 401 || response.status === 403) {
633
- if (!desktopMainAuthConfig) clearRuntimeSession();
634
- throw new Error(`hub-role-query-not-authenticated:${response.status}`);
637
+ if (response.status === 401 || response.status === 403) {
638
+ if (!desktopMainAuthConfig) clearRuntimeSession();
639
+ throw new Error(`hub-role-query-not-authenticated:${response.status}`);
635
640
  }
636
641
  if (!response.ok) {
637
642
  throw new Error(`hub-role-query-failed:${response.status}`);
@@ -813,12 +818,12 @@ function getLiveDeskUpdateStatus() {
813
818
  }
814
819
 
815
820
  if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
816
- liveDeskUpdateManager = createLiveDeskUpdateManager({
817
- remoteHub,
818
- currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
819
- currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
820
- excludedDeviceIds: [runtimeDeviceId],
821
- restartSupported: !!String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim(),
821
+ liveDeskUpdateManager = createLiveDeskUpdateManager({
822
+ remoteHub,
823
+ currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
824
+ currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
825
+ excludedDeviceIds: [runtimeDeviceId],
826
+ restartSupported: !!String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim(),
822
827
  requestHubRestart
823
828
  });
824
829
  }
@@ -1278,16 +1283,16 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
1278
1283
  remoteHub.cancelTaskBatch(result.batchId);
1279
1284
  return { ok: false, error: 'cancelled-by-user', batchId: result.batchId };
1280
1285
  }
1281
- const batch = remoteHub.getTaskBatch(result.batchId);
1282
- if (!batch) return { ok: false, error: 'task-not-found', batchId: result.batchId };
1283
- if (!['queued', 'running'].includes(batch.status)) {
1284
- const taskResults = enrichAgentTaskResults({
1285
- operation,
1286
- results: batch.results,
1287
- devices: remoteHub.listDevices({ includeDataUrl: false })
1288
- .filter(device => targetIds.includes(device.deviceId))
1289
- });
1290
- return {
1286
+ const batch = remoteHub.getTaskBatch(result.batchId);
1287
+ if (!batch) return { ok: false, error: 'task-not-found', batchId: result.batchId };
1288
+ if (!['queued', 'running'].includes(batch.status)) {
1289
+ const taskResults = enrichAgentTaskResults({
1290
+ operation,
1291
+ results: batch.results,
1292
+ devices: remoteHub.listDevices({ includeDataUrl: false })
1293
+ .filter(device => targetIds.includes(device.deviceId))
1294
+ });
1295
+ return {
1291
1296
  ok: true,
1292
1297
  batchId: result.batchId,
1293
1298
  operation,
@@ -1295,8 +1300,8 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
1295
1300
  total: batch.total,
1296
1301
  completed: batch.completed,
1297
1302
  failed: batch.failed,
1298
- results: taskResults.map(item => ({ deviceId: item.deviceId, deviceName: item.deviceName, status: item.status, stage: item.stage, result: String(item.result || '').slice(0, 3000), data: item.data, error: String(item.error || '').slice(0, 500) }))
1299
- };
1303
+ results: taskResults.map(item => ({ deviceId: item.deviceId, deviceName: item.deviceName, status: item.status, stage: item.stage, result: String(item.result || '').slice(0, 3000), data: item.data, error: String(item.error || '').slice(0, 500) }))
1304
+ };
1300
1305
  }
1301
1306
  await delayAgentMcp(200);
1302
1307
  }
@@ -1356,16 +1361,16 @@ const hubSharedFolders = createHubSharedFolders({
1356
1361
  dataDir: agentDataDir
1357
1362
  });
1358
1363
 
1359
- const app = express();
1360
- const httpServer = createServer(app);
1361
- hubConsoleRelay = createHubConsoleRelay({
1362
- url: runtimeRole === 'hub' ? hubConsoleRelayBaseUrl : 'off',
1363
- deviceId: runtimeDeviceId,
1364
- httpBaseUrl: `http://127.0.0.1:${httpPort}`,
1365
- getAccessToken: () => getRuntimeAccessToken(),
1366
- logger: console
1367
- });
1368
- const httpConnections = new Set();
1364
+ const app = express();
1365
+ const httpServer = createServer(app);
1366
+ hubConsoleRelay = createHubConsoleRelay({
1367
+ url: runtimeRole === 'hub' ? hubConsoleRelayBaseUrl : 'off',
1368
+ deviceId: runtimeDeviceId,
1369
+ httpBaseUrl: `http://127.0.0.1:${httpPort}`,
1370
+ getAccessToken: () => getRuntimeAccessToken(),
1371
+ logger: console
1372
+ });
1373
+ const httpConnections = new Set();
1369
1374
  const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1370
1375
  const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1371
1376
  const inputWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
@@ -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,
@@ -4249,10 +4325,10 @@ app.get('/api/remote/status', (_req, res) => {
4249
4325
  runtimeRole,
4250
4326
  deviceId: runtimeDeviceId,
4251
4327
  deviceName: runtimeDeviceName,
4252
- roleSource: runtimeRoleSource,
4253
- agentPackage: '@livedesk/client',
4254
- consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4255
- frameLanes: snapshotFrameLaneResourceHealth(),
4328
+ roleSource: runtimeRoleSource,
4329
+ agentPackage: '@livedesk/client',
4330
+ consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4331
+ frameLanes: snapshotFrameLaneResourceHealth(),
4256
4332
  update: getLiveDeskUpdateStatus()
4257
4333
  });
4258
4334
  });
@@ -4484,11 +4560,11 @@ app.post('/api/auth/session', async (req, res) => {
4484
4560
  csrfToken: uiSession.csrfToken,
4485
4561
  expiresAt: uiSession.expiresAt
4486
4562
  }
4487
- });
4488
- if (runtimeRole === 'hub') {
4489
- hubConsoleRelay?.refresh();
4490
- scheduleAuthenticatedHubHostTargetPublication('session-received');
4491
- }
4563
+ });
4564
+ if (runtimeRole === 'hub') {
4565
+ hubConsoleRelay?.refresh();
4566
+ scheduleAuthenticatedHubHostTargetPublication('session-received');
4567
+ }
4492
4568
  } catch (error) {
4493
4569
  const message = error instanceof Error ? error.message : String(error);
4494
4570
  const status = authVerificationHttpStatus(message);
@@ -4508,11 +4584,11 @@ function getHubHostTargetLeaseStatus() {
4508
4584
  ...hubHostTargetLeaseState,
4509
4585
  renewing: hubHostTargetRenewInFlight,
4510
4586
  renewalIntervalMs: HUB_HOST_TARGET_RENEW_MS,
4511
- leaseDurationMs: HUB_HOST_TARGET_LEASE_MS,
4512
- authenticated: Boolean(runtimeAccessToken),
4513
- timerActive: Boolean(hubHostTargetRenewTimer),
4514
- wake: { ...hubWakeNotificationState },
4515
- consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' }
4587
+ leaseDurationMs: HUB_HOST_TARGET_LEASE_MS,
4588
+ authenticated: Boolean(runtimeAccessToken),
4589
+ timerActive: Boolean(hubHostTargetRenewTimer),
4590
+ wake: { ...hubWakeNotificationState },
4591
+ consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' }
4516
4592
  };
4517
4593
  }
4518
4594
 
@@ -4609,9 +4685,9 @@ async function callHubSupabaseRpc(name, payload, accessToken) {
4609
4685
  );
4610
4686
  const data = await response.json().catch(() => null);
4611
4687
  const result = Array.isArray(data) ? data[0] : data;
4612
- if (response.status === 401 || response.status === 403) {
4613
- if (!desktopMainAuthConfig) clearRuntimeSession();
4614
- throw new Error(`${name}-not-authenticated:${response.status}`);
4688
+ if (response.status === 401 || response.status === 403) {
4689
+ if (!desktopMainAuthConfig) clearRuntimeSession();
4690
+ throw new Error(`${name}-not-authenticated:${response.status}`);
4615
4691
  }
4616
4692
  if (!response.ok) {
4617
4693
  throw new Error(`${name}-failed:${response.status}`);
@@ -4816,11 +4892,11 @@ app.get('/api/hub/status', (_req, res) => {
4816
4892
  ...remoteHub.getStatus({ includeSecrets: false }),
4817
4893
  role: 'hub',
4818
4894
  deviceId: runtimeDeviceId,
4819
- deviceName: runtimeDeviceName,
4820
- roleSource: runtimeRoleSource,
4821
- runtimeStarted: true,
4822
- consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4823
- hostTargetLease: getHubHostTargetLeaseStatus(),
4895
+ deviceName: runtimeDeviceName,
4896
+ roleSource: runtimeRoleSource,
4897
+ runtimeStarted: true,
4898
+ consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4899
+ hostTargetLease: getHubHostTargetLeaseStatus(),
4824
4900
  update: getLiveDeskUpdateStatus()
4825
4901
  });
4826
4902
  });
@@ -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 || '')) }));
@@ -5827,14 +5955,14 @@ hubSharedFolders.startAutoSync(
5827
5955
  () => remoteHub.listDevices({ includeDataUrl: false }).filter(device => device.connected).map(device => device.deviceId),
5828
5956
  () => process.env.LIVEDESK_REMOTE_DIRECTORY || 'Desktop/LiveDeskFiles'
5829
5957
  );
5830
- httpServer.listen(httpPort, httpHost, () => {
5958
+ httpServer.listen(httpPort, httpHost, () => {
5831
5959
  const status = remoteHub.getStatus({ includeSecrets: true });
5832
5960
  const managerVersion = String(process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version || 'dev');
5833
5961
  console.log(`[LiveDesk Hub] Version ${managerVersion}`);
5834
- console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
5835
- console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
5836
- hubConsoleRelay?.start();
5837
- });
5962
+ console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
5963
+ console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
5964
+ hubConsoleRelay?.start();
5965
+ });
5838
5966
 
5839
5967
  const roleWatchTimer = runtimeRole === 'hub'
5840
5968
  ? setInterval(() => { void watchAuthoritativeRuntimeRole(); }, 5000)
@@ -5915,10 +6043,10 @@ function shutdownHub(signal) {
5915
6043
  const startedAt = Date.now();
5916
6044
  console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
5917
6045
  if (roleWatchTimer) clearInterval(roleWatchTimer);
5918
- clearInterval(browserWebSocketHeartbeatTimer);
5919
- runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
5920
- runSynchronousShutdownStep('mobile console relay close', () => hubConsoleRelay?.close());
5921
- atlasClients.clear();
6046
+ clearInterval(browserWebSocketHeartbeatTimer);
6047
+ runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
6048
+ runSynchronousShutdownStep('mobile console relay close', () => hubConsoleRelay?.close());
6049
+ atlasClients.clear();
5922
6050
  runSynchronousShutdownStep('shared folder close', () => hubSharedFolders.close());
5923
6051
 
5924
6052
  const httpClosed = new Promise(resolveClose => {
@@ -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
  }
@@ -13,7 +13,8 @@ export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
13
13
  approveNewDevices: true,
14
14
  startWithComputer: true,
15
15
  keepRunningInTray: true,
16
- showConnectionNotifications: true
16
+ showConnectionNotifications: true,
17
+ showThisComputer: false
17
18
  },
18
19
  security: {
19
20
  accessMode: 'trusted-only',
@@ -157,7 +158,7 @@ const numbers = (entries) => Object.fromEntries(entries.map(([key, min, max]) =>
157
158
 
158
159
  const RULES = {
159
160
  connection: {
160
- ...bools(['usePinToAddDevices', 'rotatePinAfterPairing', 'allowNewDevices', 'approveNewDevices', 'startWithComputer', 'keepRunningInTray', 'showConnectionNotifications']),
161
+ ...bools(['usePinToAddDevices', 'rotatePinAfterPairing', 'allowNewDevices', 'approveNewDevices', 'startWithComputer', 'keepRunningInTray', 'showConnectionNotifications', 'showThisComputer']),
161
162
  ...numbers([['pinValidityMinutes', 10, 1440]])
162
163
  },
163
164
  security: {