@livedesk/hub 0.1.0 → 0.1.1
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/README.md +7 -1
- package/package.json +5 -2
- package/src/filesystem/directory-reader.js +187 -0
- package/src/filesystem/hub-filesystem.js +78 -0
- package/src/filesystem/path-registry.js +78 -0
- package/src/filesystem/roots.js +115 -0
- package/src/filesystem/shared-folders.js +180 -0
- package/src/filesystem/transfer-jobs.js +229 -0
- package/src/lzo1x.js +109 -0
- package/src/mode4-atlas-worker.js +439 -0
- package/src/mode4-atlas.js +129 -0
- package/src/remote-hub.js +1295 -163
- package/src/server.js +1150 -69
package/src/server.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import cors from 'cors';
|
|
4
3
|
import express from 'express';
|
|
5
4
|
import crypto from 'node:crypto';
|
|
6
5
|
import { createServer } from 'node:http';
|
|
@@ -9,6 +8,11 @@ import { dirname, resolve } from 'node:path';
|
|
|
9
8
|
import { fileURLToPath } from 'node:url';
|
|
10
9
|
import { WebSocketServer } from 'ws';
|
|
11
10
|
import { createRemoteHub } from './remote-hub.js';
|
|
11
|
+
import { Mode4AtlasSession } from './mode4-atlas.js';
|
|
12
|
+
import { createHubFilesystem } from './filesystem/hub-filesystem.js';
|
|
13
|
+
import { createHubTransferJobs } from './filesystem/transfer-jobs.js';
|
|
14
|
+
import { createHubSharedFolders } from './filesystem/shared-folders.js';
|
|
15
|
+
import { toFilesystemHttpError } from './filesystem/directory-reader.js';
|
|
12
16
|
|
|
13
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
18
|
const webDistCandidates = [
|
|
@@ -19,15 +23,173 @@ const webDistCandidates = [
|
|
|
19
23
|
const webDistPath = webDistCandidates.find(candidate => existsSync(resolve(candidate, 'index.html'))) || webDistCandidates[webDistCandidates.length - 1];
|
|
20
24
|
const webIndexPath = resolve(webDistPath, 'index.html');
|
|
21
25
|
const packageInfo = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
22
|
-
const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '
|
|
26
|
+
const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '127.0.0.1';
|
|
23
27
|
const httpPort = Number(process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT || 5179);
|
|
28
|
+
const frameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_BACKPRESSURE_BYTES', 64 * 1024 * 1024);
|
|
29
|
+
const frameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_QUEUE_PACKETS', 256);
|
|
30
|
+
const frameClientDrainBudgetPackets = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_DRAIN_BUDGET_PACKETS', 64);
|
|
31
|
+
const mode5FrameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_MODE5_WS_BACKPRESSURE_BYTES', 2 * 1024 * 1024);
|
|
32
|
+
const mode5FrameQueuePackets = readPositiveIntegerEnv('LIVEDESK_MODE5_WS_QUEUE_PACKETS', 2);
|
|
24
33
|
const frameClients = new Set();
|
|
34
|
+
const frameClientsByDeviceId = new Map();
|
|
35
|
+
const frameWildcardClients = new Set();
|
|
36
|
+
const atlasClients = new Map();
|
|
25
37
|
const inputClients = new Set();
|
|
26
38
|
const audioClients = new Set();
|
|
39
|
+
const FREE_DEVICE_LIMIT = 5;
|
|
40
|
+
const PLUS_DEVICE_LIMIT = 30;
|
|
41
|
+
const LICENSE_VERIFY_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
42
|
+
const supabaseUrl = String(process.env.LIVEDESK_SUPABASE_URL || 'https://otbyfkjxrkngvjziawki.supabase.co').replace(/\/+$/, '');
|
|
43
|
+
const supabasePublishableKey = String(process.env.LIVEDESK_SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_NpUs0RDJH2YnllsqTKO6TQ_1jTdSsNQ');
|
|
44
|
+
const testLicensePlan = process.env.LIVEDESK_TEST_MODE === '1'
|
|
45
|
+
&& ['ltd', 'pro'].includes(String(process.env.LIVEDESK_TEST_LICENSE_PLAN || '').toLowerCase())
|
|
46
|
+
? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
|
|
47
|
+
: '';
|
|
48
|
+
let connectedDeviceCount = 0;
|
|
49
|
+
let verifiedLicense = {
|
|
50
|
+
userId: '',
|
|
51
|
+
plan: 'free',
|
|
52
|
+
status: 'inactive',
|
|
53
|
+
expiresAt: '',
|
|
54
|
+
verifiedAt: 0
|
|
55
|
+
};
|
|
27
56
|
let frameClientSeq = 0;
|
|
28
57
|
let inputClientSeq = 0;
|
|
29
58
|
let audioClientSeq = 0;
|
|
30
59
|
|
|
60
|
+
function readPositiveIntegerEnv(name, fallback) {
|
|
61
|
+
const value = Number(process.env[name]);
|
|
62
|
+
return Number.isFinite(value) && value > 0 ? Math.round(value) : fallback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function handleRemoteHubEvent(type, event) {
|
|
66
|
+
if (type === 'RemoteInputApplied') {
|
|
67
|
+
for (const ws of inputClients) {
|
|
68
|
+
sendJson(ws, {
|
|
69
|
+
type: 'RemoteInputApplied',
|
|
70
|
+
...event
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
|
|
76
|
+
connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
|
|
77
|
+
}
|
|
78
|
+
if (type !== 'RemoteDeviceConnected') {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const deviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
|
|
82
|
+
if (!deviceId) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const ws of atlasClients.keys()) {
|
|
86
|
+
if (ws.readyState === 1 && ws.liveDeskAtlasDeviceIds?.has(deviceId)) {
|
|
87
|
+
startMode4AtlasInput(ws, deviceId, 'device-connected');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const clients = new Set([
|
|
91
|
+
...(frameClientsByDeviceId.get(deviceId) || []),
|
|
92
|
+
...frameWildcardClients
|
|
93
|
+
]);
|
|
94
|
+
for (const ws of clients) {
|
|
95
|
+
if (ws.readyState === 1
|
|
96
|
+
&& ws.liveDeskAutoStart
|
|
97
|
+
&& (!ws.liveDeskDeviceIds?.size || ws.liveDeskDeviceIds.has(deviceId))) {
|
|
98
|
+
startFrameSubscriptionLive(ws, 'device-reconnected', deviceId);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function activeLicensePlan() {
|
|
104
|
+
if (testLicensePlan) {
|
|
105
|
+
return testLicensePlan;
|
|
106
|
+
}
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
if (verifiedLicense.status !== 'active'
|
|
109
|
+
|| now - verifiedLicense.verifiedAt > LICENSE_VERIFY_MAX_AGE_MS) {
|
|
110
|
+
return 'free';
|
|
111
|
+
}
|
|
112
|
+
const expiresAt = verifiedLicense.expiresAt ? Date.parse(verifiedLicense.expiresAt) : Number.POSITIVE_INFINITY;
|
|
113
|
+
if (Number.isFinite(expiresAt) && expiresAt <= now) {
|
|
114
|
+
return 'free';
|
|
115
|
+
}
|
|
116
|
+
return verifiedLicense.plan === 'pro' ? 'pro' : verifiedLicense.plan === 'ltd' ? 'ltd' : 'free';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function activeDeviceLimit() {
|
|
120
|
+
const plan = activeLicensePlan();
|
|
121
|
+
return plan === 'pro' ? Number.POSITIVE_INFINITY : plan === 'ltd' ? PLUS_DEVICE_LIMIT : FREE_DEVICE_LIMIT;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hasHubFeatureAccess() {
|
|
125
|
+
return connectedDeviceCount <= activeDeviceLimit();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function licenseSnapshot() {
|
|
129
|
+
const plan = activeLicensePlan();
|
|
130
|
+
const limit = activeDeviceLimit();
|
|
131
|
+
return {
|
|
132
|
+
plan,
|
|
133
|
+
status: plan === 'free' ? 'free' : 'active',
|
|
134
|
+
deviceLimit: Number.isFinite(limit) ? limit : null,
|
|
135
|
+
connectedDeviceCount,
|
|
136
|
+
featureAccess: connectedDeviceCount <= limit,
|
|
137
|
+
verifiedAt: verifiedLicense.verifiedAt ? new Date(verifiedLicense.verifiedAt).toISOString() : '',
|
|
138
|
+
expiresAt: verifiedLicense.expiresAt || ''
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function syncVerifiedLicense(accessToken) {
|
|
143
|
+
const token = String(accessToken || '').trim();
|
|
144
|
+
if (!token) {
|
|
145
|
+
throw new Error('supabase-access-token-required');
|
|
146
|
+
}
|
|
147
|
+
const headers = {
|
|
148
|
+
apikey: supabasePublishableKey,
|
|
149
|
+
Authorization: `Bearer ${token}`,
|
|
150
|
+
Accept: 'application/json'
|
|
151
|
+
};
|
|
152
|
+
const userResponse = await fetch(`${supabaseUrl}/auth/v1/user`, { headers });
|
|
153
|
+
if (!userResponse.ok) {
|
|
154
|
+
throw new Error(`supabase-user-verification-failed:${userResponse.status}`);
|
|
155
|
+
}
|
|
156
|
+
const user = await userResponse.json();
|
|
157
|
+
const userId = String(user?.id || '').trim();
|
|
158
|
+
if (!userId) {
|
|
159
|
+
throw new Error('supabase-user-missing');
|
|
160
|
+
}
|
|
161
|
+
const query = new URLSearchParams({
|
|
162
|
+
select: 'user_id,product_key,plan,status,expires_at,updated_at',
|
|
163
|
+
user_id: `eq.${userId}`,
|
|
164
|
+
product_key: 'eq.livedesk',
|
|
165
|
+
limit: '1'
|
|
166
|
+
});
|
|
167
|
+
const entitlementResponse = await fetch(`${supabaseUrl}/rest/v1/livedesk_entitlements?${query}`, { headers });
|
|
168
|
+
if (!entitlementResponse.ok) {
|
|
169
|
+
throw new Error(`supabase-entitlement-query-failed:${entitlementResponse.status}`);
|
|
170
|
+
}
|
|
171
|
+
const rows = await entitlementResponse.json();
|
|
172
|
+
const entitlement = Array.isArray(rows) ? rows[0] : null;
|
|
173
|
+
const plan = entitlement?.plan === 'pro' ? 'pro' : entitlement?.plan === 'ltd' ? 'ltd' : 'free';
|
|
174
|
+
const status = entitlement?.status === 'active' ? 'active' : 'inactive';
|
|
175
|
+
verifiedLicense = {
|
|
176
|
+
userId,
|
|
177
|
+
plan,
|
|
178
|
+
status,
|
|
179
|
+
expiresAt: String(entitlement?.expires_at || ''),
|
|
180
|
+
verifiedAt: Date.now()
|
|
181
|
+
};
|
|
182
|
+
return licenseSnapshot();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function requireHubFeatureAccess(_req, res, next) {
|
|
186
|
+
if (hasHubFeatureAccess()) {
|
|
187
|
+
next();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
|
|
191
|
+
}
|
|
192
|
+
|
|
31
193
|
const remoteHub = createRemoteHub({
|
|
32
194
|
managerPackage: '@livedesk/hub',
|
|
33
195
|
managerVersion: packageInfo.version,
|
|
@@ -38,39 +200,125 @@ const remoteHub = createRemoteHub({
|
|
|
38
200
|
},
|
|
39
201
|
logEvent: (_scope, message) => console.log(`[LiveDesk Hub] ${message}`),
|
|
40
202
|
logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
|
|
203
|
+
emitEvent: handleRemoteHubEvent,
|
|
41
204
|
emitFrame: broadcastRemoteBinaryFrame,
|
|
42
205
|
emitAudio: broadcastRemoteBinaryAudio
|
|
43
206
|
});
|
|
207
|
+
const hubFilesystem = createHubFilesystem();
|
|
208
|
+
const hubTransferJobs = createHubTransferJobs({
|
|
209
|
+
filesystem: hubFilesystem,
|
|
210
|
+
remoteHub,
|
|
211
|
+
maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2)
|
|
212
|
+
});
|
|
213
|
+
const hubSharedFolders = createHubSharedFolders({
|
|
214
|
+
filesystem: hubFilesystem,
|
|
215
|
+
transferJobs: hubTransferJobs,
|
|
216
|
+
remoteHub
|
|
217
|
+
});
|
|
44
218
|
|
|
45
219
|
const app = express();
|
|
46
220
|
const httpServer = createServer(app);
|
|
47
|
-
const frameWss = new WebSocketServer({ noServer: true });
|
|
48
|
-
const
|
|
49
|
-
const
|
|
221
|
+
const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
222
|
+
const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
223
|
+
const inputWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
224
|
+
const audioWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
225
|
+
|
|
226
|
+
function normalizeHostname(value) {
|
|
227
|
+
return String(value || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isLoopbackHostname(value) {
|
|
231
|
+
const hostname = normalizeHostname(value);
|
|
232
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isPrivateHubHostname(value) {
|
|
236
|
+
const hostname = normalizeHostname(value);
|
|
237
|
+
if (isLoopbackHostname(hostname)
|
|
238
|
+
|| hostname === '0.0.0.0'
|
|
239
|
+
|| hostname.startsWith('10.')
|
|
240
|
+
|| hostname.startsWith('192.168.')
|
|
241
|
+
|| hostname.startsWith('169.254.')
|
|
242
|
+
|| hostname.startsWith('fc')
|
|
243
|
+
|| hostname.startsWith('fd')
|
|
244
|
+
|| hostname.startsWith('fe80:')) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
const match = /^(172)\.(\d{1,3})\./.exec(hostname);
|
|
248
|
+
return !!match && Number(match[2]) >= 16 && Number(match[2]) <= 31;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isLoopbackAddress(value) {
|
|
252
|
+
const address = String(value || '').trim().toLowerCase();
|
|
253
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function requestHostname(req) {
|
|
257
|
+
try {
|
|
258
|
+
return normalizeHostname(new URL(`http://${req.headers.host || '127.0.0.1'}`).hostname);
|
|
259
|
+
} catch {
|
|
260
|
+
return '';
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isTrustedBrowserRequest(req) {
|
|
265
|
+
const origin = String(req.headers.origin || '').trim();
|
|
266
|
+
if (!origin) {
|
|
267
|
+
const fetchSite = String(req.headers['sec-fetch-site'] || '').toLowerCase();
|
|
268
|
+
return isLoopbackAddress(req.socket?.remoteAddress)
|
|
269
|
+
|| fetchSite === 'same-origin'
|
|
270
|
+
|| fetchSite === 'none';
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const parsed = new URL(origin);
|
|
274
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
const originHost = normalizeHostname(parsed.hostname);
|
|
278
|
+
const targetHost = requestHostname(req);
|
|
279
|
+
return (originHost === targetHost && isPrivateHubHostname(targetHost))
|
|
280
|
+
|| (isLoopbackHostname(originHost) && isLoopbackHostname(targetHost));
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
50
285
|
|
|
51
286
|
app.use((req, res, next) => {
|
|
52
|
-
if (req
|
|
287
|
+
if (!isTrustedBrowserRequest(req)) {
|
|
288
|
+
res.status(403).json({ ok: false, error: 'untrusted-hub-origin' });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const origin = String(req.headers.origin || '').trim();
|
|
292
|
+
if (origin) {
|
|
293
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
53
294
|
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
295
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
|
|
296
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
54
297
|
res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
|
|
55
298
|
}
|
|
299
|
+
if (req.method === 'OPTIONS') {
|
|
300
|
+
res.status(204).end();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
56
303
|
next();
|
|
57
304
|
});
|
|
58
|
-
|
|
59
|
-
app.use(cors({
|
|
60
|
-
origin: true,
|
|
61
|
-
credentials: false,
|
|
62
|
-
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
|
63
|
-
allowedHeaders: ['Content-Type']
|
|
64
|
-
}));
|
|
65
305
|
app.use(express.json({ limit: '32mb' }));
|
|
66
306
|
|
|
67
307
|
const MAX_FILE_TRANSFER_FILES = 24;
|
|
68
308
|
const MAX_FILE_TRANSFER_BYTES = 24 * 1024 * 1024;
|
|
309
|
+
const MAX_FILE_TRANSFER_CHUNK_BYTES = 512 * 1024;
|
|
69
310
|
|
|
70
311
|
function noStore(res) {
|
|
71
312
|
res.setHeader('Cache-Control', 'no-store');
|
|
72
313
|
}
|
|
73
314
|
|
|
315
|
+
function sendFilesystemError(res, error) {
|
|
316
|
+
const normalized = toFilesystemHttpError(error);
|
|
317
|
+
const code = String(normalized.code || 'FILESYSTEM_ERROR').toLowerCase();
|
|
318
|
+
const status = Number(normalized.status || 500);
|
|
319
|
+
res.status(status).json({ ok: false, error: code });
|
|
320
|
+
}
|
|
321
|
+
|
|
74
322
|
function clampNumber(value, min, max, fallback) {
|
|
75
323
|
const number = Number(value);
|
|
76
324
|
if (!Number.isFinite(number)) {
|
|
@@ -79,6 +327,24 @@ function clampNumber(value, min, max, fallback) {
|
|
|
79
327
|
return Math.max(min, Math.min(max, Math.round(number)));
|
|
80
328
|
}
|
|
81
329
|
|
|
330
|
+
function normalizeMonitorIndex(value, fallback = 0) {
|
|
331
|
+
return clampNumber(value, 0, 63, fallback);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function normalizeMonitorSelections(value) {
|
|
335
|
+
const result = {};
|
|
336
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
for (const [deviceId, monitorIndex] of Object.entries(value)) {
|
|
340
|
+
const id = String(deviceId || '').trim();
|
|
341
|
+
if (id) {
|
|
342
|
+
result[id] = normalizeMonitorIndex(monitorIndex);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
82
348
|
function normalizeDeviceIds(value) {
|
|
83
349
|
const raw = Array.isArray(value)
|
|
84
350
|
? value
|
|
@@ -133,13 +399,55 @@ function normalizeTransferFiles(value) {
|
|
|
133
399
|
return { ok: true, files, totalBytes };
|
|
134
400
|
}
|
|
135
401
|
|
|
402
|
+
function normalizeTransferChunk(body = {}) {
|
|
403
|
+
const dataBase64 = String(body.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
|
|
404
|
+
const name = String(body.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
|
|
405
|
+
const relativePath = String(body.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
|
|
406
|
+
const offset = Math.max(0, Math.floor(Number(body.offset) || 0));
|
|
407
|
+
const totalBytes = Math.max(0, Math.floor(Number(body.totalBytes) || 0));
|
|
408
|
+
const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
|
|
409
|
+
const final = body.final === true;
|
|
410
|
+
|
|
411
|
+
if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
|
|
412
|
+
return { ok: false, error: 'invalid-file-transfer-chunk' };
|
|
413
|
+
}
|
|
414
|
+
if (byteLength > MAX_FILE_TRANSFER_CHUNK_BYTES) {
|
|
415
|
+
return { ok: false, error: 'file-transfer-chunk-too-large', byteLength };
|
|
416
|
+
}
|
|
417
|
+
if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
|
|
418
|
+
return { ok: false, error: 'empty-file-transfer-chunk' };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
ok: true,
|
|
423
|
+
chunk: {
|
|
424
|
+
name,
|
|
425
|
+
relativePath,
|
|
426
|
+
offset,
|
|
427
|
+
totalBytes,
|
|
428
|
+
byteLength,
|
|
429
|
+
dataBase64,
|
|
430
|
+
final,
|
|
431
|
+
mimeType: String(body.type || body.mimeType || '').slice(0, 160),
|
|
432
|
+
lastModified: Number(body.lastModified || 0) || 0
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
136
437
|
function normalizeLiveOptions(payload = {}) {
|
|
137
|
-
const mode = String(payload.frameMode || payload.mode || '
|
|
438
|
+
const mode = String(payload.frameMode || payload.mode || 'mode3-h264-hw').trim() || 'mode3-h264-hw';
|
|
439
|
+
const streamPurpose = String(payload.streamPurpose || payload.purpose || 'wall').trim().slice(0, 24) || 'wall';
|
|
440
|
+
const maxFps = streamPurpose === 'control' ? 60 : 30;
|
|
138
441
|
return {
|
|
139
|
-
fps: clampNumber(payload.fps, 1,
|
|
140
|
-
maxWidth: clampNumber(payload.maxWidth, 320,
|
|
141
|
-
maxHeight: clampNumber(payload.maxHeight, 180,
|
|
142
|
-
quality: clampNumber(payload.quality, 20, 95,
|
|
442
|
+
fps: clampNumber(payload.fps, 1, maxFps, 2),
|
|
443
|
+
maxWidth: clampNumber(payload.maxWidth, 320, 3840, 640),
|
|
444
|
+
maxHeight: clampNumber(payload.maxHeight, 180, 2160, 360),
|
|
445
|
+
quality: clampNumber(payload.quality, 20, 95, 45),
|
|
446
|
+
monitorIndex: normalizeMonitorIndex(payload.monitorIndex ?? payload.screenIndex ?? payload.displayIndex),
|
|
447
|
+
monitorSelections: normalizeMonitorSelections(payload.monitorSelections),
|
|
448
|
+
forceRestart: /^(1|true|yes|on)$/i.test(String(payload.forceRestart ?? '')),
|
|
449
|
+
reuseExisting: /^(1|true|yes|on)$/i.test(String(payload.reuseExisting ?? '')),
|
|
450
|
+
streamPurpose,
|
|
143
451
|
mode,
|
|
144
452
|
frameMode: mode
|
|
145
453
|
};
|
|
@@ -149,15 +457,226 @@ function sendJson(ws, payload) {
|
|
|
149
457
|
if (!ws || ws.readyState !== 1) {
|
|
150
458
|
return false;
|
|
151
459
|
}
|
|
152
|
-
ws
|
|
460
|
+
return safeWebSocketSend(ws, JSON.stringify(payload));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function forgetWebSocketClient(ws) {
|
|
464
|
+
unregisterFrameClient(ws);
|
|
465
|
+
frameClients.delete(ws);
|
|
466
|
+
audioClients.delete(ws);
|
|
467
|
+
inputClients.delete(ws);
|
|
468
|
+
try {
|
|
469
|
+
ws.terminate?.();
|
|
470
|
+
} catch {
|
|
471
|
+
// Ignore cleanup failures.
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function safeWebSocketSend(ws, payload, options = undefined) {
|
|
476
|
+
if (!ws || ws.readyState !== 1) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
ws.send(payload, options, err => {
|
|
482
|
+
if (err) {
|
|
483
|
+
forgetWebSocketClient(ws);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
return true;
|
|
487
|
+
} catch {
|
|
488
|
+
forgetWebSocketClient(ws);
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function unregisterFrameClient(ws) {
|
|
494
|
+
if (!ws) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const ids = ws.liveDeskDeviceIds instanceof Set ? ws.liveDeskDeviceIds : new Set();
|
|
498
|
+
for (const deviceId of ids) {
|
|
499
|
+
const clients = frameClientsByDeviceId.get(deviceId);
|
|
500
|
+
if (!clients) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
clients.delete(ws);
|
|
504
|
+
if (clients.size === 0) {
|
|
505
|
+
frameClientsByDeviceId.delete(deviceId);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
frameWildcardClients.delete(ws);
|
|
509
|
+
ws.liveDeskFrameSendLane?.queue?.splice?.(0);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function registerFrameClient(ws) {
|
|
513
|
+
if (!ws) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const ids = ws.liveDeskDeviceIds instanceof Set ? ws.liveDeskDeviceIds : new Set();
|
|
517
|
+
if (ids.size === 0) {
|
|
518
|
+
frameWildcardClients.add(ws);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
for (const deviceId of ids) {
|
|
522
|
+
let clients = frameClientsByDeviceId.get(deviceId);
|
|
523
|
+
if (!clients) {
|
|
524
|
+
clients = new Set();
|
|
525
|
+
frameClientsByDeviceId.set(deviceId, clients);
|
|
526
|
+
}
|
|
527
|
+
clients.add(ws);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function ensureFrameClientSendLane(ws) {
|
|
532
|
+
if (!ws.liveDeskFrameSendLane) {
|
|
533
|
+
ws.liveDeskFrameSendLane = {
|
|
534
|
+
queue: [],
|
|
535
|
+
draining: false,
|
|
536
|
+
dropped: 0,
|
|
537
|
+
awaitingKeyFrames: new Set()
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
return ws.liveDeskFrameSendLane;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function dropQueuedFrameForLane(lane) {
|
|
544
|
+
const deltaIndex = lane.queue.findIndex(item => item?.isKeyFrame !== true);
|
|
545
|
+
const dropIndex = deltaIndex >= 0 ? deltaIndex : 0;
|
|
546
|
+
const dropped = lane.queue.splice(dropIndex, 1);
|
|
547
|
+
lane.dropped += dropped.length;
|
|
548
|
+
const droppedItem = dropped[0];
|
|
549
|
+
if (droppedItem?.isH264 && droppedItem.deviceId) {
|
|
550
|
+
lane.awaitingKeyFrames.add(droppedItem.deviceId);
|
|
551
|
+
let recoveryKeySeen = false;
|
|
552
|
+
lane.queue = lane.queue.filter(item => {
|
|
553
|
+
if (!item?.isH264 || item.deviceId !== droppedItem.deviceId) {
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
if (item.isKeyFrame) {
|
|
557
|
+
recoveryKeySeen = true;
|
|
558
|
+
lane.awaitingKeyFrames.delete(item.deviceId);
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
if (recoveryKeySeen) {
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
lane.dropped += 1;
|
|
565
|
+
return false;
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function dropQueuedMode5FrameForLane(lane, deviceId) {
|
|
571
|
+
const dropIndex = lane.queue.findIndex(item => item?.isMode5 && item.deviceId === deviceId);
|
|
572
|
+
if (dropIndex < 0) {
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
lane.queue.splice(dropIndex, 1);
|
|
576
|
+
lane.dropped += 1;
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function drainFrameClientSendLane(ws) {
|
|
581
|
+
const lane = ws.liveDeskFrameSendLane;
|
|
582
|
+
if (!lane || lane.draining) {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
lane.draining = true;
|
|
586
|
+
setImmediate(() => {
|
|
587
|
+
lane.draining = false;
|
|
588
|
+
if (!ws || ws.readyState !== ws.OPEN) {
|
|
589
|
+
lane.queue.length = 0;
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let sent = 0;
|
|
594
|
+
while (lane.queue.length > 0 && sent < frameClientDrainBudgetPackets) {
|
|
595
|
+
if (ws.bufferedAmount > frameBackpressureBytes) {
|
|
596
|
+
ws.liveDeskFrameBackpressured = true;
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
const item = lane.queue.shift();
|
|
600
|
+
if (!item) {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
ws.liveDeskFrameBackpressured = false;
|
|
604
|
+
safeWebSocketSend(ws, item.packet, { binary: true });
|
|
605
|
+
sent += 1;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (lane.queue.length > 0 && ws.readyState === ws.OPEN) {
|
|
609
|
+
drainFrameClientSendLane(ws);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function enqueueFramePacketForClient(ws, packet, meta) {
|
|
615
|
+
if (!ws || ws.readyState !== ws.OPEN) {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
const lane = ensureFrameClientSendLane(ws);
|
|
619
|
+
if (meta.isH264 && meta.isKeyFrame) {
|
|
620
|
+
lane.awaitingKeyFrames.delete(meta.deviceId);
|
|
621
|
+
} else if (meta.isH264 && lane.awaitingKeyFrames.has(meta.deviceId)) {
|
|
622
|
+
lane.dropped += 1;
|
|
623
|
+
ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
const backpressureBytes = meta.isMode5 ? mode5FrameBackpressureBytes : frameBackpressureBytes;
|
|
627
|
+
if (ws.bufferedAmount > backpressureBytes) {
|
|
628
|
+
ws.liveDeskFrameBackpressured = true;
|
|
629
|
+
lane.dropped += 1;
|
|
630
|
+
if (meta.isH264 && meta.deviceId) {
|
|
631
|
+
lane.awaitingKeyFrames.add(meta.deviceId);
|
|
632
|
+
}
|
|
633
|
+
ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
if (meta.isMode5) {
|
|
637
|
+
let queuedMode5Packets = lane.queue.filter(item => item?.isMode5 && item.deviceId === meta.deviceId).length;
|
|
638
|
+
while (queuedMode5Packets >= mode5FrameQueuePackets) {
|
|
639
|
+
if (!dropQueuedMode5FrameForLane(lane, meta.deviceId)) {
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
queuedMode5Packets -= 1;
|
|
643
|
+
ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
while (lane.queue.length >= frameClientQueuePackets) {
|
|
647
|
+
dropQueuedFrameForLane(lane);
|
|
648
|
+
ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
|
|
649
|
+
}
|
|
650
|
+
lane.queue.push({
|
|
651
|
+
packet,
|
|
652
|
+
deviceId: meta.deviceId,
|
|
653
|
+
frameSeq: meta.frameSeq,
|
|
654
|
+
isH264: meta.isH264,
|
|
655
|
+
isMode5: meta.isMode5,
|
|
656
|
+
isKeyFrame: meta.isKeyFrame
|
|
657
|
+
});
|
|
658
|
+
drainFrameClientSendLane(ws);
|
|
153
659
|
return true;
|
|
154
660
|
}
|
|
155
661
|
|
|
156
662
|
function updateFrameSubscription(ws, payload = {}) {
|
|
663
|
+
const previousDeviceIds = ws.liveDeskDeviceIds instanceof Set
|
|
664
|
+
? new Set(ws.liveDeskDeviceIds)
|
|
665
|
+
: new Set();
|
|
666
|
+
unregisterFrameClient(ws);
|
|
157
667
|
const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
|
|
158
668
|
ws.liveDeskDeviceIds = new Set(deviceIds);
|
|
669
|
+
registerFrameClient(ws);
|
|
159
670
|
ws.liveDeskAutoStart = /^(1|true|yes|on|live)$/i.test(String(payload.autoStartLive ?? payload.startLive ?? ''));
|
|
160
671
|
ws.liveDeskLiveOptions = normalizeLiveOptions(payload);
|
|
672
|
+
if (!(ws.liveDeskStreamIdsByDeviceId instanceof Map)) {
|
|
673
|
+
ws.liveDeskStreamIdsByDeviceId = new Map();
|
|
674
|
+
}
|
|
675
|
+
for (const subscribedDeviceId of ws.liveDeskStreamIdsByDeviceId.keys()) {
|
|
676
|
+
if (!ws.liveDeskDeviceIds.has(subscribedDeviceId)) {
|
|
677
|
+
ws.liveDeskStreamIdsByDeviceId.delete(subscribedDeviceId);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
161
680
|
sendJson(ws, {
|
|
162
681
|
type: 'RemoteFrameSubscription',
|
|
163
682
|
timestamp: new Date().toISOString(),
|
|
@@ -165,37 +684,97 @@ function updateFrameSubscription(ws, payload = {}) {
|
|
|
165
684
|
autoStartLive: ws.liveDeskAutoStart
|
|
166
685
|
});
|
|
167
686
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
687
|
+
const newlySubscribedIds = deviceIds.filter(deviceId => !previousDeviceIds.has(deviceId));
|
|
688
|
+
const targetIds = newlySubscribedIds.length > 0 ? newlySubscribedIds : deviceIds;
|
|
689
|
+
for (const deviceId of targetIds.slice(0, 80)) {
|
|
690
|
+
startFrameSubscriptionLive(ws, 'subscribe', deviceId, { reuseExisting: true });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function startFrameSubscriptionLive(ws, reason = 'subscribe', onlyDeviceId = '', overrideLiveOptions = null) {
|
|
695
|
+
if (!hasHubFeatureAccess()) {
|
|
696
|
+
sendJson(ws, { type: 'RemoteFrameSubscriptionError', error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const subscribedIds = [...(ws.liveDeskDeviceIds || [])];
|
|
700
|
+
if (!ws.liveDeskAutoStart || subscribedIds.length === 0) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const targetIds = onlyDeviceId
|
|
705
|
+
? subscribedIds.filter(deviceId => deviceId === onlyDeviceId)
|
|
706
|
+
: subscribedIds;
|
|
707
|
+
if (targetIds.length === 0) {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const liveOptions = overrideLiveOptions && typeof overrideLiveOptions === 'object'
|
|
712
|
+
? { ...ws.liveDeskLiveOptions, ...overrideLiveOptions }
|
|
713
|
+
: ws.liveDeskLiveOptions;
|
|
714
|
+
const lookup = new Map(remoteHub.listDevices({ includeDataUrl: false }).map(device => [device.deviceId, device]));
|
|
715
|
+
const started = [];
|
|
716
|
+
const skipped = [];
|
|
717
|
+
for (const deviceId of targetIds.slice(0, 80)) {
|
|
718
|
+
const device = lookup.get(deviceId);
|
|
719
|
+
const liveCapable = device?.liveStreamEnabled === true
|
|
720
|
+
|| device?.liveStreamCapable === true
|
|
721
|
+
|| device?.capabilities?.liveStream === true
|
|
722
|
+
|| device?.capabilities?.stream === true
|
|
723
|
+
|| device?.capabilities?.screenStream === true;
|
|
724
|
+
if (!device?.connected || !liveCapable) {
|
|
725
|
+
skipped.push({ deviceId, reason: device?.connected ? 'live-unavailable' : 'not-connected' });
|
|
726
|
+
continue;
|
|
192
727
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
728
|
+
const monitorIndex = Object.prototype.hasOwnProperty.call(liveOptions.monitorSelections || {}, deviceId)
|
|
729
|
+
? liveOptions.monitorSelections[deviceId]
|
|
730
|
+
: liveOptions.monitorIndex;
|
|
731
|
+
const result = remoteHub.startLiveStream(deviceId, {
|
|
732
|
+
...liveOptions,
|
|
733
|
+
monitorIndex,
|
|
734
|
+
reuseExisting: liveOptions.forceRestart !== true
|
|
735
|
+
&& (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
|
|
736
|
+
silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
|
|
198
737
|
});
|
|
738
|
+
if (result?.ok) {
|
|
739
|
+
ws.liveDeskStreamIdsByDeviceId.set(deviceId, result.streamId);
|
|
740
|
+
started.push({ deviceId, streamId: result.streamId, fps: result.fps, reused: result.reused === true });
|
|
741
|
+
} else {
|
|
742
|
+
ws.liveDeskStreamIdsByDeviceId.delete(deviceId);
|
|
743
|
+
skipped.push({ deviceId, reason: result?.error || 'start-failed' });
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (reason === 'watchdog' && !started.some(item => item.reused !== true)) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
sendJson(ws, {
|
|
750
|
+
type: 'RemoteFrameLiveAutoStart',
|
|
751
|
+
timestamp: new Date().toISOString(),
|
|
752
|
+
reason,
|
|
753
|
+
started,
|
|
754
|
+
skipped: skipped.slice(0, 24)
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function restartFrameSubscriptionLive(ws, payload = {}) {
|
|
759
|
+
const requestedIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
|
|
760
|
+
const targetIds = requestedIds.length > 0
|
|
761
|
+
? requestedIds
|
|
762
|
+
: [...(ws.liveDeskDeviceIds || [])];
|
|
763
|
+
if (targetIds.length === 0) {
|
|
764
|
+
sendJson(ws, { type: 'RemoteFrameSubscriptionError', error: 'restart-device-missing' });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const restartOptions = {
|
|
768
|
+
...normalizeLiveOptions({
|
|
769
|
+
...ws.liveDeskLiveOptions,
|
|
770
|
+
...payload
|
|
771
|
+
}),
|
|
772
|
+
forceRestart: true,
|
|
773
|
+
restartToken: String(payload.restartToken || `${Date.now()}`).slice(0, 128),
|
|
774
|
+
restartReason: String(payload.restartReason || payload.reason || 'browser-stale').slice(0, 128)
|
|
775
|
+
};
|
|
776
|
+
for (const deviceId of targetIds.slice(0, 16)) {
|
|
777
|
+
startFrameSubscriptionLive(ws, restartOptions.restartReason, deviceId, restartOptions);
|
|
199
778
|
}
|
|
200
779
|
}
|
|
201
780
|
|
|
@@ -209,20 +788,26 @@ function updateAudioSubscription(ws, payload = {}) {
|
|
|
209
788
|
});
|
|
210
789
|
}
|
|
211
790
|
|
|
212
|
-
function buildRemoteFrameBinaryPacket(frameEvent) {
|
|
791
|
+
function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
|
|
213
792
|
const payload = frameEvent?.payload;
|
|
214
793
|
if (!Buffer.isBuffer(payload) || payload.length === 0) {
|
|
215
794
|
return null;
|
|
216
795
|
}
|
|
217
796
|
const frame = frameEvent.frame || {};
|
|
797
|
+
const hubPacketEpochMs = Date.now();
|
|
798
|
+
const hubPacketAt = new Date(hubPacketEpochMs).toISOString();
|
|
218
799
|
const metadata = {
|
|
219
800
|
type: 'remote.frame.binary',
|
|
220
801
|
kind: frameEvent.kind || 'live',
|
|
221
802
|
deviceId: frameEvent.deviceId || frame.deviceId || '',
|
|
222
803
|
frameSeq: Number(frame.frameSeq || 0) || 0,
|
|
804
|
+
streamFrameSeq: Number(frame.streamFrameSeq || 0) || 0,
|
|
223
805
|
streamId: frame.streamId || '',
|
|
806
|
+
commandId: frame.commandId || '',
|
|
224
807
|
width: Number(frame.width || 0) || 0,
|
|
225
808
|
height: Number(frame.height || 0) || 0,
|
|
809
|
+
sourceWidth: Number(frame.sourceWidth || 0) || 0,
|
|
810
|
+
sourceHeight: Number(frame.sourceHeight || 0) || 0,
|
|
226
811
|
mimeType: frameEvent.mimeType || frame.mimeType || frame.format || 'image/jpeg',
|
|
227
812
|
mode: frame.mode || '',
|
|
228
813
|
frameMode: frame.frameMode || frame.mode || '',
|
|
@@ -230,13 +815,52 @@ function buildRemoteFrameBinaryPacket(frameEvent) {
|
|
|
230
815
|
encoding: frame.encoding || '',
|
|
231
816
|
codec: frame.codec || '',
|
|
232
817
|
compression: frame.compression || frame.mimeType || frame.format || '',
|
|
818
|
+
pixelFormat: frame.pixelFormat || '',
|
|
819
|
+
bytesPerPixel: Number(frame.bytesPerPixel || 0) || 0,
|
|
820
|
+
uncompressedByteLength: Number(frame.uncompressedByteLength || 0) || 0,
|
|
821
|
+
h264Format: frame.h264Format || '',
|
|
822
|
+
isKeyFrame: frame.isKeyFrame === true,
|
|
823
|
+
chunkType: frame.chunkType || '',
|
|
824
|
+
timestampUs: Number(frame.timestampUs || 0) || 0,
|
|
825
|
+
durationUs: Number(frame.durationUs || 0) || 0,
|
|
826
|
+
sourceGapMs: Number(frame.sourceGapMs || 0) || 0,
|
|
827
|
+
agentSendGapMs: Number(frame.agentSendGapMs || 0) || 0,
|
|
828
|
+
agentSendDurationMs: Number(frame.agentSendDurationMs || 0) || 0,
|
|
829
|
+
agentSendDurationFrameSeq: Number(frame.agentSendDurationFrameSeq || 0) || 0,
|
|
830
|
+
agentPaceMs: Number(frame.agentPaceMs || 0) || 0,
|
|
831
|
+
agentQueueBeforePaceMs: Number(frame.agentQueueBeforePaceMs || 0) || 0,
|
|
832
|
+
agentFrameAgeMs: Number(frame.agentFrameAgeMs || 0) || 0,
|
|
833
|
+
droppedByAgent: Number(frame.droppedByAgent || 0) || 0,
|
|
834
|
+
hubIngressDropped: Number(frame.hubIngressDropped || 0) || 0,
|
|
835
|
+
hubClientDropped: Number(diagnostics.hubClientDropped || 0) || 0,
|
|
836
|
+
hardwareEncoder: frame.hardwareEncoder || '',
|
|
837
|
+
platformProfile: frame.platformProfile || '',
|
|
838
|
+
monitorIndex: Number.isFinite(Number(frame.monitorIndex)) ? Number(frame.monitorIndex) : 0,
|
|
839
|
+
monitorCount: Number.isFinite(Number(frame.monitorCount)) ? Number(frame.monitorCount) : 1,
|
|
233
840
|
fps: Number(frame.fps || 0) || 0,
|
|
234
841
|
capturedAt: frame.capturedAt || '',
|
|
235
842
|
receivedAt: frame.receivedAt || '',
|
|
843
|
+
hubPacketAt,
|
|
844
|
+
hubPacketEpochMs,
|
|
236
845
|
byteLength: Number(frameEvent.byteLength || payload.length) || payload.length,
|
|
237
846
|
contentHash: frame.contentHash || '',
|
|
238
847
|
captureMs: Number(frame.captureMs || 0) || 0,
|
|
239
|
-
|
|
848
|
+
captureStageMs: Number(frame.captureStageMs || 0) || 0,
|
|
849
|
+
convertMs: Number(frame.convertMs || 0) || 0,
|
|
850
|
+
compressMs: Number(frame.compressMs || 0) || 0,
|
|
851
|
+
captureSourceSerial: Number(frame.captureSourceSerial || 0) || 0,
|
|
852
|
+
captureSourceSkipped: Number(frame.captureSourceSkipped || 0) || 0,
|
|
853
|
+
deltaTileSize: Number(frame.deltaTileSize || 0) || 0,
|
|
854
|
+
deltaTileCount: Number(frame.deltaTileCount || 0) || 0,
|
|
855
|
+
deltaVisualTileCount: Number(frame.deltaVisualTileCount || 0) || 0,
|
|
856
|
+
deltaRefreshTileCount: Number(frame.deltaRefreshTileCount || 0) || 0,
|
|
857
|
+
deltaTotalTiles: Number(frame.deltaTotalTiles || 0) || 0,
|
|
858
|
+
sameContentStreak: Number(frame.sameContentStreak || 0) || 0,
|
|
859
|
+
layoutVersion: Number(frame.layoutVersion || 0) || 0,
|
|
860
|
+
atlasComposeMs: Number(frame.atlasComposeMs || 0) || 0,
|
|
861
|
+
atlasPoolStarved: Number(frame.atlasPoolStarved || 0) || 0,
|
|
862
|
+
atlasReadyTiles: Number(frame.atlasReadyTiles || 0) || 0,
|
|
863
|
+
atlasTileCount: Number(frame.atlasTileCount || 0) || 0
|
|
240
864
|
};
|
|
241
865
|
const metaBuffer = Buffer.from(JSON.stringify(metadata), 'utf8');
|
|
242
866
|
const header = Buffer.allocUnsafe(4);
|
|
@@ -269,29 +893,60 @@ function buildRemoteAudioBinaryPacket(audioEvent) {
|
|
|
269
893
|
}
|
|
270
894
|
|
|
271
895
|
function broadcastRemoteBinaryFrame(frameEvent) {
|
|
896
|
+
for (const session of atlasClients.values()) {
|
|
897
|
+
session.ingest(frameEvent);
|
|
898
|
+
}
|
|
899
|
+
if (!hasHubFeatureAccess()) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
272
902
|
const deviceId = String(frameEvent?.deviceId || frameEvent?.frame?.deviceId || '').trim();
|
|
273
903
|
if (!deviceId || frameClients.size === 0) {
|
|
274
904
|
return;
|
|
275
905
|
}
|
|
276
|
-
const
|
|
277
|
-
|
|
906
|
+
const targetClients = new Set([
|
|
907
|
+
...(frameClientsByDeviceId.get(deviceId) || []),
|
|
908
|
+
...frameWildcardClients
|
|
909
|
+
]);
|
|
910
|
+
if (targetClients.size === 0) {
|
|
278
911
|
return;
|
|
279
912
|
}
|
|
280
|
-
|
|
913
|
+
const frame = frameEvent.frame || {};
|
|
914
|
+
const isH264 = String(frame.codec || frame.mimeType || frame.format || '').toLowerCase().includes('h264')
|
|
915
|
+
|| String(frame.frameMode || frame.mode || '').toLowerCase() === 'mode3-h264-hw';
|
|
916
|
+
const isMode5 = String(frame.frameMode || frame.mode || '').toLowerCase() === 'mode5-lzo-delta';
|
|
917
|
+
const isKeyFrame = frame.isKeyFrame === true || String(frame.chunkType || '').toLowerCase() === 'key';
|
|
918
|
+
const frameSeq = Number(frame.frameSeq || 0) || 0;
|
|
919
|
+
for (const client of targetClients) {
|
|
281
920
|
if (client.readyState !== client.OPEN) {
|
|
282
921
|
continue;
|
|
283
922
|
}
|
|
284
|
-
|
|
923
|
+
const expectedStreamId = client.liveDeskStreamIdsByDeviceId instanceof Map
|
|
924
|
+
? client.liveDeskStreamIdsByDeviceId.get(deviceId)
|
|
925
|
+
: '';
|
|
926
|
+
if (expectedStreamId && String(frame.streamId || '') !== expectedStreamId) {
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
const lane = ensureFrameClientSendLane(client);
|
|
930
|
+
if (client.liveDeskFrameBackpressured && isH264 && !isKeyFrame) {
|
|
931
|
+
lane.dropped += 1;
|
|
932
|
+
lane.awaitingKeyFrames.add(deviceId);
|
|
933
|
+
client.liveDeskFrameBackpressureDrops = Number(client.liveDeskFrameBackpressureDrops || 0) + 1;
|
|
285
934
|
continue;
|
|
286
935
|
}
|
|
287
|
-
|
|
936
|
+
const packet = buildRemoteFrameBinaryPacket(frameEvent, {
|
|
937
|
+
hubClientDropped: lane.dropped
|
|
938
|
+
});
|
|
939
|
+
if (!packet) {
|
|
288
940
|
continue;
|
|
289
941
|
}
|
|
290
|
-
client
|
|
942
|
+
enqueueFramePacketForClient(client, packet, { deviceId, frameSeq, isH264, isMode5, isKeyFrame });
|
|
291
943
|
}
|
|
292
944
|
}
|
|
293
945
|
|
|
294
946
|
function broadcastRemoteBinaryAudio(audioEvent) {
|
|
947
|
+
if (!hasHubFeatureAccess()) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
295
950
|
const deviceId = String(audioEvent?.deviceId || audioEvent?.frame?.deviceId || '').trim();
|
|
296
951
|
if (!deviceId || audioClients.size === 0) {
|
|
297
952
|
return;
|
|
@@ -310,10 +965,125 @@ function broadcastRemoteBinaryAudio(audioEvent) {
|
|
|
310
965
|
if (client.bufferedAmount > 2 * 1024 * 1024) {
|
|
311
966
|
continue;
|
|
312
967
|
}
|
|
313
|
-
client
|
|
968
|
+
safeWebSocketSend(client, packet, { binary: true });
|
|
314
969
|
}
|
|
315
970
|
}
|
|
316
971
|
|
|
972
|
+
function sendMode4AtlasFrame(ws, output) {
|
|
973
|
+
if (!hasHubFeatureAccess() || ws.readyState !== ws.OPEN || !output?.payload?.length) return;
|
|
974
|
+
const metadata = output.metadata || {};
|
|
975
|
+
const frameSeq = Number(metadata.frameSeq || 0) || 0;
|
|
976
|
+
const isKeyFrame = metadata.isKeyFrame === true;
|
|
977
|
+
const workerGeneration = Math.max(1, Number(metadata.workerGeneration || 1) || 1);
|
|
978
|
+
const encoderGeneration = Math.max(1, Number(metadata.encoderGeneration || 1) || 1);
|
|
979
|
+
const streamId = `${ws.liveDeskAtlasStreamId}-w${workerGeneration}-e${encoderGeneration}`;
|
|
980
|
+
const frameEvent = {
|
|
981
|
+
kind: 'live',
|
|
982
|
+
deviceId: '__livedesk_mode4_atlas__',
|
|
983
|
+
mimeType: 'video/h264',
|
|
984
|
+
byteLength: output.payload.length,
|
|
985
|
+
payload: output.payload,
|
|
986
|
+
frame: {
|
|
987
|
+
deviceId: '__livedesk_mode4_atlas__',
|
|
988
|
+
frameSeq,
|
|
989
|
+
streamFrameSeq: Number(metadata.streamFrameSeq || frameSeq) || frameSeq,
|
|
990
|
+
streamId,
|
|
991
|
+
commandId: streamId,
|
|
992
|
+
width: Number(metadata.width || 1920),
|
|
993
|
+
height: Number(metadata.height || 1080),
|
|
994
|
+
sourceWidth: Number(metadata.width || 1920),
|
|
995
|
+
sourceHeight: Number(metadata.height || 1080),
|
|
996
|
+
mimeType: 'video/h264',
|
|
997
|
+
mode: 'mode4-h264-atlas',
|
|
998
|
+
frameMode: 'mode4-h264-atlas',
|
|
999
|
+
frameProfile: 'mode4-h264-atlas-v1',
|
|
1000
|
+
encoding: 'video-atlas',
|
|
1001
|
+
codec: 'h264',
|
|
1002
|
+
compression: 'video/h264',
|
|
1003
|
+
h264Format: 'annexb',
|
|
1004
|
+
isKeyFrame,
|
|
1005
|
+
chunkType: isKeyFrame ? 'key' : 'delta',
|
|
1006
|
+
timestampUs: Number(metadata.timestampUs || 0),
|
|
1007
|
+
durationUs: Number(metadata.durationUs || 0),
|
|
1008
|
+
fps: Number(metadata.fps || 20),
|
|
1009
|
+
capturedAt: new Date().toISOString(),
|
|
1010
|
+
receivedAt: new Date().toISOString(),
|
|
1011
|
+
hardwareEncoder: String(metadata.hardwareEncoder || ''),
|
|
1012
|
+
platformProfile: 'mode4-hub-atlas-worker',
|
|
1013
|
+
workerGeneration,
|
|
1014
|
+
encoderGeneration,
|
|
1015
|
+
layoutVersion: Number(metadata.layoutVersion || 0),
|
|
1016
|
+
atlasComposeMs: Number(metadata.composeMs || 0),
|
|
1017
|
+
atlasPoolStarved: Number(metadata.poolStarved || 0),
|
|
1018
|
+
atlasReadyTiles: Number(metadata.readyTileCount || 0),
|
|
1019
|
+
atlasTileCount: Number(metadata.tileCount || 0)
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
const lane = ensureFrameClientSendLane(ws);
|
|
1023
|
+
if (ws.liveDeskFrameBackpressured && !isKeyFrame) {
|
|
1024
|
+
lane.dropped += 1;
|
|
1025
|
+
lane.awaitingKeyFrames.add('__livedesk_mode4_atlas__');
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const packet = buildRemoteFrameBinaryPacket(frameEvent, { hubClientDropped: lane.dropped });
|
|
1029
|
+
if (packet) {
|
|
1030
|
+
enqueueFramePacketForClient(ws, packet, {
|
|
1031
|
+
deviceId: '__livedesk_mode4_atlas__',
|
|
1032
|
+
frameSeq,
|
|
1033
|
+
isH264: true,
|
|
1034
|
+
isKeyFrame
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function configureMode4Atlas(ws, payload = {}) {
|
|
1040
|
+
const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId).slice(0, 100);
|
|
1041
|
+
const monitorSelections = normalizeMonitorSelections(payload.monitorSelections);
|
|
1042
|
+
const sharpTiles = Number(payload.tileWidth) >= 480 || Number(payload.tileHeight) >= 270;
|
|
1043
|
+
const tileWidth = sharpTiles ? 480 : 320;
|
|
1044
|
+
const tileHeight = sharpTiles ? 270 : 180;
|
|
1045
|
+
ws.liveDeskAtlasDeviceIds = new Set(deviceIds);
|
|
1046
|
+
ws.liveDeskAtlasInputOptions = { tileWidth, tileHeight, monitorSelections };
|
|
1047
|
+
ws.liveDeskAtlasSession.configure({
|
|
1048
|
+
deviceIds,
|
|
1049
|
+
width: clampNumber(payload.width, 640, 3840, 1920),
|
|
1050
|
+
height: clampNumber(payload.height, 360, 2160, 1080),
|
|
1051
|
+
tileWidth,
|
|
1052
|
+
tileHeight,
|
|
1053
|
+
fps: clampNumber(payload.fps, 1, 30, 20)
|
|
1054
|
+
});
|
|
1055
|
+
for (const deviceId of deviceIds) {
|
|
1056
|
+
startMode4AtlasInput(ws, deviceId, 'atlas-configure');
|
|
1057
|
+
}
|
|
1058
|
+
sendJson(ws, {
|
|
1059
|
+
type: 'Mode4AtlasSubscription',
|
|
1060
|
+
deviceIds,
|
|
1061
|
+
inputMode: 'mode2-lzo',
|
|
1062
|
+
outputMode: 'mode4-h264-atlas',
|
|
1063
|
+
tileWidth,
|
|
1064
|
+
tileHeight,
|
|
1065
|
+
timestamp: new Date().toISOString()
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function startMode4AtlasInput(ws, deviceId, reason = 'atlas-configure') {
|
|
1070
|
+
const options = ws?.liveDeskAtlasInputOptions;
|
|
1071
|
+
if (!options || !ws.liveDeskAtlasDeviceIds?.has(deviceId)) return { ok: false, error: 'atlas-device-not-configured' };
|
|
1072
|
+
return remoteHub.startLiveStream(deviceId, {
|
|
1073
|
+
fps: 8,
|
|
1074
|
+
maxWidth: options.tileWidth,
|
|
1075
|
+
maxHeight: options.tileHeight,
|
|
1076
|
+
quality: 60,
|
|
1077
|
+
mode: 'mode2-lzo',
|
|
1078
|
+
frameMode: 'mode2-lzo',
|
|
1079
|
+
streamPurpose: 'atlas',
|
|
1080
|
+
monitorIndex: Number(options.monitorSelections?.[deviceId] || 0),
|
|
1081
|
+
reuseExisting: false,
|
|
1082
|
+
silentReuse: true,
|
|
1083
|
+
restartReason: reason
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
|
|
317
1087
|
function wantsBinaryFrame(req) {
|
|
318
1088
|
return /^(1|true|yes|on|binary|raw)$/i.test(String(req.query?.binary ?? req.query?.raw ?? ''))
|
|
319
1089
|
|| /image\//i.test(String(req.headers.accept || ''));
|
|
@@ -350,13 +1120,69 @@ app.get('/api/health', (_req, res) => {
|
|
|
350
1120
|
|
|
351
1121
|
app.get('/api/remote/status', (_req, res) => {
|
|
352
1122
|
noStore(res);
|
|
1123
|
+
const secretStatus = remoteHub.getStatus({ includeSecrets: true });
|
|
353
1124
|
res.json({
|
|
354
|
-
...remoteHub.getStatus({ includeSecrets:
|
|
1125
|
+
...remoteHub.getStatus({ includeSecrets: false }),
|
|
1126
|
+
pairingPin: secretStatus.pairingPin,
|
|
355
1127
|
product: 'LiveDesk',
|
|
356
1128
|
agentPackage: '@livedesk/client'
|
|
357
1129
|
});
|
|
358
1130
|
});
|
|
359
1131
|
|
|
1132
|
+
app.get('/api/remote/registry-credentials', (_req, res) => {
|
|
1133
|
+
noStore(res);
|
|
1134
|
+
const status = remoteHub.getStatus({ includeSecrets: true });
|
|
1135
|
+
res.json({
|
|
1136
|
+
pairToken: status.pairToken,
|
|
1137
|
+
pairingPin: status.pairingPin,
|
|
1138
|
+
pairingPinUpdatedAt: status.pairingPinUpdatedAt
|
|
1139
|
+
});
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
app.get('/api/remote/license', (_req, res) => {
|
|
1143
|
+
noStore(res);
|
|
1144
|
+
res.json(licenseSnapshot());
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
app.post('/api/remote/license/sync', async (req, res) => {
|
|
1148
|
+
noStore(res);
|
|
1149
|
+
try {
|
|
1150
|
+
const authorization = String(req.headers.authorization || '');
|
|
1151
|
+
const accessToken = authorization.replace(/^Bearer\s+/i, '').trim();
|
|
1152
|
+
res.json(await syncVerifiedLicense(accessToken));
|
|
1153
|
+
} catch (err) {
|
|
1154
|
+
verifiedLicense = {
|
|
1155
|
+
userId: '',
|
|
1156
|
+
plan: 'free',
|
|
1157
|
+
status: 'inactive',
|
|
1158
|
+
expiresAt: '',
|
|
1159
|
+
verifiedAt: Date.now()
|
|
1160
|
+
};
|
|
1161
|
+
res.status(401).json({ ok: false, error: err instanceof Error ? err.message : String(err), license: licenseSnapshot() });
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
app.delete('/api/remote/license', (_req, res) => {
|
|
1166
|
+
noStore(res);
|
|
1167
|
+
verifiedLicense = {
|
|
1168
|
+
userId: '',
|
|
1169
|
+
plan: 'free',
|
|
1170
|
+
status: 'inactive',
|
|
1171
|
+
expiresAt: '',
|
|
1172
|
+
verifiedAt: Date.now()
|
|
1173
|
+
};
|
|
1174
|
+
res.json(licenseSnapshot());
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
app.post('/api/remote/pairing-pin', (_req, res) => {
|
|
1178
|
+
noStore(res);
|
|
1179
|
+
try {
|
|
1180
|
+
res.json(remoteHub.rotatePairingPin());
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
|
|
360
1186
|
app.get('/api/remote/devices', (req, res) => {
|
|
361
1187
|
noStore(res);
|
|
362
1188
|
const includeDataUrl = /^(1|true|yes|on|data|base64)$/i.test(String(req.query?.includeDataUrl ?? ''));
|
|
@@ -438,12 +1264,138 @@ app.post('/api/remote/devices/:deviceId/ping', (req, res) => {
|
|
|
438
1264
|
}));
|
|
439
1265
|
});
|
|
440
1266
|
|
|
441
|
-
app.post('/api/remote/devices/:deviceId/input', (req, res) => {
|
|
1267
|
+
app.post('/api/remote/devices/:deviceId/input', requireHubFeatureAccess, (req, res) => {
|
|
442
1268
|
noStore(res);
|
|
443
1269
|
res.json(remoteHub.sendInputControl(req.params.deviceId, req.body || {}));
|
|
444
1270
|
});
|
|
445
1271
|
|
|
446
|
-
app.
|
|
1272
|
+
app.get('/api/remote/filesystem/roots', requireHubFeatureAccess, async (req, res) => {
|
|
1273
|
+
noStore(res);
|
|
1274
|
+
try {
|
|
1275
|
+
res.json(await hubFilesystem.getRoots({ refresh: /^(1|true|yes|on)$/i.test(String(req.query?.refresh || '')) }));
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
sendFilesystemError(res, error);
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
app.get('/api/remote/filesystem/entries', requireHubFeatureAccess, async (req, res) => {
|
|
1282
|
+
noStore(res);
|
|
1283
|
+
try {
|
|
1284
|
+
res.json(await hubFilesystem.getEntries(req.query?.id));
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
sendFilesystemError(res, error);
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
app.post('/api/remote/filesystem/selection/scan', requireHubFeatureAccess, async (req, res) => {
|
|
1291
|
+
noStore(res);
|
|
1292
|
+
try {
|
|
1293
|
+
const scan = await hubFilesystem.scan(req.body?.itemIds);
|
|
1294
|
+
res.json({ files: scan.files.length, folders: scan.folders, totalBytes: scan.totalBytes });
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
sendFilesystemError(res, error);
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
app.post('/api/remote/files/from-hub', requireHubFeatureAccess, (req, res) => {
|
|
1301
|
+
noStore(res);
|
|
1302
|
+
try {
|
|
1303
|
+
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
1304
|
+
if (deviceIds.length === 0) {
|
|
1305
|
+
res.status(400).json({ ok: false, error: 'no-target-devices' });
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
const itemIds = Array.isArray(req.body?.itemIds) ? req.body.itemIds : [];
|
|
1309
|
+
if (itemIds.length === 0) {
|
|
1310
|
+
res.status(400).json({ ok: false, error: 'no-filesystem-items-selected' });
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
const job = hubTransferJobs.create({
|
|
1314
|
+
itemIds,
|
|
1315
|
+
deviceIds,
|
|
1316
|
+
remoteDirectory: req.body?.remoteDirectory
|
|
1317
|
+
});
|
|
1318
|
+
res.status(202).json({ ok: true, jobId: job.jobId, state: job.state });
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
sendFilesystemError(res, error);
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
app.get('/api/remote/files/jobs', requireHubFeatureAccess, (req, res) => {
|
|
1325
|
+
noStore(res);
|
|
1326
|
+
res.json({ jobs: hubTransferJobs.list({ activeOnly: /^(1|true|yes|on)$/i.test(String(req.query?.active || '')) }) });
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
app.get('/api/remote/files/jobs/:jobId', requireHubFeatureAccess, (req, res) => {
|
|
1330
|
+
noStore(res);
|
|
1331
|
+
const job = hubTransferJobs.get(req.params.jobId);
|
|
1332
|
+
if (!job) {
|
|
1333
|
+
res.status(404).json({ ok: false, error: 'transfer-job-not-found' });
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
res.json(job);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
app.post('/api/remote/files/jobs/:jobId/cancel', requireHubFeatureAccess, (req, res) => {
|
|
1340
|
+
noStore(res);
|
|
1341
|
+
const job = hubTransferJobs.cancel(req.params.jobId);
|
|
1342
|
+
if (!job) {
|
|
1343
|
+
res.status(404).json({ ok: false, error: 'transfer-job-not-found' });
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
res.json({ ok: true, ...job });
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
app.get('/api/remote/filesystem/shared-folders', requireHubFeatureAccess, async (_req, res) => {
|
|
1350
|
+
noStore(res);
|
|
1351
|
+
try {
|
|
1352
|
+
res.json({ folders: await hubSharedFolders.list() });
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
sendFilesystemError(res, error);
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
app.post('/api/remote/filesystem/shared-folders', requireHubFeatureAccess, async (req, res) => {
|
|
1359
|
+
noStore(res);
|
|
1360
|
+
try {
|
|
1361
|
+
res.status(201).json({ ok: true, folder: await hubSharedFolders.add(req.body?.sourceId) });
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
sendFilesystemError(res, error);
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
app.patch('/api/remote/filesystem/shared-folders/:folderId', requireHubFeatureAccess, async (req, res) => {
|
|
1368
|
+
noStore(res);
|
|
1369
|
+
try {
|
|
1370
|
+
res.json({ ok: true, folder: await hubSharedFolders.update(req.params.folderId, req.body || {}) });
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
sendFilesystemError(res, error);
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
app.delete('/api/remote/filesystem/shared-folders/:folderId', requireHubFeatureAccess, async (req, res) => {
|
|
1377
|
+
noStore(res);
|
|
1378
|
+
try {
|
|
1379
|
+
res.json(await hubSharedFolders.remove(req.params.folderId));
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
sendFilesystemError(res, error);
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatureAccess, async (req, res) => {
|
|
1386
|
+
noStore(res);
|
|
1387
|
+
try {
|
|
1388
|
+
res.status(202).json(await hubSharedFolders.sync(
|
|
1389
|
+
req.params.folderId,
|
|
1390
|
+
normalizeDeviceIds(req.body?.deviceIds),
|
|
1391
|
+
req.body?.remoteDirectory
|
|
1392
|
+
));
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
sendFilesystemError(res, error);
|
|
1395
|
+
}
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
|
|
447
1399
|
noStore(res);
|
|
448
1400
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
449
1401
|
if (deviceIds.length === 0) {
|
|
@@ -484,7 +1436,49 @@ app.post('/api/remote/files/transfer', (req, res) => {
|
|
|
484
1436
|
});
|
|
485
1437
|
});
|
|
486
1438
|
|
|
487
|
-
app.post('/api/remote/
|
|
1439
|
+
app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
|
|
1440
|
+
noStore(res);
|
|
1441
|
+
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
1442
|
+
if (deviceIds.length === 0) {
|
|
1443
|
+
res.status(400).json({ ok: false, error: 'no-target-devices' });
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const normalized = normalizeTransferChunk(req.body || {});
|
|
1447
|
+
if (!normalized.ok) {
|
|
1448
|
+
res.status(400).json({ ok: false, error: normalized.error, byteLength: normalized.byteLength || 0 });
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
1453
|
+
const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
|
|
1454
|
+
const queuedAt = new Date().toISOString();
|
|
1455
|
+
const results = deviceIds.map(deviceId => ({
|
|
1456
|
+
deviceId,
|
|
1457
|
+
...remoteHub.sendCommand(deviceId, {
|
|
1458
|
+
command: 'file.transfer.chunk',
|
|
1459
|
+
payload: {
|
|
1460
|
+
transferId,
|
|
1461
|
+
remoteDirectory,
|
|
1462
|
+
...normalized.chunk,
|
|
1463
|
+
requestedAt: queuedAt
|
|
1464
|
+
}
|
|
1465
|
+
})
|
|
1466
|
+
}));
|
|
1467
|
+
const queued = results.filter(result => result.ok).length;
|
|
1468
|
+
res.json({
|
|
1469
|
+
ok: queued > 0,
|
|
1470
|
+
transferId,
|
|
1471
|
+
queued,
|
|
1472
|
+
total: deviceIds.length,
|
|
1473
|
+
byteLength: normalized.chunk.byteLength,
|
|
1474
|
+
offset: normalized.chunk.offset,
|
|
1475
|
+
final: normalized.chunk.final,
|
|
1476
|
+
results,
|
|
1477
|
+
error: queued > 0 ? undefined : 'no-transfer-queued'
|
|
1478
|
+
});
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
app.post('/api/remote/devices/:deviceId/tasks', requireHubFeatureAccess, (req, res) => {
|
|
488
1482
|
noStore(res);
|
|
489
1483
|
res.json(remoteHub.requestAgentTask(req.params.deviceId, {
|
|
490
1484
|
instruction: req.body?.instruction,
|
|
@@ -496,7 +1490,7 @@ app.post('/api/remote/devices/:deviceId/tasks', (req, res) => {
|
|
|
496
1490
|
}));
|
|
497
1491
|
});
|
|
498
1492
|
|
|
499
|
-
app.post('/api/remote/tasks', (req, res) => {
|
|
1493
|
+
app.post('/api/remote/tasks', requireHubFeatureAccess, (req, res) => {
|
|
500
1494
|
noStore(res);
|
|
501
1495
|
const requestedIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
502
1496
|
const approvalLevel = req.body?.approvalLevel === 'ai-assist' ? 'ai-assist' : 'task-only';
|
|
@@ -545,7 +1539,7 @@ app.post('/api/remote/devices/:deviceId/thumbnail/request', (req, res) => {
|
|
|
545
1539
|
res.json(remoteHub.requestThumbnail(req.params.deviceId, req.body || {}));
|
|
546
1540
|
});
|
|
547
1541
|
|
|
548
|
-
app.get('/api/remote/devices/:deviceId/live/frame', (req, res) => {
|
|
1542
|
+
app.get('/api/remote/devices/:deviceId/live/frame', requireHubFeatureAccess, (req, res) => {
|
|
549
1543
|
noStore(res);
|
|
550
1544
|
if (wantsBinaryFrame(req)) {
|
|
551
1545
|
sendFrameBinary(req, res, 'live');
|
|
@@ -561,7 +1555,7 @@ app.get('/api/remote/devices/:deviceId/live/frame', (req, res) => {
|
|
|
561
1555
|
res.json({ ok: true, frame });
|
|
562
1556
|
});
|
|
563
1557
|
|
|
564
|
-
app.post('/api/remote/devices/:deviceId/live/start', (req, res) => {
|
|
1558
|
+
app.post('/api/remote/devices/:deviceId/live/start', requireHubFeatureAccess, (req, res) => {
|
|
565
1559
|
noStore(res);
|
|
566
1560
|
res.json(remoteHub.startLiveStream(req.params.deviceId, req.body || {}));
|
|
567
1561
|
});
|
|
@@ -571,7 +1565,7 @@ app.post('/api/remote/devices/:deviceId/live/stop', (req, res) => {
|
|
|
571
1565
|
res.json(remoteHub.stopLiveStream(req.params.deviceId, req.body || {}));
|
|
572
1566
|
});
|
|
573
1567
|
|
|
574
|
-
app.post('/api/remote/devices/:deviceId/audio/start', (req, res) => {
|
|
1568
|
+
app.post('/api/remote/devices/:deviceId/audio/start', requireHubFeatureAccess, (req, res) => {
|
|
575
1569
|
noStore(res);
|
|
576
1570
|
res.json(remoteHub.startAudioStream(req.params.deviceId, req.body || {}));
|
|
577
1571
|
});
|
|
@@ -597,12 +1591,21 @@ if (existsSync(webIndexPath)) {
|
|
|
597
1591
|
}
|
|
598
1592
|
|
|
599
1593
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
1594
|
+
if (!isTrustedBrowserRequest(req)) {
|
|
1595
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
1596
|
+
socket.destroy();
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
600
1599
|
try {
|
|
601
1600
|
const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
602
1601
|
if (parsed.pathname === '/api/remote/frames/ws') {
|
|
603
1602
|
frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
|
|
604
1603
|
return;
|
|
605
1604
|
}
|
|
1605
|
+
if (parsed.pathname === '/api/remote/atlas/ws') {
|
|
1606
|
+
atlasWss.handleUpgrade(req, socket, head, ws => atlasWss.emit('connection', ws, req));
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
606
1609
|
if (parsed.pathname === '/api/remote/input/ws') {
|
|
607
1610
|
inputWss.handleUpgrade(req, socket, head, ws => inputWss.emit('connection', ws, req));
|
|
608
1611
|
return;
|
|
@@ -622,6 +1625,15 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
622
1625
|
frameWss.on('connection', (ws, req) => {
|
|
623
1626
|
frameClients.add(ws);
|
|
624
1627
|
ws.liveDeskFrameClientId = `rfws-${++frameClientSeq}`;
|
|
1628
|
+
try {
|
|
1629
|
+
ws._socket?.setNoDelay?.(true);
|
|
1630
|
+
} catch {
|
|
1631
|
+
// Best-effort latency hint for browser frame sockets.
|
|
1632
|
+
}
|
|
1633
|
+
const cleanup = () => {
|
|
1634
|
+
unregisterFrameClient(ws);
|
|
1635
|
+
frameClients.delete(ws);
|
|
1636
|
+
};
|
|
625
1637
|
try {
|
|
626
1638
|
const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
627
1639
|
updateFrameSubscription(ws, { deviceIds: parsed.searchParams.get('devices') || '' });
|
|
@@ -633,13 +1645,15 @@ frameWss.on('connection', (ws, req) => {
|
|
|
633
1645
|
const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
634
1646
|
if (payload?.type === 'subscribe') {
|
|
635
1647
|
updateFrameSubscription(ws, payload);
|
|
1648
|
+
} else if (payload?.type === 'restart-live') {
|
|
1649
|
+
restartFrameSubscriptionLive(ws, payload);
|
|
636
1650
|
}
|
|
637
1651
|
} catch {
|
|
638
1652
|
sendJson(ws, { type: 'RemoteFrameSubscriptionError', error: 'invalid-message' });
|
|
639
1653
|
}
|
|
640
1654
|
});
|
|
641
|
-
ws.on('close',
|
|
642
|
-
ws.on('error',
|
|
1655
|
+
ws.on('close', cleanup);
|
|
1656
|
+
ws.on('error', cleanup);
|
|
643
1657
|
sendJson(ws, {
|
|
644
1658
|
type: 'RemoteFrameSocketReady',
|
|
645
1659
|
protocol: 'livedesk.remote.frames.binary.v1',
|
|
@@ -648,6 +1662,52 @@ frameWss.on('connection', (ws, req) => {
|
|
|
648
1662
|
});
|
|
649
1663
|
});
|
|
650
1664
|
|
|
1665
|
+
atlasWss.on('connection', ws => {
|
|
1666
|
+
ws.liveDeskFrameClientId = `atlas-${++frameClientSeq}`;
|
|
1667
|
+
ws.liveDeskAtlasStreamId = `mode4-atlas-${crypto.randomUUID()}`;
|
|
1668
|
+
try { ws._socket?.setNoDelay?.(true); } catch {}
|
|
1669
|
+
const session = new Mode4AtlasSession({
|
|
1670
|
+
onFrame: output => sendMode4AtlasFrame(ws, output),
|
|
1671
|
+
onLayout: layout => sendJson(ws, { ...layout, type: 'Mode4AtlasLayout' }),
|
|
1672
|
+
onStatus: status => {
|
|
1673
|
+
if (status?.type === 'log') {
|
|
1674
|
+
const logger = status.level === 'warn' ? console.warn : console.log;
|
|
1675
|
+
logger(`[LiveDesk Hub] ${status.message}`);
|
|
1676
|
+
}
|
|
1677
|
+
sendJson(ws, { ...status, type: 'Mode4AtlasStatus' });
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
ws.liveDeskAtlasSession = session;
|
|
1681
|
+
atlasClients.set(ws, session);
|
|
1682
|
+
const cleanup = () => {
|
|
1683
|
+
atlasClients.delete(ws);
|
|
1684
|
+
session.close();
|
|
1685
|
+
const lane = ws.liveDeskFrameSendLane;
|
|
1686
|
+
if (lane) {
|
|
1687
|
+
lane.queue.length = 0;
|
|
1688
|
+
lane.awaitingKeyFrames.clear();
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
ws.on('message', data => {
|
|
1692
|
+
try {
|
|
1693
|
+
const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
1694
|
+
if (payload?.type === 'subscribe' || payload?.type === 'configure') {
|
|
1695
|
+
configureMode4Atlas(ws, payload);
|
|
1696
|
+
}
|
|
1697
|
+
} catch {
|
|
1698
|
+
sendJson(ws, { type: 'Mode4AtlasError', error: 'invalid-message' });
|
|
1699
|
+
}
|
|
1700
|
+
});
|
|
1701
|
+
ws.on('close', cleanup);
|
|
1702
|
+
ws.on('error', cleanup);
|
|
1703
|
+
sendJson(ws, {
|
|
1704
|
+
type: 'Mode4AtlasSocketReady',
|
|
1705
|
+
protocol: 'livedesk.remote.atlas.h264.v1',
|
|
1706
|
+
clientId: ws.liveDeskFrameClientId,
|
|
1707
|
+
timestamp: new Date().toISOString()
|
|
1708
|
+
});
|
|
1709
|
+
});
|
|
1710
|
+
|
|
651
1711
|
audioWss.on('connection', (ws, req) => {
|
|
652
1712
|
audioClients.add(ws);
|
|
653
1713
|
ws.liveDeskAudioClientId = `raws-${++audioClientSeq}`;
|
|
@@ -681,6 +1741,10 @@ inputWss.on('connection', ws => {
|
|
|
681
1741
|
inputClients.add(ws);
|
|
682
1742
|
ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
|
|
683
1743
|
ws.on('message', data => {
|
|
1744
|
+
if (!hasHubFeatureAccess()) {
|
|
1745
|
+
sendJson(ws, { type: 'RemoteInputError', error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
684
1748
|
let payload;
|
|
685
1749
|
try {
|
|
686
1750
|
payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
@@ -689,8 +1753,15 @@ inputWss.on('connection', ws => {
|
|
|
689
1753
|
return;
|
|
690
1754
|
}
|
|
691
1755
|
const deviceId = String(payload?.deviceId || '').trim();
|
|
692
|
-
const input = payload?.input && typeof payload.input === 'object'
|
|
1756
|
+
const input = payload?.input && typeof payload.input === 'object'
|
|
1757
|
+
? { ...payload.input, hubReceivedAtEpochMs: Date.now() }
|
|
1758
|
+
: { ...payload, hubReceivedAtEpochMs: Date.now() };
|
|
693
1759
|
const result = remoteHub.sendInputControl(deviceId, input);
|
|
1760
|
+
const fireAndForget = payload?.fireAndForget === true
|
|
1761
|
+
|| String(input?.type || '').toLowerCase() === 'pointermove';
|
|
1762
|
+
if (fireAndForget && result?.ok) {
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
694
1765
|
sendJson(ws, {
|
|
695
1766
|
type: result?.ok ? 'RemoteInputQueued' : 'RemoteInputError',
|
|
696
1767
|
requestId: payload?.requestId || '',
|
|
@@ -710,14 +1781,24 @@ inputWss.on('connection', ws => {
|
|
|
710
1781
|
});
|
|
711
1782
|
|
|
712
1783
|
await remoteHub.start();
|
|
1784
|
+
connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
|
|
1785
|
+
hubSharedFolders.startAutoSync(
|
|
1786
|
+
() => remoteHub.listDevices({ includeDataUrl: false }).filter(device => device.connected).map(device => device.deviceId),
|
|
1787
|
+
() => process.env.LIVEDESK_REMOTE_DIRECTORY || 'Desktop/LiveDeskFiles'
|
|
1788
|
+
);
|
|
713
1789
|
httpServer.listen(httpPort, httpHost, () => {
|
|
714
1790
|
const status = remoteHub.getStatus({ includeSecrets: true });
|
|
1791
|
+
const managerVersion = String(process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version || 'dev');
|
|
1792
|
+
console.log(`[LiveDesk Hub] Version ${managerVersion}`);
|
|
715
1793
|
console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
|
|
716
|
-
console.log(`[LiveDesk Hub]
|
|
1794
|
+
console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
|
|
717
1795
|
});
|
|
718
1796
|
|
|
719
1797
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
720
1798
|
process.once(signal, async () => {
|
|
1799
|
+
for (const session of atlasClients.values()) session.close();
|
|
1800
|
+
atlasClients.clear();
|
|
1801
|
+
hubSharedFolders.close();
|
|
721
1802
|
await remoteHub.close();
|
|
722
1803
|
httpServer.close(() => process.exit(0));
|
|
723
1804
|
});
|