@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/package.json +2 -2
- package/src/filesystem/roots.js +20 -2
- package/src/remote-clipboard-contract.mjs +482 -0
- package/src/remote-hub.js +1140 -221
- package/src/server.js +137 -9
- package/src/settings/effective-device-policy.js +31 -9
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.
|
|
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
|
-
|
|
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
|
|
11
|
-
|
|
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
|
}
|