@livedesk/client 0.1.199 → 0.1.201

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.
@@ -11,7 +11,13 @@ import net from 'node:net';
11
11
  import os from 'node:os';
12
12
  import { formatClientVersionBanner, readClientPackageVersion } from './client-version.js';
13
13
  import { createClientRuntimeServer } from '../src/runtime/client-runtime-server.js';
14
- import { installAgentTerminationHandlers } from '../src/runtime/agent-process-lifecycle.js';
14
+ import {
15
+ createWindowsProcessTreeTracker,
16
+ drainOwnedWindowsAgentTreeUntilStopped,
17
+ drainOwnedUnixAgentTree,
18
+ installAgentTerminationHandlers,
19
+ signalAgentTree
20
+ } from '../src/runtime/agent-process-lifecycle.js';
15
21
  import { createHubWakeListener } from '../src/runtime/hub-wake-listener.js';
16
22
  import {
17
23
  inspectLinuxVideoAcceleration,
@@ -27,6 +33,8 @@ const nodeAgentPath = join(__dirname, 'livedesk-client-node.js');
27
33
  const FAST_PREFLIGHT_TIMEOUT_MS = 5000;
28
34
  const DEFAULT_MANAGER = '127.0.0.1:5197';
29
35
  const DEFAULT_HUB_CLIENT_PORT = 5197;
36
+ const DEFAULT_RELAY_HOST = 'www.gnsoftech.com';
37
+ const DEFAULT_RELAY_PORT = 5199;
30
38
  const DEFAULT_AUTH_CALLBACK_HOST = '127.0.0.1';
31
39
  const DEFAULT_AUTH_CALLBACK_PORT = 5179;
32
40
  const ENDPOINT_PROBE_TIMEOUT_MS = 900;
@@ -67,6 +75,22 @@ let networkChangeMonitor = null;
67
75
  const sessionRefreshesInFlight = new WeakMap();
68
76
  installAgentTerminationHandlers({ getAgentProcess: () => activeAgentProcess });
69
77
 
78
+ function requestActiveAgentStop(signal = 'SIGTERM') {
79
+ const child = activeAgentProcess;
80
+ if (!child) return false;
81
+ if (process.platform === 'win32') {
82
+ // Internal reconnect/slot/role restarts use the same immutable
83
+ // PID+CreationDate tree contract as unexpected exit and terminal
84
+ // shutdown. Store the shared proof promise so no replacement can race.
85
+ if (!child.livedeskTreeStopPromise) {
86
+ child.livedeskTreeStopPromise = drainOwnedWindowsAgentTreeUntilStopped(child);
87
+ }
88
+ return true;
89
+ }
90
+ signalAgentTree(child, signal);
91
+ return true;
92
+ }
93
+
70
94
  export function requestLocalDiscoveryWake(reason = 'local-trigger') {
71
95
  const previous = discoveryWakeController;
72
96
  discoveryWakeController = new AbortController();
@@ -191,9 +215,12 @@ Default flow:
191
215
  Options:
192
216
  --slot <number> Screen wall slot number for this computer.
193
217
  --name <name> Friendly device name. Default: OS hostname.
194
- --engine auto|fast|csharp|node Select agent engine. Default: auto.
195
- --fast Force C# RemoteFast frame.binary engine.
196
- --node Force the legacy Node agent.
218
+ --engine auto|fast|csharp|node Select agent engine. Default: auto.
219
+ --fast Force C# RemoteFast frame.binary engine.
220
+ --node Force the legacy Node agent.
221
+ --transport auto|tcp|ws|udp-p2p
222
+ Direct-first connection policy. Default: auto.
223
+ --relay <host:port> Encrypted fallback relay. Default: www.gnsoftech.com:5199.
197
224
  --control Enable RemoteFast keyboard/mouse control.
198
225
  --no-control Disable RemoteFast keyboard/mouse control.
199
226
  --thumbnail Enable thumbnail capture when supported.
@@ -225,7 +252,7 @@ function isTruthy(value) {
225
252
  return /^(1|true|yes|on)$/i.test(String(value || '').trim());
226
253
  }
227
254
 
228
- function normalizeEngine(value) {
255
+ function normalizeEngine(value) {
229
256
  const engine = String(value || 'auto').trim().toLowerCase();
230
257
  if (engine === 'c#' || engine === 'csharp' || engine === 'fast') {
231
258
  return 'fast';
@@ -233,11 +260,54 @@ function normalizeEngine(value) {
233
260
  if (engine === 'js' || engine === 'node' || engine === 'legacy') {
234
261
  return 'node';
235
262
  }
236
- return 'auto';
237
- }
238
-
239
- function parseLauncherArgs(argv) {
240
- const forwarded = [];
263
+ return 'auto';
264
+ }
265
+
266
+ function normalizeClientTransport(value) {
267
+ const transport = String(value || 'auto').trim().toLowerCase();
268
+ if (transport === 'legacy') return 'tcp';
269
+ if (transport === 'wss') return 'ws';
270
+ if (transport === 'udp-relay') return 'udp-p2p';
271
+ return ['auto', 'tcp', 'ws', 'udp-p2p'].includes(transport)
272
+ ? transport
273
+ : 'tcp';
274
+ }
275
+
276
+ export function parseClientTransportOption(value) {
277
+ const transport = String(value || '').trim().toLowerCase();
278
+ if (!['auto', 'tcp', 'legacy', 'ws', 'wss', 'udp-p2p', 'udp-relay'].includes(transport)) {
279
+ throw new Error(`Unsupported LiveDesk Client transport: ${value || '(missing)'}`);
280
+ }
281
+ return normalizeClientTransport(transport);
282
+ }
283
+
284
+ function transportAllowsRelay(transport) {
285
+ return transport === 'auto' || transport === 'udp-p2p';
286
+ }
287
+
288
+ export function resolveClientRelayEndpoint(env = process.env) {
289
+ const explicit = String(
290
+ env.LIVEDESK_CLIENT_RELAY
291
+ || env.LIVEDESK_RELAY_ENDPOINT
292
+ || ''
293
+ ).trim().replace(/^tcp:\/\//i, '');
294
+ if (parseManagerEndpoint(explicit)) {
295
+ return explicit;
296
+ }
297
+ const host = String(
298
+ env.LIVEDESK_RELAY_HOST
299
+ || env.LIVEDESK_UDP_RENDEZVOUS_HOST
300
+ || DEFAULT_RELAY_HOST
301
+ ).trim();
302
+ const port = normalizePort(
303
+ env.LIVEDESK_RELAY_PORT
304
+ || env.LIVEDESK_UDP_RENDEZVOUS_PORT
305
+ ) || DEFAULT_RELAY_PORT;
306
+ return host ? `${host}:${port}` : '';
307
+ }
308
+
309
+ export function parseLauncherArgs(argv) {
310
+ const forwarded = [];
241
311
  let engine = normalizeEngine(process.env.LIVEDESK_CLIENT_ENGINE || process.env.MINDEXEC_REMOTE_ENGINE);
242
312
  let help = false;
243
313
  let loginRequested = false;
@@ -245,7 +315,18 @@ function parseLauncherArgs(argv) {
245
315
  let logout = false;
246
316
  let version = false;
247
317
  let manager = process.env.LIVEDESK_CLIENT_MANAGER || process.env.MINDEXEC_REMOTE_MANAGER || '';
248
- let pair = process.env.LIVEDESK_CLIENT_PAIR_TOKEN || process.env.MINDEXEC_REMOTE_PAIR_TOKEN || '';
318
+ let pair = process.env.LIVEDESK_CLIENT_PAIR_TOKEN || process.env.MINDEXEC_REMOTE_PAIR_TOKEN || '';
319
+ let transport = normalizeClientTransport(
320
+ process.env.LIVEDESK_CLIENT_TRANSPORT || process.env.MINDEXEC_REMOTE_TRANSPORT);
321
+ let relay = resolveClientRelayEndpoint();
322
+ const requireFast = isTruthy(process.env.LIVEDESK_CLIENT_REQUIRE_FAST);
323
+ let relayExplicit = Boolean(String(
324
+ process.env.LIVEDESK_CLIENT_RELAY
325
+ || process.env.LIVEDESK_RELAY_ENDPOINT
326
+ || process.env.LIVEDESK_RELAY_HOST
327
+ || process.env.LIVEDESK_UDP_RENDEZVOUS_HOST
328
+ || ''
329
+ ).trim());
249
330
  let deviceId = String(process.env.LIVEDESK_DEVICE_ID || '').trim();
250
331
  let slot = process.env.LIVEDESK_CLIENT_SLOT || process.env.MINDEXEC_REMOTE_SLOT || '';
251
332
  let authPort = normalizePort(process.env.LIVEDESK_CLIENT_RUNTIME_PORT || process.env.LIVEDESK_CLIENT_AUTH_PORT) || DEFAULT_AUTH_CALLBACK_PORT;
@@ -325,13 +406,28 @@ function parseLauncherArgs(argv) {
325
406
  index += 1;
326
407
  continue;
327
408
  }
328
- if (arg === '--pair') {
329
- pair = argv[index + 1] || pair;
330
- forwarded.push(arg, argv[index + 1]);
331
- index += 1;
332
- continue;
333
- }
334
- if (arg === '--device-id') {
409
+ if (arg === '--pair') {
410
+ pair = argv[index + 1] || pair;
411
+ forwarded.push(arg, argv[index + 1]);
412
+ index += 1;
413
+ continue;
414
+ }
415
+ if (arg === '--transport') {
416
+ transport = parseClientTransportOption(argv[index + 1]);
417
+ index += 1;
418
+ continue;
419
+ }
420
+ if (arg === '--relay') {
421
+ const candidate = String(argv[index + 1] || '').trim().replace(/^tcp:\/\//i, '');
422
+ if (!parseManagerEndpoint(candidate)) {
423
+ throw new Error(`Invalid LiveDesk relay endpoint: ${argv[index + 1] || '(missing)'}`);
424
+ }
425
+ relay = candidate;
426
+ relayExplicit = true;
427
+ index += 1;
428
+ continue;
429
+ }
430
+ if (arg === '--device-id') {
335
431
  deviceId = argv[index + 1] || deviceId;
336
432
  forwarded.push(arg, argv[index + 1]);
337
433
  index += 1;
@@ -371,9 +467,13 @@ function parseLauncherArgs(argv) {
371
467
  loginDisabled,
372
468
  logout,
373
469
  version,
374
- manager,
375
- pair,
376
- deviceId,
470
+ manager,
471
+ pair,
472
+ transport,
473
+ relay,
474
+ relayExplicit,
475
+ requireFast,
476
+ deviceId,
377
477
  slot,
378
478
  authPort,
379
479
  nodeOnlyFeature,
@@ -551,13 +651,19 @@ function buildStartupClientArgs(parsed) {
551
651
  if (slot) {
552
652
  args.push(slot);
553
653
  }
554
- if (parsed.engine === 'fast') {
555
- args.push('--fast');
556
- } else if (parsed.engine === 'node') {
557
- args.push('--node');
558
- }
559
-
560
- const forwarded = Array.isArray(parsed.forwarded) ? parsed.forwarded : [];
654
+ if (parsed.engine === 'fast') {
655
+ args.push('--fast');
656
+ } else if (parsed.engine === 'node') {
657
+ args.push('--node');
658
+ }
659
+ if (parsed.transport && parsed.transport !== 'auto') {
660
+ args.push('--transport', parsed.transport);
661
+ }
662
+ if (parsed.relayExplicit && parsed.relay) {
663
+ args.push('--relay', parsed.relay);
664
+ }
665
+
666
+ const forwarded = Array.isArray(parsed.forwarded) ? parsed.forwarded : [];
561
667
  for (let index = 0; index < forwarded.length; index += 1) {
562
668
  const arg = forwarded[index];
563
669
  if (!arg || arg === 'connect') {
@@ -2449,7 +2555,7 @@ function readRequestBody(req, maxBytes = 4096) {
2449
2555
  });
2450
2556
  }
2451
2557
 
2452
- async function resolveManagerFromPin(supabase, pin) {
2558
+ async function resolveManagerFromPin(supabase, pin, options = {}) {
2453
2559
  const normalizedPin = normalizePairingPin(pin);
2454
2560
  if (!normalizedPin) {
2455
2561
  throw new Error('Enter a 6-digit LiveDesk PIN.');
@@ -2478,16 +2584,20 @@ async function resolveManagerFromPin(supabase, pin) {
2478
2584
  if (endpointCandidates.length === 0) {
2479
2585
  throw new Error('The PIN matched a Hub record without a reachable address.');
2480
2586
  }
2481
- const manager = await chooseReachableEndpoint(endpointCandidates, { requireReachable: true });
2482
- if (!manager) {
2483
- throw new Error(`No reachable LiveDesk Hub endpoint yet. Checked ${endpointCandidates.join(', ')}.`);
2484
- }
2485
-
2587
+ const target = await chooseManagerConnectionTarget(endpointCandidates, {
2588
+ allowRelayFallback: options.allowRelayFallback === true
2589
+ });
2590
+ if (!target) {
2591
+ throw new Error(`No reachable LiveDesk Hub endpoint yet. Checked ${endpointCandidates.join(', ')}.`);
2592
+ }
2593
+
2486
2594
  const resolved = {
2487
- manager,
2595
+ manager: target.manager,
2488
2596
  pair: result.pair_token,
2489
2597
  hubDeviceId: String(result.node_id || '').trim(),
2490
2598
  endpointCandidates,
2599
+ directReachable: target.directReachable,
2600
+ connectionTransport: target.connectionTransport,
2491
2601
  discoverySource: 'supabase'
2492
2602
  };
2493
2603
  writeCachedHubTarget(cacheOwnerKey, resolved);
@@ -2506,7 +2616,9 @@ async function waitForManagerFromSavedPin(supabase, pin, options = {}) {
2506
2616
  }
2507
2617
  attempts += 1;
2508
2618
  try {
2509
- return await resolveManagerFromPin(supabase, pin);
2619
+ return await resolveManagerFromPin(supabase, pin, {
2620
+ allowRelayFallback: options.allowRelayFallback === true
2621
+ });
2510
2622
  } catch (err) {
2511
2623
  if (shouldStop()) {
2512
2624
  return null;
@@ -2783,7 +2895,7 @@ async function startConnectionChoiceServer(supabase, options = {}) {
2783
2895
  res.end(JSON.stringify({ ok: true, restarting: true, role: 'hub', roleVersion: nextRoleVersion }));
2784
2896
  setTimeout(() => {
2785
2897
  try {
2786
- activeAgentProcess?.kill();
2898
+ requestActiveAgentStop();
2787
2899
  } catch {
2788
2900
  }
2789
2901
  try {
@@ -2890,7 +3002,9 @@ async function startConnectionChoiceServer(supabase, options = {}) {
2890
3002
  pendingStartup = normalizeStartupChoice(startupValue, pendingStartup);
2891
3003
  pin = normalizePairingPin(pin);
2892
3004
  try {
2893
- const resolved = await resolveManagerFromPin(supabase, pin);
3005
+ const resolved = await resolveManagerFromPin(supabase, pin, {
3006
+ allowRelayFallback: options.allowRelayFallback === true
3007
+ });
2894
3008
  writeSavedPin(pin);
2895
3009
  applyStartupPreference(pendingStartup, startupArgs);
2896
3010
  const choice = { type: 'pin', ...resolved };
@@ -3051,7 +3165,9 @@ async function chooseClientConnection(supabase, options = {}) {
3051
3165
  },
3052
3166
  resolvePin: pin => {
3053
3167
  if (!supabase) throw new Error('supabase-session-required');
3054
- return resolveManagerFromPin(supabase, pin);
3168
+ return resolveManagerFromPin(supabase, pin, {
3169
+ allowRelayFallback: options.allowRelayFallback === true
3170
+ });
3055
3171
  },
3056
3172
  changeRole: async (role, snapshot) => {
3057
3173
  if (!supabase) {
@@ -3080,7 +3196,7 @@ async function chooseClientConnection(supabase, options = {}) {
3080
3196
  writeLocalRoleCache(role, options.deviceId, roleVersion);
3081
3197
  roleRestartRequest = { role, requestedAt: new Date().toISOString() };
3082
3198
  requestLocalDiscoveryWake('role-change');
3083
- try { activeAgentProcess?.kill(); } catch { /* the lifecycle loop observes the role request */ }
3199
+ try { requestActiveAgentStop(); } catch { /* the lifecycle loop observes the role request */ }
3084
3200
  return { ok: true, restarting: true, role, roleVersion };
3085
3201
  },
3086
3202
  videoAcceleration: linuxVideoAccelerationStatus,
@@ -3098,7 +3214,7 @@ async function chooseClientConnection(supabase, options = {}) {
3098
3214
  requestedAt: new Date().toISOString()
3099
3215
  };
3100
3216
  requestLocalDiscoveryWake('linux-video-acceleration-ready');
3101
- try { activeAgentProcess?.kill(); } catch { /* the lifecycle loop restarts with the new ffmpeg preference */ }
3217
+ try { requestActiveAgentStop(); } catch { /* the lifecycle loop restarts with the new ffmpeg preference */ }
3102
3218
  },
3103
3219
  assignSlot: async request => {
3104
3220
  const result = await requestHubSlotAssignment(request);
@@ -3113,20 +3229,20 @@ async function chooseClientConnection(supabase, options = {}) {
3113
3229
  };
3114
3230
  requestLocalDiscoveryWake('slot-updated');
3115
3231
  setTimeout(() => {
3116
- try { activeAgentProcess?.kill(); } catch { /* the lifecycle loop restarts with the saved slot */ }
3232
+ try { requestActiveAgentStop(); } catch { /* the lifecycle loop restarts with the saved slot */ }
3117
3233
  }, 150);
3118
3234
  return { ...result, slotNumber, saved: true, restarting: true };
3119
3235
  },
3120
3236
  onRestart: () => process.kill(process.pid, 'SIGTERM'),
3121
3237
  onShutdown: () => process.kill(process.pid, 'SIGTERM'),
3122
- onDisconnect: () => activeAgentProcess?.kill(),
3238
+ onDisconnect: () => requestActiveAgentStop(),
3123
3239
  // Ending the current Agent causes the unified lifecycle loop to
3124
3240
  // discard its old manager/pair token and start Supabase discovery
3125
3241
  // again. Leaving this empty made the Reconnect button a success
3126
3242
  // response with no actual transport work.
3127
3243
  onReconnect: () => {
3128
3244
  requestLocalDiscoveryWake('manual-reconnect');
3129
- activeAgentProcess?.kill();
3245
+ requestActiveAgentStop();
3130
3246
  }
3131
3247
  });
3132
3248
  await connectionPage.start();
@@ -3147,9 +3263,10 @@ async function chooseClientConnection(supabase, options = {}) {
3147
3263
  deviceId: options.deviceId,
3148
3264
  slot: options.slot,
3149
3265
  startupArgs: options.startupArgs,
3150
- savedSession: options.savedSession,
3151
- savedPin: options.savedPin
3152
- });
3266
+ savedSession: options.savedSession,
3267
+ savedPin: options.savedPin,
3268
+ allowRelayFallback: options.allowRelayFallback === true
3269
+ });
3153
3270
  if (options.openBrowser !== false) {
3154
3271
  console.log('Opening LiveDesk connection page...');
3155
3272
  openBrowser(connectionPage.url);
@@ -3164,11 +3281,11 @@ function parseManagerEndpoint(value) {
3164
3281
  if (separator <= 0) {
3165
3282
  return null;
3166
3283
  }
3167
- const host = text.slice(0, separator).replace(/^\[/, '').replace(/\]$/, '').trim();
3168
- const port = Number(text.slice(separator + 1));
3169
- if (!host || !Number.isFinite(port)) {
3170
- return null;
3171
- }
3284
+ const host = text.slice(0, separator).replace(/^\[/, '').replace(/\]$/, '').trim();
3285
+ const port = Number(text.slice(separator + 1));
3286
+ if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
3287
+ return null;
3288
+ }
3172
3289
  return { host, port };
3173
3290
  }
3174
3291
 
@@ -3294,6 +3411,37 @@ export async function chooseReachableEndpoint(candidates, options = {}) {
3294
3411
  });
3295
3412
  }
3296
3413
 
3414
+ export async function chooseManagerConnectionTarget(candidates, options = {}) {
3415
+ const endpoints = [...new Set((Array.isArray(candidates) ? candidates : [])
3416
+ .map(value => String(value || '').trim())
3417
+ .filter(Boolean))];
3418
+ if (endpoints.length === 0) return null;
3419
+
3420
+ const manager = await chooseReachableEndpoint(endpoints, {
3421
+ requireReachable: true,
3422
+ probeEndpoint: options.probeEndpoint
3423
+ });
3424
+ if (manager) {
3425
+ return {
3426
+ manager,
3427
+ directReachable: true,
3428
+ connectionTransport: 'direct-tcp'
3429
+ };
3430
+ }
3431
+ if (options.allowRelayFallback !== true) {
3432
+ return null;
3433
+ }
3434
+ return {
3435
+ // The account/PIN response is the authority for this retry candidate.
3436
+ // RemoteFast gives it one bounded direct attempt, then uses the
3437
+ // pair-token-authenticated encrypted relay rather than keeping the
3438
+ // launcher in an indefinite TCP reachability loop.
3439
+ manager: endpoints[0],
3440
+ directReachable: false,
3441
+ connectionTransport: 'relay-fallback'
3442
+ };
3443
+ }
3444
+
3297
3445
  export async function resolveManagerFromCachedTarget(ownerKey, options = {}) {
3298
3446
  const cached = options.cachedTarget || readCachedHubTarget(ownerKey);
3299
3447
  if (!cached) return null;
@@ -3389,15 +3537,19 @@ async function resolveManagerFromSupabase(supabase, options = {}) {
3389
3537
  if (endpointCandidates.length === 0) {
3390
3538
  throw new Error('The LiveDesk Hub record does not contain a reachable local address.');
3391
3539
  }
3392
- const manager = await chooseReachableEndpoint(endpointCandidates, { requireReachable: options.requireReachable === true });
3393
- if (!manager) {
3394
- throw new Error(`No reachable LiveDesk Hub endpoint yet. Checked ${endpointCandidates.join(', ')}.`);
3395
- }
3396
- return {
3397
- manager,
3398
- pair: data.pair_token,
3399
- hubDeviceId: String(data.node_id || '').trim(),
3400
- endpointCandidates
3540
+ const target = await chooseManagerConnectionTarget(endpointCandidates, {
3541
+ allowRelayFallback: options.allowRelayFallback === true
3542
+ });
3543
+ if (!target) {
3544
+ throw new Error(`No reachable LiveDesk Hub endpoint yet. Checked ${endpointCandidates.join(', ')}.`);
3545
+ }
3546
+ return {
3547
+ manager: target.manager,
3548
+ pair: data.pair_token,
3549
+ hubDeviceId: String(data.node_id || '').trim(),
3550
+ endpointCandidates,
3551
+ directReachable: target.directReachable,
3552
+ connectionTransport: target.connectionTransport
3401
3553
  };
3402
3554
  }
3403
3555
 
@@ -3440,7 +3592,9 @@ async function waitForManagerFromSupabase(supabase, options = {}) {
3440
3592
  }
3441
3593
  attempts += 1;
3442
3594
  try {
3443
- return await resolveManagerFromSupabase(supabase, { requireReachable: true });
3595
+ return await resolveManagerFromSupabase(supabase, {
3596
+ allowRelayFallback: options.allowRelayFallback === true
3597
+ });
3444
3598
  } catch (err) {
3445
3599
  const message = formatDiscoveryError(err);
3446
3600
  if (message !== lastMessage || attempts === 1 || attempts % 6 === 0) {
@@ -3493,6 +3647,9 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3493
3647
  let pair = parsed.pair;
3494
3648
  let connectionPage = null;
3495
3649
  let discoverySource = '';
3650
+ let connectionTransport = '';
3651
+ let directReachable = false;
3652
+ const allowRelayFallback = transportAllowsRelay(parsed.transport);
3496
3653
 
3497
3654
  if (shouldLogin) {
3498
3655
  const supabase = await createSupabaseClient();
@@ -3509,7 +3666,9 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3509
3666
  if ((parsed.startupRun || existingConnectionPage) && savedSession?.access_token) {
3510
3667
  choice = { type: 'google', session: savedSession };
3511
3668
  } else if ((parsed.startupRun || existingConnectionPage) && savedPin) {
3512
- const resolved = await waitForManagerFromSavedPin(supabase, savedPin);
3669
+ const resolved = await waitForManagerFromSavedPin(supabase, savedPin, {
3670
+ allowRelayFallback
3671
+ });
3513
3672
  choice = { type: 'pin', ...resolved };
3514
3673
  } else if (existingConnectionPage && previousChoice) {
3515
3674
  choice = previousChoice;
@@ -3521,10 +3680,11 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3521
3680
  deviceId: parsed.deviceId,
3522
3681
  engine: parsed.engine,
3523
3682
  slot: parsed.slot,
3524
- startupArgs,
3525
- savedSession,
3526
- savedPin,
3527
- openBrowser: parsed.openBrowserOnStart
3683
+ startupArgs,
3684
+ savedSession,
3685
+ savedPin,
3686
+ allowRelayFallback,
3687
+ openBrowser: parsed.openBrowserOnStart
3528
3688
  });
3529
3689
  }
3530
3690
  connectionPage = existingConnectionPage || choice.connectionPage || null;
@@ -3532,19 +3692,27 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3532
3692
  manager = choice.manager;
3533
3693
  pair = choice.pair;
3534
3694
  discoverySource = choice.discoverySource || 'supabase';
3695
+ connectionTransport = choice.connectionTransport || 'direct-tcp';
3696
+ directReachable = choice.directReachable !== false;
3535
3697
  connectionPage?.update({
3536
3698
  endpointCandidates: choice.endpointCandidates || [],
3537
3699
  manager,
3538
3700
  assignedHubId: choice.hubDeviceId || '',
3539
- message: `Connected to LiveDesk Hub at ${manager}.`
3540
- });
3541
- console.log('Connected by LiveDesk PIN.');
3701
+ message: connectionTransport === 'relay-fallback'
3702
+ ? `LiveDesk Hub found at ${manager}; starting encrypted relay fallback.`
3703
+ : `Connected to LiveDesk Hub at ${manager}.`
3704
+ });
3705
+ console.log(connectionTransport === 'relay-fallback'
3706
+ ? 'LiveDesk PIN accepted. Direct TCP is unavailable; starting encrypted relay fallback.'
3707
+ : 'Connected by LiveDesk PIN.');
3542
3708
  } else {
3543
3709
  const cacheOwnerKey = hubCacheOwnerForSession(choice.session);
3544
3710
  let resolved = await resolveManagerFromCachedTarget(cacheOwnerKey);
3545
3711
  let session = choice.session;
3546
3712
  discoverySource = resolved ? 'cache' : 'supabase';
3547
3713
  if (resolved) {
3714
+ connectionTransport = resolved.connectionTransport || 'direct-tcp';
3715
+ directReachable = resolved.directReachable !== false;
3548
3716
  writeSavedSessionToFile(session);
3549
3717
  console.log(`Found cached LiveDesk Hub at ${resolved.manager}; skipped registry discovery.`);
3550
3718
  } else {
@@ -3565,6 +3733,7 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3565
3733
  console.log(`Signed in to LiveDesk${email}.`);
3566
3734
  if (!resolved) {
3567
3735
  resolved = await waitForManagerFromSupabase(supabase, {
3736
+ allowRelayFallback,
3568
3737
  shouldStop: () => Boolean(roleRestartRequest?.role)
3569
3738
  });
3570
3739
  }
@@ -3593,15 +3762,21 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3593
3762
  }
3594
3763
  }
3595
3764
  manager = resolved.manager;
3596
- pair = resolved.pair;
3765
+ pair = resolved.pair;
3766
+ connectionTransport = resolved.connectionTransport || 'direct-tcp';
3767
+ directReachable = resolved.directReachable !== false;
3597
3768
  connectionPage?.update({
3598
3769
  endpointCandidates: resolved.endpointCandidates || [],
3599
3770
  manager,
3600
3771
  assignedHubId: resolved.hubDeviceId || '',
3601
- message: `Connected to LiveDesk Hub at ${manager}.`
3602
- });
3603
- }
3604
- console.log(`Found LiveDesk Hub at ${manager}.`);
3772
+ message: connectionTransport === 'relay-fallback'
3773
+ ? `LiveDesk Hub found at ${manager}; starting encrypted relay fallback.`
3774
+ : `Connected to LiveDesk Hub at ${manager}.`
3775
+ });
3776
+ }
3777
+ console.log(connectionTransport === 'relay-fallback'
3778
+ ? `Found LiveDesk Hub record at ${manager}; direct TCP did not answer, so RemoteFast will use the encrypted rendezvous relay.`
3779
+ : `Found LiveDesk Hub at ${manager}.`);
3605
3780
  }
3606
3781
  // Exact-package updates preserve the already paired Hub endpoint and pass
3607
3782
  // --no-login so the replacement cannot block on browser auth. The unified
@@ -3647,12 +3822,16 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3647
3822
  forwarded = appendForwardedFlag(forwarded, '--exit-on-invalid-pair');
3648
3823
  }
3649
3824
  return {
3650
- ...parsed,
3651
- manager,
3652
- pair,
3653
- slot,
3825
+ ...parsed,
3826
+ manager,
3827
+ pair,
3828
+ transport: parsed.transport,
3829
+ relay: parsed.relay,
3830
+ slot,
3654
3831
  connectionPage,
3655
3832
  forwarded,
3833
+ connectionTransport: connectionTransport || 'direct-tcp',
3834
+ directReachable: connectionTransport === 'relay-fallback' ? false : (directReachable || !shouldLogin),
3656
3835
  discoverySource,
3657
3836
  rediscoverOnDisconnect: shouldLogin,
3658
3837
  rediscoverOnInvalidPair: shouldLogin
@@ -3872,7 +4051,9 @@ function buildClientAgentEnvironment(prepared) {
3872
4051
  env.LIVEDESK_CLIENT_SLOT = String(prepared?.slot || env.LIVEDESK_CLIENT_SLOT || '');
3873
4052
  env.LIVEDESK_DEVICE_ID = String(prepared?.deviceId || env.LIVEDESK_DEVICE_ID || '');
3874
4053
  env.LIVEDESK_CLIENT_ENGINE = String(prepared?.engine || env.LIVEDESK_CLIENT_ENGINE || 'auto');
3875
- env.LIVEDESK_CLIENT_TRANSPORT = String(env.LIVEDESK_CLIENT_TRANSPORT || 'auto');
4054
+ env.LIVEDESK_CLIENT_TRANSPORT = String(prepared?.transport || env.LIVEDESK_CLIENT_TRANSPORT || 'auto');
4055
+ env.LIVEDESK_CLIENT_RELAY = String(prepared?.relay || env.LIVEDESK_CLIENT_RELAY || '');
4056
+ env.LIVEDESK_CLIENT_REQUIRE_FAST = requiresFastTransport(prepared) ? '1' : '0';
3876
4057
  env.LIVEDESK_UDP_ENABLED = String(env.LIVEDESK_UDP_ENABLED || '1');
3877
4058
  env.LIVEDESK_CLIENT_UPDATE_STATE_PATH = CLIENT_UPDATE_STATE_PATH;
3878
4059
  env.LIVEDESK_CLIENT_UPDATE_ARGS_BASE64 = Buffer
@@ -3985,23 +4166,35 @@ function prepareFastExecutable(executable) {
3985
4166
  }
3986
4167
  }
3987
4168
 
3988
- function buildFastArgs(args, fakeThumbnail) {
3989
- const forwarded = [];
3990
- for (const arg of args) {
3991
- if (arg === '--fake-thumbnail') {
3992
- forwarded.push('--fake-frame');
3993
- continue;
4169
+ export function buildFastArgs(args, fakeThumbnail, transport = 'auto', relay = '') {
4170
+ const forwarded = [];
4171
+ for (let index = 0; index < args.length; index += 1) {
4172
+ const arg = args[index];
4173
+ if (arg === '--transport' || arg === '--relay') {
4174
+ index += 1;
4175
+ continue;
4176
+ }
4177
+ if (arg === '--fake-thumbnail') {
4178
+ forwarded.push('--fake-frame');
4179
+ continue;
3994
4180
  }
3995
4181
  if (arg === '--tasks' || arg === '--no-tasks' || arg === '--no-ai') {
3996
4182
  continue;
3997
4183
  }
3998
4184
  forwarded.push(arg);
3999
4185
  }
4000
- if (fakeThumbnail && !forwarded.includes('--fake-frame')) {
4001
- forwarded.push('--fake-frame');
4002
- }
4003
- return forwarded;
4004
- }
4186
+ if (fakeThumbnail && !forwarded.includes('--fake-frame')) {
4187
+ forwarded.push('--fake-frame');
4188
+ }
4189
+ let launchArgs = upsertForwardedOption(
4190
+ forwarded,
4191
+ '--transport',
4192
+ normalizeClientTransport(transport));
4193
+ if (transportAllowsRelay(normalizeClientTransport(transport))) {
4194
+ launchArgs = upsertForwardedOption(launchArgs, '--relay', relay);
4195
+ }
4196
+ return launchArgs;
4197
+ }
4005
4198
 
4006
4199
  function redactAgentArgs(args = []) {
4007
4200
  const redacted = [];
@@ -4103,6 +4296,7 @@ function spawnAgent(command, args, env = process.env, onStart = null) {
4103
4296
  restartVerified: false
4104
4297
  });
4105
4298
  const ownsProcessGroup = process.platform !== 'win32';
4299
+ const agentSpawnedAtMs = Date.now();
4106
4300
  const child = spawn(command, args, {
4107
4301
  env,
4108
4302
  stdio: 'inherit',
@@ -4113,24 +4307,78 @@ function spawnAgent(command, args, env = process.env, onStart = null) {
4113
4307
  windowsHide: true
4114
4308
  });
4115
4309
  child.livedeskOwnsProcessGroup = ownsProcessGroup;
4310
+ child.livedeskWindowsTreeTracker = createWindowsProcessTreeTracker(child, {
4311
+ spawnedAtMs: agentSpawnedAtMs
4312
+ });
4116
4313
  activeAgentProcess = child;
4117
4314
  if (typeof onStart === 'function') {
4118
4315
  onStart(child);
4119
4316
  }
4120
4317
 
4121
- return new Promise(resolve => {
4122
- child.once('error', error => {
4123
- console.error(`Failed to launch ${command}: ${error.message}`);
4124
- resolve({ code: 1, signal: null });
4125
- });
4126
- child.once('exit', (code, signal) => {
4127
- if (activeAgentProcess === child) {
4128
- activeAgentProcess = null;
4129
- }
4130
- resolve({ code: code ?? 0, signal });
4131
- });
4132
- });
4133
- }
4318
+ return new Promise(resolve => {
4319
+ let settling = false;
4320
+ child.once('error', error => {
4321
+ if (settling) return;
4322
+ settling = true;
4323
+ child.livedeskWindowsTreeTracker?.stop?.();
4324
+ if (activeAgentProcess === child) {
4325
+ activeAgentProcess = null;
4326
+ }
4327
+ console.error(`Failed to launch ${command}: ${error.message}`);
4328
+ resolve({ code: 1, signal: null });
4329
+ });
4330
+ child.once('exit', (code, signal) => {
4331
+ if (settling) return;
4332
+ settling = true;
4333
+ child.livedeskWindowsTreeTracker?.markRootExited?.();
4334
+ // The Unix Agent is a dedicated process-group leader. Its exit
4335
+ // event proves only that the leader stopped; SCK/FFmpeg capture
4336
+ // descendants can still own that PGID. Keep active ownership and
4337
+ // do not let the lifecycle loop launch a replacement until the
4338
+ // complete group has drained.
4339
+ void (async () => {
4340
+ try {
4341
+ if (process.platform === 'win32') {
4342
+ if (!child.livedeskTreeStopPromise) {
4343
+ child.livedeskTreeStopPromise = drainOwnedWindowsAgentTreeUntilStopped(child);
4344
+ }
4345
+ const windowsTreeStop = await child.livedeskTreeStopPromise;
4346
+ if (windowsTreeStop?.ok === false) {
4347
+ throw new Error(
4348
+ `exact Windows Agent tree drain failed: `
4349
+ + `${windowsTreeStop.error?.message || 'bounded immutable-tree cleanup failed'}`
4350
+ );
4351
+ }
4352
+ }
4353
+ await drainOwnedUnixAgentTree(child);
4354
+ } catch (error) {
4355
+ console.error(
4356
+ `[LiveDesk Client] Could not drain exited Agent tree pid=${child.pid || 'unknown'}: `
4357
+ + `${error?.message || error}`
4358
+ );
4359
+ // Block this lifecycle iteration from spawning a
4360
+ // replacement, but settle explicitly so the launcher
4361
+ // terminates instead of hanging forever.
4362
+ child.livedeskWindowsTreeTracker?.stop?.();
4363
+ if (activeAgentProcess === child) {
4364
+ activeAgentProcess = null;
4365
+ }
4366
+ resolve({
4367
+ code: 1,
4368
+ signal: null,
4369
+ lifecycleFailure: String(error?.message || error)
4370
+ });
4371
+ return;
4372
+ }
4373
+ child.livedeskWindowsTreeTracker?.stop?.();
4374
+ if (activeAgentProcess === child) {
4375
+ activeAgentProcess = null;
4376
+ }
4377
+ resolve({ code: code ?? 0, signal });
4378
+ })();
4379
+ });
4380
+ });
4381
+ }
4134
4382
 
4135
4383
  function shouldUseFast(parsed) {
4136
4384
  if (parsed.engine === 'node') {
@@ -4142,12 +4390,20 @@ function shouldUseFast(parsed) {
4142
4390
  return !parsed.nodeOnlyFeature;
4143
4391
  }
4144
4392
 
4145
- function shouldTryFast(parsed, runtime) {
4393
+ function shouldTryFast(parsed, runtime) {
4146
4394
  if (parsed.engine === 'fast') {
4147
4395
  return true;
4148
4396
  }
4149
4397
  return shouldUseFast(parsed) && !!runtime;
4150
- }
4398
+ }
4399
+
4400
+ export function requiresFastTransport(prepared = {}) {
4401
+ return prepared.requireFast === true
4402
+ || prepared.connectionTransport === 'relay-fallback'
4403
+ || prepared.transport === 'udp-p2p'
4404
+ || prepared.transport === 'ws'
4405
+ || prepared.relayExplicit === true;
4406
+ }
4151
4407
 
4152
4408
  export async function runClientRuntime(argv = process.argv.slice(2)) {
4153
4409
  startNetworkChangeMonitor();
@@ -4201,9 +4457,16 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
4201
4457
  await spawnUnifiedRoleRestart(roleRestartBeforeAgent.role);
4202
4458
  connectionPage?.close?.();
4203
4459
  process.exit(0);
4204
- }
4460
+ }
4205
4461
  const fastRuntime = getFastRuntime();
4206
- const useFast = shouldTryFast(prepared, fastRuntime);
4462
+ const fastRequired = requiresFastTransport(prepared);
4463
+ if (fastRequired && prepared.engine === 'node') {
4464
+ throw new Error(
4465
+ 'The legacy Node engine cannot use WebSocket, UDP P2P, or encrypted relay transport. '
4466
+ + 'Use --engine fast, or use --transport tcp while the Hub is directly reachable.'
4467
+ );
4468
+ }
4469
+ const useFast = fastRequired || shouldTryFast(prepared, fastRuntime);
4207
4470
  const agentLaunchStartedAt = Date.now();
4208
4471
  const updateAgentDashboard = (patch = {}) => {
4209
4472
  prepared.connectionPage?.update({
@@ -4217,11 +4480,15 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
4217
4480
  ? `LiveDesk client agent is running with ${patch.engine || 'agent'} mode.`
4218
4481
  : 'LiveDesk client agent is launching.'
4219
4482
  });
4220
- };
4221
- let result;
4222
- if (useFast) {
4223
- const fastArgs = buildFastArgs(prepared.forwarded, prepared.fakeThumbnail);
4224
- const fastLaunch = resolveFastLaunch(fastRuntime);
4483
+ };
4484
+ let result;
4485
+ if (useFast) {
4486
+ const fastArgs = buildFastArgs(
4487
+ prepared.forwarded,
4488
+ prepared.fakeThumbnail,
4489
+ prepared.transport,
4490
+ prepared.relay);
4491
+ const fastLaunch = resolveFastLaunch(fastRuntime);
4225
4492
  if (fastLaunch.ok) {
4226
4493
  const launchArgs = [...fastLaunch.argsPrefix, ...fastArgs];
4227
4494
  updateAgentDashboard({
@@ -4242,10 +4509,15 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
4242
4509
  state: 'running'
4243
4510
  });
4244
4511
  });
4245
- } else if (prepared.engine === 'fast') {
4246
- console.error(`C# RemoteFast is unavailable: ${fastLaunch.reason}. Use --engine node to run the legacy Node agent.`);
4247
- prepared.connectionPage?.close?.();
4248
- process.exit(2);
4512
+ } else if (prepared.engine === 'fast' || fastRequired) {
4513
+ console.error(
4514
+ `C# RemoteFast is unavailable: ${fastLaunch.reason}. `
4515
+ + (fastRequired
4516
+ ? 'The requested P2P, WebSocket, or encrypted relay transport requires the packaged RemoteFast runtime; the legacy Node agent was not started.'
4517
+ : 'Use --engine node to run the legacy Node agent.')
4518
+ );
4519
+ prepared.connectionPage?.close?.();
4520
+ process.exit(2);
4249
4521
  } else {
4250
4522
  console.warn(`C# RemoteFast is unavailable (${fastLaunch.reason}). Falling back to the Node remote agent.`);
4251
4523
  const launchArgs = [nodeAgentPath, ...prepared.forwarded];
@@ -4286,9 +4558,36 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
4286
4558
  pid: child.pid,
4287
4559
  startedAt: new Date().toISOString(),
4288
4560
  state: 'running'
4289
- });
4290
- });
4291
- }
4561
+ });
4562
+ });
4563
+ }
4564
+ // Contract: once the owned Agent exits, the local runtime must stop
4565
+ // advertising its old PID before any restart or Hub rediscovery.
4566
+ // Keeping a dead PID makes status diagnostics lie and causes manual
4567
+ // reconnect to target a process that no longer exists.
4568
+ prepared.connectionPage?.update({
4569
+ agent: {
4570
+ requestedEngine: prepared.engine,
4571
+ state: 'stopped',
4572
+ pid: 0,
4573
+ stoppedAt: new Date().toISOString(),
4574
+ exitCode: Number.isInteger(result?.code) ? result.code : null,
4575
+ exitSignal: result?.signal || ''
4576
+ }
4577
+ });
4578
+ if (result?.lifecycleFailure) {
4579
+ prepared.connectionPage?.update({
4580
+ agent: {
4581
+ requestedEngine: prepared.engine,
4582
+ state: 'failed',
4583
+ pid: 0,
4584
+ error: result.lifecycleFailure
4585
+ },
4586
+ message: 'LiveDesk stopped because the previous Windows capture tree could not be drained safely.'
4587
+ });
4588
+ connectionPage?.close?.();
4589
+ throw new Error(result.lifecycleFailure);
4590
+ }
4292
4591
  const requestedRoleRestart = connectionPage?.getRoleRestartRequest?.();
4293
4592
  if (requestedRoleRestart?.role) {
4294
4593
  if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) process.exit(43);
@@ -4302,7 +4601,8 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
4302
4601
  connectionPage?.update({
4303
4602
  agent: {
4304
4603
  requestedEngine: prepared.engine,
4305
- state: 'reconnecting'
4604
+ state: 'reconnecting',
4605
+ pid: 0
4306
4606
  },
4307
4607
  message: restart.reason === 'slot-updated'
4308
4608
  ? `Slot ${String(restart.slotNumber || prepared.slot || '').padStart(3, '0')} saved. Restarting the LiveDesk Agent.`
@@ -4331,10 +4631,11 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
4331
4631
  ? 'LiveDesk Hub pair token changed. Refreshing Hub discovery now.'
4332
4632
  : 'LiveDesk Hub connection ended. Looking for the current Hub now.');
4333
4633
  connectionPage?.update({
4334
- agent: {
4335
- requestedEngine: prepared.engine,
4336
- state: 'reconnecting'
4337
- },
4634
+ agent: {
4635
+ requestedEngine: prepared.engine,
4636
+ state: 'reconnecting',
4637
+ pid: 0
4638
+ },
4338
4639
  manager: '',
4339
4640
  message: 'The previous Hub is unavailable. LiveDesk is finding the current Hub automatically.'
4340
4641
  });