@livedesk/hub 0.1.42 → 0.1.43

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -11,7 +11,7 @@
11
11
  "scripts": {
12
12
  "dev": "node src/server.js",
13
13
  "start": "node src/server.js",
14
- "check": "node --check src/server.js && node --check src/remote-hub.js",
14
+ "check": "node --check src/server.js && node --check src/remote-hub.js && node --check src/console-relay.js",
15
15
  "prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
16
16
  },
17
17
  "dependencies": {
@@ -0,0 +1,415 @@
1
+ import WebSocket from 'ws';
2
+
3
+ const DEFAULT_CONSOLE_RELAY_URL = 'https://livedesk-wake.lovecrdm.workers.dev';
4
+ const DEFAULT_RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 20_000];
5
+ const MAX_CHANNELS = 32;
6
+ const MAX_HTTP_REQUEST_BYTES = 1024 * 1024;
7
+ const MAX_HTTP_RESPONSE_BYTES = 4 * 1024 * 1024;
8
+ const MAX_RELAY_BUFFERED_BYTES = 4 * 1024 * 1024;
9
+ const HTTP_TIMEOUT_MS = 15_000;
10
+ const BINARY_HEADER_BYTES = 73;
11
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12
+
13
+ function normalizeRelayUrl(value) {
14
+ const source = String(value || DEFAULT_CONSOLE_RELAY_URL).trim();
15
+ if (!source || /^(off|disabled|none)$/i.test(source)) return '';
16
+ try {
17
+ const url = new URL(source);
18
+ url.protocol = url.protocol === 'http:' ? 'ws:' : url.protocol === 'https:' ? 'wss:' : url.protocol;
19
+ url.pathname = '/v1/hub';
20
+ url.search = '';
21
+ url.hash = '';
22
+ return url;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function allowedHttpRequest(method, path) {
29
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return false;
30
+ const pathname = String(path || '').split('?')[0];
31
+ if (pathname === '/api/health'
32
+ || pathname === '/api/runtime/status'
33
+ || pathname === '/api/runtime/restart'
34
+ || pathname === '/api/auth/status'
35
+ || pathname === '/api/remote/status'
36
+ || pathname === '/api/hub/status'
37
+ || pathname === '/api/update/status'
38
+ || pathname === '/api/update/apply') {
39
+ return true;
40
+ }
41
+ if (/^\/api\/settings(?:\/|$)/.test(pathname)
42
+ || /^\/api\/captures(?:\/|$)/.test(pathname)
43
+ || /^\/api\/capture-sessions(?:\/|$)/.test(pathname)
44
+ || /^\/api\/remote\/devices(?:\/|$)/.test(pathname)
45
+ || /^\/api\/remote\/frames$/.test(pathname)
46
+ || /^\/api\/remote\/filesystem(?:\/|$)/.test(pathname)
47
+ || /^\/api\/remote\/files(?:\/|$)/.test(pathname)
48
+ || /^\/api\/remote\/tasks(?:\/|$)/.test(pathname)
49
+ || /^\/api\/remote\/license(?:\/sync)?$/.test(pathname)) {
50
+ return pathname !== '/api/remote/registry-credentials'
51
+ && pathname !== '/api/remote/pairing-pin'
52
+ && !pathname.startsWith('/api/remote/host-target')
53
+ && !pathname.startsWith('/api/remote/synthetic');
54
+ }
55
+ return false;
56
+ }
57
+
58
+ function allowedWebSocketPath(path) {
59
+ const pathname = String(path || '').split('?')[0];
60
+ return pathname === '/api/remote/frames/ws'
61
+ || pathname === '/api/remote/atlas/ws'
62
+ || pathname === '/api/remote/input/ws'
63
+ || pathname === '/api/remote/audio/ws';
64
+ }
65
+
66
+ function channelKey(consoleId, channelId) {
67
+ return `${consoleId}\n${channelId}`;
68
+ }
69
+
70
+ function parseMessage(raw) {
71
+ try {
72
+ return JSON.parse(String(raw || ''));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function safeContentHeaders(headers) {
79
+ const result = {};
80
+ for (const name of ['accept', 'content-type', 'if-match', 'range']) {
81
+ const value = headers && typeof headers === 'object' ? headers[name] || headers[name.toUpperCase()] : '';
82
+ if (value) result[name] = String(value).slice(0, 512);
83
+ }
84
+ return result;
85
+ }
86
+
87
+ export function createHubConsoleRelay(options = {}) {
88
+ const relayUrl = normalizeRelayUrl(options.url);
89
+ const deviceId = String(options.deviceId || '').trim();
90
+ const httpBaseUrl = String(options.httpBaseUrl || '').replace(/\/+$/, '');
91
+ const WebSocketImpl = options.WebSocketImpl || WebSocket;
92
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
93
+ const getAccessToken = typeof options.getAccessToken === 'function'
94
+ ? options.getAccessToken
95
+ : async () => String(options.accessToken || '').trim();
96
+ const logger = options.logger || console;
97
+ const retryDelaysMs = options.retryDelaysMs || DEFAULT_RETRY_DELAYS_MS;
98
+ const localSockets = new Map();
99
+ const pendingHttp = new Set();
100
+ let relaySocket = null;
101
+ let retryTimer = null;
102
+ let retryAttempt = 0;
103
+ let generation = 0;
104
+ let stopped = true;
105
+ let state = relayUrl ? 'idle' : 'disabled';
106
+ let lastConnectedAt = '';
107
+ let lastError = relayUrl instanceof URL ? '' : relayUrl === '' ? '' : 'invalid-relay-url';
108
+ let droppedBinaryMessages = 0;
109
+
110
+ const sendRelay = payload => {
111
+ if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN) return false;
112
+ if (Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) return false;
113
+ relaySocket.send(typeof payload === 'string' || Buffer.isBuffer(payload) ? payload : JSON.stringify(payload));
114
+ return true;
115
+ };
116
+
117
+ const sendError = (consoleId, channelId, error, type = 'relay-error') => {
118
+ sendRelay({ type, consoleId, channelId, error: String(error || 'console-relay-error').slice(0, 240) });
119
+ };
120
+
121
+ const closeLocalSocket = (key, code = 1000, reason = 'console-channel-closed') => {
122
+ const socket = localSockets.get(key);
123
+ if (!socket) return;
124
+ localSockets.delete(key);
125
+ try { socket.close(code, reason); } catch { /* exact socket is already closed */ }
126
+ try { socket.terminate?.(); } catch { /* exact socket is already closed */ }
127
+ };
128
+
129
+ const closeConsoleChannels = consoleId => {
130
+ for (const key of [...localSockets.keys()]) {
131
+ if (key.startsWith(`${consoleId}\n`)) closeLocalSocket(key, 1001, 'console-detached');
132
+ }
133
+ };
134
+
135
+ const closeAllLocalSockets = reason => {
136
+ for (const key of [...localSockets.keys()]) closeLocalSocket(key, 1012, reason);
137
+ };
138
+
139
+ const handleHttpRequest = async payload => {
140
+ const consoleId = String(payload?.consoleId || '');
141
+ const channelId = String(payload?.channelId || '');
142
+ const method = String(payload?.method || 'GET').toUpperCase();
143
+ const path = String(payload?.path || '');
144
+ if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedHttpRequest(method, path)) {
145
+ sendError(consoleId, channelId, 'console-route-not-allowed', 'http-response');
146
+ return;
147
+ }
148
+ if (pendingHttp.size >= MAX_CHANNELS) {
149
+ sendError(consoleId, channelId, 'console-http-capacity-reached', 'http-response');
150
+ return;
151
+ }
152
+ let body;
153
+ try {
154
+ body = payload.bodyBase64 ? Buffer.from(String(payload.bodyBase64), 'base64') : undefined;
155
+ } catch {
156
+ sendError(consoleId, channelId, 'invalid-console-http-body', 'http-response');
157
+ return;
158
+ }
159
+ if (body && body.byteLength > MAX_HTTP_REQUEST_BYTES) {
160
+ sendError(consoleId, channelId, 'console-http-body-too-large', 'http-response');
161
+ return;
162
+ }
163
+
164
+ const requestKey = channelKey(consoleId, channelId);
165
+ pendingHttp.add(requestKey);
166
+ const controller = new AbortController();
167
+ const timeout = setTimeout(() => controller.abort(new Error('console-http-timeout')), HTTP_TIMEOUT_MS);
168
+ timeout.unref?.();
169
+ try {
170
+ const response = await fetchImpl(`${httpBaseUrl}${path}`, {
171
+ method,
172
+ headers: safeContentHeaders(payload.headers),
173
+ body: ['GET', 'HEAD'].includes(method) ? undefined : body,
174
+ signal: controller.signal
175
+ });
176
+ const responseBytes = Buffer.from(await response.arrayBuffer());
177
+ if (responseBytes.byteLength > MAX_HTTP_RESPONSE_BYTES) {
178
+ sendError(consoleId, channelId, 'console-http-response-too-large', 'http-response');
179
+ return;
180
+ }
181
+ sendRelay({
182
+ type: 'http-response',
183
+ consoleId,
184
+ channelId,
185
+ status: response.status,
186
+ statusText: response.statusText,
187
+ headers: {
188
+ 'content-type': String(response.headers.get('content-type') || '').slice(0, 256),
189
+ 'content-range': String(response.headers.get('content-range') || '').slice(0, 256)
190
+ },
191
+ bodyBase64: responseBytes.toString('base64')
192
+ });
193
+ } catch (error) {
194
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'http-response');
195
+ } finally {
196
+ clearTimeout(timeout);
197
+ pendingHttp.delete(requestKey);
198
+ }
199
+ };
200
+
201
+ const handleWebSocketOpen = payload => {
202
+ const consoleId = String(payload?.consoleId || '');
203
+ const channelId = String(payload?.channelId || '');
204
+ const path = String(payload?.path || '');
205
+ if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedWebSocketPath(path)) {
206
+ sendError(consoleId, channelId, 'console-websocket-route-not-allowed', 'ws-error');
207
+ return;
208
+ }
209
+ if (localSockets.size >= MAX_CHANNELS) {
210
+ sendError(consoleId, channelId, 'console-websocket-capacity-reached', 'ws-error');
211
+ return;
212
+ }
213
+ const key = channelKey(consoleId, channelId);
214
+ closeLocalSocket(key, 1000, 'console-channel-replaced');
215
+ const localUrl = new URL(path, httpBaseUrl);
216
+ localUrl.protocol = localUrl.protocol === 'https:' ? 'wss:' : 'ws:';
217
+ let socket;
218
+ try {
219
+ socket = new WebSocketImpl(localUrl, { perMessageDeflate: false });
220
+ } catch (error) {
221
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
222
+ return;
223
+ }
224
+ localSockets.set(key, socket);
225
+ socket.once('open', () => {
226
+ if (localSockets.get(key) !== socket) return;
227
+ sendRelay({ type: 'ws-opened', consoleId, channelId });
228
+ });
229
+ socket.on('message', (data, isBinary) => {
230
+ if (localSockets.get(key) !== socket) return;
231
+ if (isBinary) {
232
+ if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN
233
+ || Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) {
234
+ droppedBinaryMessages += 1;
235
+ return;
236
+ }
237
+ const header = Buffer.from(`B${consoleId}${channelId}`, 'ascii');
238
+ if (header.byteLength !== BINARY_HEADER_BYTES) {
239
+ closeLocalSocket(key, 1008, 'invalid-relay-channel-id');
240
+ return;
241
+ }
242
+ relaySocket.send(Buffer.concat([header, Buffer.from(data)]));
243
+ return;
244
+ }
245
+ sendRelay({ type: 'ws-message', consoleId, channelId, data: String(data), binary: false });
246
+ });
247
+ socket.once('close', (code, reason) => {
248
+ if (localSockets.get(key) === socket) localSockets.delete(key);
249
+ sendRelay({ type: 'ws-closed', consoleId, channelId, code, reason: String(reason || '').slice(0, 120) });
250
+ });
251
+ socket.once('error', error => {
252
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
253
+ });
254
+ };
255
+
256
+ const handleMessage = raw => {
257
+ const payload = parseMessage(raw);
258
+ if (!payload?.type) return;
259
+ if (payload.type === 'relay-ready') {
260
+ state = 'connected';
261
+ lastConnectedAt = new Date().toISOString();
262
+ lastError = '';
263
+ return;
264
+ }
265
+ if (payload.type === 'console-detached') {
266
+ closeConsoleChannels(String(payload.consoleId || ''));
267
+ return;
268
+ }
269
+ if (payload.type === 'http-request') {
270
+ void handleHttpRequest(payload);
271
+ return;
272
+ }
273
+ if (payload.type === 'ws-open') {
274
+ handleWebSocketOpen(payload);
275
+ return;
276
+ }
277
+ const key = channelKey(String(payload.consoleId || ''), String(payload.channelId || ''));
278
+ const socket = localSockets.get(key);
279
+ if (!socket) return;
280
+ if (payload.type === 'ws-send' && socket.readyState === WebSocketImpl.OPEN) {
281
+ const data = String(payload.data || '');
282
+ if (Buffer.byteLength(data) <= MAX_HTTP_REQUEST_BYTES) socket.send(data);
283
+ } else if (payload.type === 'ws-close') {
284
+ closeLocalSocket(key, Number(payload.code || 1000), String(payload.reason || 'console-request').slice(0, 120));
285
+ }
286
+ };
287
+
288
+ const scheduleReconnect = connect => {
289
+ if (stopped || retryTimer || !(relayUrl instanceof URL)) return;
290
+ const delayMs = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)];
291
+ retryAttempt += 1;
292
+ state = 'waiting-retry';
293
+ retryTimer = setTimeout(() => {
294
+ retryTimer = null;
295
+ void connect();
296
+ }, delayMs);
297
+ retryTimer.unref?.();
298
+ };
299
+
300
+ const connect = async () => {
301
+ if (stopped || !(relayUrl instanceof URL) || !deviceId) return;
302
+ const ownerGeneration = ++generation;
303
+ state = 'connecting';
304
+ let accessToken = '';
305
+ try {
306
+ accessToken = String(await getAccessToken() || '').trim();
307
+ } catch (error) {
308
+ lastError = error instanceof Error ? error.message : String(error);
309
+ }
310
+ if (stopped || ownerGeneration !== generation) return;
311
+ if (!accessToken) {
312
+ lastError = 'hub-session-required';
313
+ scheduleReconnect(connect);
314
+ return;
315
+ }
316
+ const url = new URL(relayUrl);
317
+ url.searchParams.set('deviceId', deviceId);
318
+ let socket;
319
+ try {
320
+ socket = new WebSocketImpl(url, {
321
+ headers: { Authorization: `Bearer ${accessToken}` },
322
+ perMessageDeflate: false
323
+ });
324
+ } catch (error) {
325
+ lastError = error instanceof Error ? error.message : String(error);
326
+ scheduleReconnect(connect);
327
+ return;
328
+ }
329
+ if (stopped || ownerGeneration !== generation) {
330
+ try { socket.terminate?.(); } catch { /* stale connect is already gone */ }
331
+ return;
332
+ }
333
+ relaySocket = socket;
334
+ socket.once('open', () => {
335
+ if (relaySocket !== socket || ownerGeneration !== generation || stopped) return;
336
+ retryAttempt = 0;
337
+ state = 'authenticating';
338
+ });
339
+ socket.on('message', raw => {
340
+ if (relaySocket === socket && ownerGeneration === generation && !stopped) handleMessage(raw);
341
+ });
342
+ socket.once('close', () => {
343
+ if (relaySocket === socket) relaySocket = null;
344
+ if (ownerGeneration !== generation || stopped) return;
345
+ state = 'disconnected';
346
+ closeAllLocalSockets('relay-disconnected');
347
+ scheduleReconnect(connect);
348
+ });
349
+ socket.once('error', error => {
350
+ lastError = error instanceof Error ? error.message : String(error);
351
+ try { socket.terminate?.(); } catch { /* close handler owns retry */ }
352
+ });
353
+ };
354
+
355
+ const start = () => {
356
+ if (!stopped || !(relayUrl instanceof URL) || !deviceId) return;
357
+ stopped = false;
358
+ void connect();
359
+ };
360
+
361
+ const refresh = () => {
362
+ if (stopped) {
363
+ start();
364
+ return;
365
+ }
366
+ generation += 1;
367
+ if (retryTimer) {
368
+ clearTimeout(retryTimer);
369
+ retryTimer = null;
370
+ }
371
+ const socket = relaySocket;
372
+ relaySocket = null;
373
+ try { socket?.terminate?.(); } catch { /* refresh owns the exact socket */ }
374
+ closeAllLocalSockets('relay-refresh');
375
+ void connect();
376
+ };
377
+
378
+ const close = () => {
379
+ if (stopped) return;
380
+ stopped = true;
381
+ generation += 1;
382
+ if (retryTimer) clearTimeout(retryTimer);
383
+ retryTimer = null;
384
+ closeAllLocalSockets('hub-shutdown');
385
+ const socket = relaySocket;
386
+ relaySocket = null;
387
+ try { socket?.close(1001, 'hub-shutdown'); } catch { /* exact socket is already closed */ }
388
+ try { socket?.terminate?.(); } catch { /* exact socket is already closed */ }
389
+ state = relayUrl ? 'closed' : 'disabled';
390
+ };
391
+
392
+ return {
393
+ start,
394
+ refresh,
395
+ close,
396
+ inspect: () => ({
397
+ enabled: relayUrl instanceof URL && Boolean(deviceId),
398
+ state,
399
+ connected: state === 'connected',
400
+ localWebSocketChannels: localSockets.size,
401
+ pendingHttpRequests: pendingHttp.size,
402
+ droppedBinaryMessages,
403
+ lastConnectedAt,
404
+ lastError
405
+ })
406
+ };
407
+ }
408
+
409
+ export const consoleRelayContract = Object.freeze({
410
+ maxChannels: MAX_CHANNELS,
411
+ maxHttpRequestBytes: MAX_HTTP_REQUEST_BYTES,
412
+ maxHttpResponseBytes: MAX_HTTP_RESPONSE_BYTES,
413
+ maxBufferedBytes: MAX_RELAY_BUFFERED_BYTES,
414
+ binaryHeaderBytes: BINARY_HEADER_BYTES
415
+ });
package/src/server.js CHANGED
@@ -7,8 +7,9 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, wri
7
7
  import { dirname, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import os from 'node:os';
10
- import { WebSocketServer } from 'ws';
11
- import { createRemoteHub } from './remote-hub.js';
10
+ import { WebSocketServer } from 'ws';
11
+ import { createRemoteHub } from './remote-hub.js';
12
+ import { createHubConsoleRelay } from './console-relay.js';
12
13
  import {
13
14
  buildImmutableRemoteFramePacket,
14
15
  createRemoteFramePacketMetrics,
@@ -202,15 +203,22 @@ let verifiedLicense = {
202
203
  let frameClientSeq = 0;
203
204
  let inputClientSeq = 0;
204
205
  let audioClientSeq = 0;
205
- let liveDeskUpdateManager = null;
206
- let hubTransferJobs = null;
206
+ let liveDeskUpdateManager = null;
207
+ let hubTransferJobs = null;
208
+ let hubConsoleRelay = null;
207
209
  const HUB_HOST_TARGET_LEASE_MS = Math.max(5000, readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_LEASE_MS', 60_000));
208
210
  const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
209
211
  readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
210
212
  Math.max(1000, HUB_HOST_TARGET_LEASE_MS - 1000)
211
213
  ));
212
- const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
213
- const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
214
+ const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
215
+ const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
216
+ const hubConsoleRelayBaseUrl = String(
217
+ process.env.LIVEDESK_CONSOLE_RELAY_URL
218
+ || (process.env.LIVEDESK_TEST_MODE === '1' || process.env.LIVEDESK_AUTH_TEST_MODE === '1'
219
+ ? 'off'
220
+ : 'https://livedesk-wake.lovecrdm.workers.dev')
221
+ ).trim();
214
222
  const HUB_WAKE_NOTIFY_RETRY_MS = 60_000;
215
223
  let hubHostTargetRenewTimer = null;
216
224
  let hubHostTargetRenewInFlight = false;
@@ -1333,9 +1341,16 @@ const hubSharedFolders = createHubSharedFolders({
1333
1341
  dataDir: agentDataDir
1334
1342
  });
1335
1343
 
1336
- const app = express();
1337
- const httpServer = createServer(app);
1338
- const httpConnections = new Set();
1344
+ const app = express();
1345
+ const httpServer = createServer(app);
1346
+ hubConsoleRelay = createHubConsoleRelay({
1347
+ url: runtimeRole === 'hub' ? hubConsoleRelayBaseUrl : 'off',
1348
+ deviceId: runtimeDeviceId,
1349
+ httpBaseUrl: `http://127.0.0.1:${httpPort}`,
1350
+ getAccessToken: () => getRuntimeAccessToken(),
1351
+ logger: console
1352
+ });
1353
+ const httpConnections = new Set();
1339
1354
  const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1340
1355
  const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1341
1356
  const inputWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
@@ -4219,9 +4234,10 @@ app.get('/api/remote/status', (_req, res) => {
4219
4234
  runtimeRole,
4220
4235
  deviceId: runtimeDeviceId,
4221
4236
  deviceName: runtimeDeviceName,
4222
- roleSource: runtimeRoleSource,
4223
- agentPackage: '@livedesk/client',
4224
- frameLanes: snapshotFrameLaneResourceHealth(),
4237
+ roleSource: runtimeRoleSource,
4238
+ agentPackage: '@livedesk/client',
4239
+ consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4240
+ frameLanes: snapshotFrameLaneResourceHealth(),
4225
4241
  update: getLiveDeskUpdateStatus()
4226
4242
  });
4227
4243
  });
