@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/package.json +2 -2
- package/src/agents/agent-tool-registry.js +34 -8
- package/src/filesystem/roots.js +20 -2
- package/src/remote-clipboard-contract.mjs +482 -0
- package/src/remote-hub.js +1145 -224
- package/src/server.js +142 -12
- 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,
|
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
}
|