@@ -4453,10 +4469,11 @@ app.post('/api/auth/session', async (req, res) => {
4453
4469
  csrfToken: uiSession.csrfToken,
4454
4470
  expiresAt: uiSession.expiresAt
4455
4471
  }
4456
- });
4457
- if (runtimeRole === 'hub') {
4458
- scheduleAuthenticatedHubHostTargetPublication('session-received');
4459
- }
4472
+ });
4473
+ if (runtimeRole === 'hub') {
4474
+ hubConsoleRelay?.refresh();
4475
+ scheduleAuthenticatedHubHostTargetPublication('session-received');
4476
+ }
4460
4477
  } catch (error) {
4461
4478
  const message = error instanceof Error ? error.message : String(error);
4462
4479
  const status = authVerificationHttpStatus(message);
@@ -4476,10 +4493,11 @@ function getHubHostTargetLeaseStatus() {
4476
4493
  ...hubHostTargetLeaseState,
4477
4494
  renewing: hubHostTargetRenewInFlight,
4478
4495
  renewalIntervalMs: HUB_HOST_TARGET_RENEW_MS,
4479
- leaseDurationMs: HUB_HOST_TARGET_LEASE_MS,
4480
- authenticated: Boolean(runtimeAccessToken),
4481
- timerActive: Boolean(hubHostTargetRenewTimer),
4482
- wake: { ...hubWakeNotificationState }
4496
+ leaseDurationMs: HUB_HOST_TARGET_LEASE_MS,
4497
+ authenticated: Boolean(runtimeAccessToken),
4498
+ timerActive: Boolean(hubHostTargetRenewTimer),
4499
+ wake: { ...hubWakeNotificationState },
4500
+ consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' }
4483
4501
  };
4484
4502
  }
4485
4503
 
@@ -4783,10 +4801,11 @@ app.get('/api/hub/status', (_req, res) => {
4783
4801
  ...remoteHub.getStatus({ includeSecrets: false }),
4784
4802
  role: 'hub',
4785
4803
  deviceId: runtimeDeviceId,
4786
- deviceName: runtimeDeviceName,
4787
- roleSource: runtimeRoleSource,
4788
- runtimeStarted: true,
4789
- hostTargetLease: getHubHostTargetLeaseStatus(),
4804
+ deviceName: runtimeDeviceName,
4805
+ roleSource: runtimeRoleSource,
4806
+ runtimeStarted: true,
4807
+ consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4808
+ hostTargetLease: getHubHostTargetLeaseStatus(),
4790
4809
  update: getLiveDeskUpdateStatus()
4791
4810
  });
4792
4811
  });
@@ -5793,13 +5812,14 @@ hubSharedFolders.startAutoSync(
5793
5812
  () => remoteHub.listDevices({ includeDataUrl: false }).filter(device => device.connected).map(device => device.deviceId),
5794
5813
  () => process.env.LIVEDESK_REMOTE_DIRECTORY || 'Desktop/LiveDeskFiles'
5795
5814
  );
5796
- httpServer.listen(httpPort, httpHost, () => {
5815
+ httpServer.listen(httpPort, httpHost, () => {
5797
5816
  const status = remoteHub.getStatus({ includeSecrets: true });
5798
5817
  const managerVersion = String(process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version || 'dev');
5799
5818
  console.log(`[LiveDesk Hub] Version ${managerVersion}`);
5800
- console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
5801
- console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
5802
- });
5819
+ console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
5820
+ console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
5821
+ hubConsoleRelay?.start();
5822
+ });
5803
5823
 
5804
5824
  const roleWatchTimer = runtimeRole === 'hub'
5805
5825
  ? setInterval(() => { void watchAuthoritativeRuntimeRole(); }, 5000)
@@ -5880,9 +5900,10 @@ function shutdownHub(signal) {
5880
5900
  const startedAt = Date.now();
5881
5901
  console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
5882
5902
  if (roleWatchTimer) clearInterval(roleWatchTimer);
5883
- clearInterval(browserWebSocketHeartbeatTimer);
5884
- runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
5885
- atlasClients.clear();
5903
+ clearInterval(browserWebSocketHeartbeatTimer);
5904
+ runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
5905
+ runSynchronousShutdownStep('mobile console relay close', () => hubConsoleRelay?.close());
5906
+ atlasClients.clear();
5886
5907
  runSynchronousShutdownStep('shared folder close', () => hubSharedFolders.close());
5887
5908
 
5888
5909
  const httpClosed = new Promise(resolveClose => {