@livedesk/client 0.1.204 → 0.1.206

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.
@@ -39,6 +39,9 @@ const DEFAULT_RELAY_PORT = 5199;
39
39
  const DEFAULT_AUTH_CALLBACK_HOST = '127.0.0.1';
40
40
  const DEFAULT_AUTH_CALLBACK_PORT = 5179;
41
41
  const ENDPOINT_PROBE_TIMEOUT_MS = 900;
42
+ const FRESH_REGISTRY_TIMEOUT_MS = 4000;
43
+ const FRESH_REGISTRY_TIMEOUT_MIN_MS = 25;
44
+ const FRESH_REGISTRY_TIMEOUT_MAX_MS = 10000;
42
45
  // Hub-online delivery is an optimization, not a correctness dependency. A
43
46
  // browser, proxy, or suspended network can miss the wake event, so registry
44
47
  // polling must stay interactive instead of backing off to multi-minute waits.
@@ -49,6 +52,7 @@ const EXIT_INVALID_PAIR_TOKEN = 23;
49
52
  const EXIT_CLIENT_UPDATE = 42;
50
53
  const SESSION_REFRESH_SKEW_SECONDS = 60;
51
54
  const HUB_TARGET_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
55
+ const HUB_TARGET_CACHE_FUTURE_SKEW_MS = 5 * 60 * 1000;
52
56
  const WINDOWS_STARTUP_SCRIPT_NAME = 'LiveDesk Desktop.vbs';
53
57
  const WINDOWS_STARTUP_LEGACY_SCRIPT_NAME = 'LiveDesk Client.vbs';
54
58
  const WINDOWS_STARTUP_LEGACY_CMD_NAME = 'LiveDesk Client.cmd';
@@ -967,7 +971,9 @@ export function normalizeCachedHubTarget(value, expectedOwnerKey, now = Date.now
967
971
  return null;
968
972
  }
969
973
  const updatedAt = Date.parse(String(cached.updatedAt || ''));
970
- if (!Number.isFinite(updatedAt) || now - updatedAt > HUB_TARGET_CACHE_MAX_AGE_MS) {
974
+ if (!Number.isFinite(updatedAt)
975
+ || updatedAt > now + HUB_TARGET_CACHE_FUTURE_SKEW_MS
976
+ || now - updatedAt > HUB_TARGET_CACHE_MAX_AGE_MS) {
971
977
  return null;
972
978
  }
973
979
  const pair = String(cached.pair || '').trim();
@@ -1131,13 +1137,63 @@ function isTransientNetworkError(error) {
1131
1137
  return /fetch failed|network|dns|socket|connection|timeout|getaddrinfo/i.test(getNestedErrorMessage(error));
1132
1138
  }
1133
1139
 
1134
- function formatDiscoveryError(error) {
1135
- if (isTransientNetworkError(error)) {
1136
- const code = getNestedErrorCode(error);
1137
- return `Network is not ready yet${code ? ` (${code})` : ''}. Waiting for DNS/Wi-Fi after sleep.`;
1138
- }
1139
- return error instanceof Error ? error.message : String(error);
1140
- }
1140
+ function formatDiscoveryError(error) {
1141
+ if (isTransientNetworkError(error)) {
1142
+ const code = getNestedErrorCode(error);
1143
+ return `Network is not ready yet${code ? ` (${code})` : ''}. Waiting for DNS/Wi-Fi after sleep.`;
1144
+ }
1145
+ return error instanceof Error ? error.message : String(error);
1146
+ }
1147
+
1148
+ function createHubDiscoveryError(code, message, cause = null) {
1149
+ const error = new Error(message);
1150
+ error.code = String(code || '').trim();
1151
+ if (cause) {
1152
+ error.cause = cause;
1153
+ }
1154
+ return error;
1155
+ }
1156
+
1157
+ export function canUseCachedRelayAfterRegistryError(error) {
1158
+ const code = getNestedErrorCode(error).toLowerCase();
1159
+ if ([
1160
+ 'hub-registry-missing',
1161
+ 'hub-registry-expired',
1162
+ 'hub-registry-timeout',
1163
+ 'pin-not-found',
1164
+ 'pin-expired',
1165
+ 'resolver-unavailable'
1166
+ ].includes(code)) {
1167
+ return true;
1168
+ }
1169
+ // Semantic registry failures must never be reclassified as network errors
1170
+ // merely because their message mentions a "connection token".
1171
+ if (code.startsWith('hub-registry-')
1172
+ || code.startsWith('pin-registry-')
1173
+ || ['invalid-pin', 'rate-limited', 'request-too-large'].includes(code)) {
1174
+ return false;
1175
+ }
1176
+ return isTransientNetworkError(error);
1177
+ }
1178
+
1179
+ async function normalizePinResolverInvocationError(error) {
1180
+ if (isTransientNetworkError(error)) {
1181
+ return error;
1182
+ }
1183
+ const response = error?.context;
1184
+ const status = Number(response?.status || 0);
1185
+ let reason = '';
1186
+ try {
1187
+ const payload = await response?.clone?.().json?.();
1188
+ reason = String(payload?.reason || '').trim().toLowerCase();
1189
+ } catch {
1190
+ // HTTP status still gives a conservative semantic classification below.
1191
+ }
1192
+ if (!reason && status === 404) reason = 'pin-not-found';
1193
+ if (!reason && status === 503) reason = 'resolver-unavailable';
1194
+ if (!reason) return error;
1195
+ return createHubDiscoveryError(reason, reason, error);
1196
+ }
1141
1197
 
1142
1198
  async function recoverRotatedSession(supabase, previousSession) {
1143
1199
  for (const delayMs of [100, 250, 500]) {
@@ -2640,54 +2696,72 @@ function readRequestBody(req, maxBytes = 4096) {
2640
2696
  });
2641
2697
  }
2642
2698
 
2643
- async function resolveManagerFromPin(supabase, pin, options = {}) {
2699
+ export async function resolveManagerFromPin(supabase, pin, options = {}) {
2644
2700
  const normalizedPin = normalizePairingPin(pin);
2645
2701
  if (!normalizedPin) {
2646
2702
  throw new Error('Enter a 6-digit LiveDesk PIN.');
2647
2703
  }
2648
2704
  const cacheOwnerKey = hubCacheOwnerForPin(normalizedPin);
2649
- const cachedTarget = await resolveManagerFromCachedTarget(cacheOwnerKey);
2650
- if (cachedTarget) {
2651
- console.log(`Found cached LiveDesk Hub at ${cachedTarget.manager}.`);
2652
- return cachedTarget;
2653
- }
2654
-
2655
- const { data, error } = await supabase.functions.invoke('livedesk-resolve-remote-host', {
2656
- body: { pin: normalizedPin }
2657
- });
2658
- if (error) {
2659
- throw error;
2660
- }
2661
- const result = Array.isArray(data) ? data[0] : data;
2662
- if (!result?.ok) {
2663
- throw new Error(result?.reason || 'pin-not-found');
2664
- }
2665
- if (!result.pair_token) {
2666
- throw new Error('The PIN matched a Hub record without a private connection token.');
2667
- }
2668
- const endpointCandidates = normalizeEndpointCandidates(result);
2669
- if (endpointCandidates.length === 0) {
2670
- throw new Error('The PIN matched a Hub record without a reachable address.');
2671
- }
2672
- const target = await chooseManagerConnectionTarget(endpointCandidates, {
2673
- allowRelayFallback: options.allowRelayFallback === true
2705
+ const resolved = await resolveManagerWithCachedRecovery(cacheOwnerKey, {
2706
+ allowRelayFallback: options.allowRelayFallback === true,
2707
+ relayEndpoint: options.relayEndpoint,
2708
+ probeEndpoint: options.probeEndpoint,
2709
+ resolveFreshTarget: async () => {
2710
+ const { data, error } = await supabase.functions.invoke('livedesk-resolve-remote-host', {
2711
+ body: { pin: normalizedPin }
2712
+ });
2713
+ if (error) {
2714
+ throw await normalizePinResolverInvocationError(error);
2715
+ }
2716
+ const result = Array.isArray(data) ? data[0] : data;
2717
+ if (!result?.ok) {
2718
+ const reason = String(result?.reason || 'pin-not-found').trim().toLowerCase();
2719
+ throw createHubDiscoveryError(reason, reason);
2720
+ }
2721
+ if (!result.pair_token) {
2722
+ throw createHubDiscoveryError(
2723
+ 'pin-registry-pair-token-missing',
2724
+ 'The PIN matched a Hub record without a private connection token.'
2725
+ );
2726
+ }
2727
+ const endpointCandidates = normalizeEndpointCandidates(result);
2728
+ if (endpointCandidates.length === 0) {
2729
+ throw createHubDiscoveryError(
2730
+ 'pin-registry-endpoints-missing',
2731
+ 'The PIN matched a Hub record without a reachable address.'
2732
+ );
2733
+ }
2734
+ const target = await chooseManagerConnectionTarget(endpointCandidates, {
2735
+ allowRelayFallback: options.allowRelayFallback === true,
2736
+ probeEndpoint: options.probeEndpoint
2737
+ });
2738
+ if (!target) {
2739
+ throw createHubDiscoveryError(
2740
+ 'pin-direct-unreachable',
2741
+ `No reachable LiveDesk Hub endpoint yet. Checked ${endpointCandidates.join(', ')}.`
2742
+ );
2743
+ }
2744
+ return {
2745
+ manager: target.manager,
2746
+ pair: result.pair_token,
2747
+ hubDeviceId: String(result.node_id || '').trim(),
2748
+ endpointCandidates,
2749
+ directReachable: target.directReachable,
2750
+ connectionTransport: target.connectionTransport,
2751
+ discoverySource: 'supabase'
2752
+ };
2753
+ }
2674
2754
  });
2675
- if (!target) {
2676
- throw new Error(`No reachable LiveDesk Hub endpoint yet. Checked ${endpointCandidates.join(', ')}.`);
2755
+ if (resolved.discoverySource === 'supabase') {
2756
+ writeCachedHubTarget(cacheOwnerKey, resolved);
2677
2757
  }
2678
-
2679
- const resolved = {
2680
- manager: target.manager,
2681
- pair: result.pair_token,
2682
- hubDeviceId: String(result.node_id || '').trim(),
2683
- endpointCandidates,
2684
- directReachable: target.directReachable,
2685
- connectionTransport: target.connectionTransport,
2686
- discoverySource: 'supabase'
2687
- };
2688
- writeCachedHubTarget(cacheOwnerKey, resolved);
2758
+ console.log(resolved.connectionTransport === 'relay-fallback' && resolved.discoverySource === 'cache'
2759
+ ? 'Fresh PIN registry lookup was unavailable; using the saved PIN-bound identity for the bounded encrypted relay path.'
2760
+ : resolved.discoverySource === 'cache'
2761
+ ? `Fresh PIN registry lookup was unavailable; using the responding saved direct Hub endpoint ${resolved.manager}.`
2762
+ : `Found current PIN-bound LiveDesk Hub at ${resolved.manager}.`);
2689
2763
  return resolved;
2690
- }
2764
+ }
2691
2765
 
2692
2766
  async function waitForManagerFromSavedPin(supabase, pin, options = {}) {
2693
2767
  const configuredIntervalMs = Number(options.intervalMs || 0);
@@ -2702,7 +2776,8 @@ async function waitForManagerFromSavedPin(supabase, pin, options = {}) {
2702
2776
  attempts += 1;
2703
2777
  try {
2704
2778
  return await resolveManagerFromPin(supabase, pin, {
2705
- allowRelayFallback: options.allowRelayFallback === true
2779
+ allowRelayFallback: options.allowRelayFallback === true,
2780
+ relayEndpoint: options.relayEndpoint
2706
2781
  });
2707
2782
  } catch (err) {
2708
2783
  if (shouldStop()) {
@@ -2856,9 +2931,11 @@ async function startConnectionChoiceServer(supabase, options = {}) {
2856
2931
  }
2857
2932
  dashboardState.message = 'Saved LiveDesk PIN found. Waiting for the Hub to become reachable.';
2858
2933
  try {
2859
- const resolved = await waitForManagerFromSavedPin(supabase, savedPin, {
2860
- shouldStop: () => completed || dashboardState.loggedOut
2861
- });
2934
+ const resolved = await waitForManagerFromSavedPin(supabase, savedPin, {
2935
+ allowRelayFallback: options.allowRelayFallback === true,
2936
+ relayEndpoint: options.relayEndpoint,
2937
+ shouldStop: () => completed || dashboardState.loggedOut
2938
+ });
2862
2939
  if (!resolved) {
2863
2940
  return;
2864
2941
  }
@@ -3088,7 +3165,8 @@ async function startConnectionChoiceServer(supabase, options = {}) {
3088
3165
  pin = normalizePairingPin(pin);
3089
3166
  try {
3090
3167
  const resolved = await resolveManagerFromPin(supabase, pin, {
3091
- allowRelayFallback: options.allowRelayFallback === true
3168
+ allowRelayFallback: options.allowRelayFallback === true,
3169
+ relayEndpoint: options.relayEndpoint
3092
3170
  });
3093
3171
  writeSavedPin(pin);
3094
3172
  applyStartupPreference(pendingStartup, startupArgs);
@@ -3223,6 +3301,8 @@ async function chooseClientConnection(supabase, options = {}) {
3223
3301
  deviceId: options.deviceId,
3224
3302
  engine: options.engine,
3225
3303
  slot: options.slot,
3304
+ assignedHubId: process.env.LIVEDESK_ASSIGNED_HUB_ID,
3305
+ roleVersion: process.env.LIVEDESK_ROLE_VERSION,
3226
3306
  savedSession: options.savedSession,
3227
3307
  initialChoice: options.initialChoice,
3228
3308
  initialChoiceMessage: options.initialChoiceMessage,
@@ -3251,7 +3331,8 @@ async function chooseClientConnection(supabase, options = {}) {
3251
3331
  resolvePin: pin => {
3252
3332
  if (!supabase) throw new Error('supabase-session-required');
3253
3333
  return resolveManagerFromPin(supabase, pin, {
3254
- allowRelayFallback: options.allowRelayFallback === true
3334
+ allowRelayFallback: options.allowRelayFallback === true,
3335
+ relayEndpoint: options.relayEndpoint
3255
3336
  });
3256
3337
  },
3257
3338
  changeRole: async (role, snapshot) => {
@@ -3350,7 +3431,8 @@ async function chooseClientConnection(supabase, options = {}) {
3350
3431
  startupArgs: options.startupArgs,
3351
3432
  savedSession: options.savedSession,
3352
3433
  savedPin: options.savedPin,
3353
- allowRelayFallback: options.allowRelayFallback === true
3434
+ allowRelayFallback: options.allowRelayFallback === true,
3435
+ relayEndpoint: options.relayEndpoint
3354
3436
  });
3355
3437
  if (options.openBrowser !== false) {
3356
3438
  console.log('Opening LiveDesk connection page...');
@@ -3528,10 +3610,16 @@ export async function chooseManagerConnectionTarget(candidates, options = {}) {
3528
3610
  }
3529
3611
 
3530
3612
  export async function resolveManagerFromCachedTarget(ownerKey, options = {}) {
3531
- const cached = options.cachedTarget || readCachedHubTarget(ownerKey);
3613
+ const cached = Object.prototype.hasOwnProperty.call(options, 'cachedTarget')
3614
+ ? normalizeCachedHubTarget(options.cachedTarget, ownerKey, options.now)
3615
+ : readCachedHubTarget(ownerKey);
3532
3616
  if (!cached) return null;
3533
- const manager = await chooseReachableEndpoint(cached.endpointCandidates, { requireReachable: true });
3534
- if (!manager) return null;
3617
+ const target = await chooseManagerConnectionTarget(cached.endpointCandidates, {
3618
+ allowRelayFallback: false,
3619
+ probeEndpoint: options.probeEndpoint
3620
+ });
3621
+ if (!target) return null;
3622
+ const manager = target.manager;
3535
3623
  const resolved = {
3536
3624
  manager,
3537
3625
  pair: cached.pair,
@@ -3540,6 +3628,8 @@ export async function resolveManagerFromCachedTarget(ownerKey, options = {}) {
3540
3628
  manager,
3541
3629
  ...cached.endpointCandidates.filter(endpoint => endpoint !== manager)
3542
3630
  ],
3631
+ directReachable: target.directReachable,
3632
+ connectionTransport: target.connectionTransport,
3543
3633
  discoverySource: 'cache'
3544
3634
  };
3545
3635
  if (options.persist !== false) {
@@ -3548,6 +3638,86 @@ export async function resolveManagerFromCachedTarget(ownerKey, options = {}) {
3548
3638
  return resolved;
3549
3639
  }
3550
3640
 
3641
+ export function resolveManagerFromCachedRelayFallback(ownerKey, options = {}) {
3642
+ if (options.allowRelayFallback !== true) return null;
3643
+ const cached = Object.prototype.hasOwnProperty.call(options, 'cachedTarget')
3644
+ ? normalizeCachedHubTarget(options.cachedTarget, ownerKey, options.now)
3645
+ : readCachedHubTarget(ownerKey);
3646
+ if (!cached) return null;
3647
+ const normalizedOwner = String(cached.ownerKey || '');
3648
+ const accountIdentityComplete = normalizedOwner.startsWith('user:')
3649
+ && normalizedOwner.length > 'user:'.length
3650
+ && Boolean(cached.hubDeviceId);
3651
+ // The public PIN resolver intentionally does not expose node_id. Its
3652
+ // previous signed pair/endpoints remain bound to the exact saved six-digit
3653
+ // PIN owner in the private, finite-lifetime cache.
3654
+ const pinIdentityComplete = /^pin:[0-9]{6}$/.test(normalizedOwner);
3655
+ const relayEndpoint = String(options.relayEndpoint || '').trim().replace(/^tcp:\/\//i, '');
3656
+ if ((!accountIdentityComplete && !pinIdentityComplete) || !parseManagerEndpoint(relayEndpoint)) {
3657
+ return null;
3658
+ }
3659
+ return {
3660
+ manager: cached.endpointCandidates[0],
3661
+ pair: cached.pair,
3662
+ hubDeviceId: cached.hubDeviceId,
3663
+ endpointCandidates: [...cached.endpointCandidates],
3664
+ directReachable: false,
3665
+ connectionTransport: 'relay-fallback',
3666
+ discoverySource: 'cache'
3667
+ };
3668
+ }
3669
+
3670
+ export async function resolveManagerWithCachedRecovery(ownerKey, options = {}) {
3671
+ const cached = Object.prototype.hasOwnProperty.call(options, 'cachedTarget')
3672
+ ? normalizeCachedHubTarget(options.cachedTarget, ownerKey, options.now)
3673
+ : readCachedHubTarget(ownerKey);
3674
+ try {
3675
+ if (typeof options.resolveFreshTarget !== 'function') {
3676
+ throw createHubDiscoveryError('fresh-registry-resolver-required', 'Fresh Hub registry resolver is required.');
3677
+ }
3678
+ const configuredFreshTimeoutMs = Number(options.freshTimeoutMs);
3679
+ const freshTimeoutMs = Number.isFinite(configuredFreshTimeoutMs)
3680
+ ? Math.max(FRESH_REGISTRY_TIMEOUT_MIN_MS, Math.min(FRESH_REGISTRY_TIMEOUT_MAX_MS, configuredFreshTimeoutMs))
3681
+ : FRESH_REGISTRY_TIMEOUT_MS;
3682
+ let timeoutHandle = null;
3683
+ const fresh = await Promise.race([
3684
+ Promise.resolve().then(() => options.resolveFreshTarget()),
3685
+ new Promise((_, reject) => {
3686
+ timeoutHandle = setTimeout(() => reject(createHubDiscoveryError(
3687
+ 'hub-registry-timeout',
3688
+ `Fresh LiveDesk Hub registry lookup exceeded ${freshTimeoutMs}ms.`
3689
+ )), freshTimeoutMs);
3690
+ })
3691
+ ]).finally(() => clearTimeout(timeoutHandle));
3692
+ if (!fresh?.manager || !fresh?.pair) {
3693
+ throw createHubDiscoveryError('fresh-registry-target-invalid', 'Fresh Hub registry target is incomplete.');
3694
+ }
3695
+ return {
3696
+ ...fresh,
3697
+ discoverySource: fresh.discoverySource || 'supabase'
3698
+ };
3699
+ } catch (error) {
3700
+ if (canUseCachedRelayAfterRegistryError(error)) {
3701
+ const direct = await resolveManagerFromCachedTarget(ownerKey, {
3702
+ cachedTarget: cached,
3703
+ probeEndpoint: options.probeEndpoint,
3704
+ // A TCP listener is not pair-token authentication. Only a
3705
+ // fresh signed registry result may extend cache trust.
3706
+ persist: false
3707
+ });
3708
+ if (direct) return direct;
3709
+ const relay = resolveManagerFromCachedRelayFallback(ownerKey, {
3710
+ cachedTarget: cached,
3711
+ allowRelayFallback: options.allowRelayFallback === true,
3712
+ relayEndpoint: options.relayEndpoint,
3713
+ now: options.now
3714
+ });
3715
+ if (relay) return relay;
3716
+ }
3717
+ throw error;
3718
+ }
3719
+ }
3720
+
3551
3721
  export function getDiscoveryRetryDelay(attempt, configuredIntervalMs = 0, random = Math.random) {
3552
3722
  const configured = Number(configuredIntervalMs);
3553
3723
  if (Number.isFinite(configured) && configured > 0) {
@@ -3599,34 +3769,50 @@ function normalizeEndpointCandidates(target) {
3599
3769
  return [...new Set(rawCandidates.map(value => String(value || '').trim()).filter(Boolean))];
3600
3770
  }
3601
3771
 
3602
- async function resolveManagerFromSupabase(supabase, options = {}) {
3772
+ export async function resolveManagerFromSupabase(supabase, options = {}) {
3603
3773
  await refreshSessionIfNeeded(supabase);
3604
3774
  const { data, error } = await supabase
3605
3775
  .from('livedesk_remote_host_targets')
3606
3776
  .select('node_id, endpoint, endpoint_candidates, pair_token, active, expires_at, updated_at, manager_version')
3607
3777
  .eq('product_key', 'livedesk')
3608
3778
  .maybeSingle();
3609
- if (error) {
3610
- throw error;
3611
- }
3612
- if (!data?.active) {
3613
- throw new Error('No active LiveDesk Hub was found for this Google account. Start LiveDesk Hub on the screen wall computer and sign in there first.');
3614
- }
3615
- if (data.expires_at && Date.parse(data.expires_at) <= Date.now()) {
3616
- throw new Error('The LiveDesk Hub record is expired. Open the Hub screen again while signed in.');
3617
- }
3618
- if (!data.pair_token) {
3619
- throw new Error('The LiveDesk Hub record is missing its private connection token.');
3620
- }
3621
- const endpointCandidates = normalizeEndpointCandidates(data);
3622
- if (endpointCandidates.length === 0) {
3623
- throw new Error('The LiveDesk Hub record does not contain a reachable local address.');
3624
- }
3779
+ if (error) {
3780
+ throw error;
3781
+ }
3782
+ if (!data?.active) {
3783
+ throw createHubDiscoveryError(
3784
+ 'hub-registry-missing',
3785
+ 'No active LiveDesk Hub was found for this Google account. Start LiveDesk Hub on the screen wall computer and sign in there first.'
3786
+ );
3787
+ }
3788
+ if (data.expires_at && Date.parse(data.expires_at) <= Date.now()) {
3789
+ throw createHubDiscoveryError(
3790
+ 'hub-registry-expired',
3791
+ 'The LiveDesk Hub record is expired. Open the Hub screen again while signed in.'
3792
+ );
3793
+ }
3794
+ if (!data.pair_token) {
3795
+ throw createHubDiscoveryError(
3796
+ 'hub-registry-pair-token-missing',
3797
+ 'The LiveDesk Hub record is missing its private connection token.'
3798
+ );
3799
+ }
3800
+ const endpointCandidates = normalizeEndpointCandidates(data);
3801
+ if (endpointCandidates.length === 0) {
3802
+ throw createHubDiscoveryError(
3803
+ 'hub-registry-endpoints-missing',
3804
+ 'The LiveDesk Hub record does not contain a reachable local address.'
3805
+ );
3806
+ }
3625
3807
  const target = await chooseManagerConnectionTarget(endpointCandidates, {
3626
- allowRelayFallback: options.allowRelayFallback === true
3808
+ allowRelayFallback: options.allowRelayFallback === true,
3809
+ probeEndpoint: options.probeEndpoint
3627
3810
  });
3628
3811
  if (!target) {
3629
- throw new Error(`No reachable LiveDesk Hub endpoint yet. Checked ${endpointCandidates.join(', ')}.`);
3812
+ throw createHubDiscoveryError(
3813
+ 'hub-direct-unreachable',
3814
+ `No reachable LiveDesk Hub endpoint yet. Checked ${endpointCandidates.join(', ')}.`
3815
+ );
3630
3816
  }
3631
3817
  return {
3632
3818
  manager: target.manager,
@@ -3669,6 +3855,7 @@ async function waitForManagerFromSupabase(supabase, options = {}) {
3669
3855
  let attempts = 0;
3670
3856
  let lastMessage = '';
3671
3857
  let wakeListener = null;
3858
+ let initialError = options.initialError || null;
3672
3859
  console.log('Waiting for a LiveDesk Hub. LiveDesk is listening for Hub-online events with adaptive registry retries as a fallback.');
3673
3860
  try {
3674
3861
  while (true) {
@@ -3677,6 +3864,11 @@ async function waitForManagerFromSupabase(supabase, options = {}) {
3677
3864
  }
3678
3865
  attempts += 1;
3679
3866
  try {
3867
+ if (initialError) {
3868
+ const error = initialError;
3869
+ initialError = null;
3870
+ throw error;
3871
+ }
3680
3872
  return await resolveManagerFromSupabase(supabase, {
3681
3873
  allowRelayFallback: options.allowRelayFallback === true
3682
3874
  });
@@ -3752,7 +3944,8 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3752
3944
  choice = { type: 'google', session: savedSession };
3753
3945
  } else if ((parsed.startupRun || existingConnectionPage) && savedPin) {
3754
3946
  const resolved = await waitForManagerFromSavedPin(supabase, savedPin, {
3755
- allowRelayFallback
3947
+ allowRelayFallback,
3948
+ relayEndpoint: parsed.relay
3756
3949
  });
3757
3950
  choice = { type: 'pin', ...resolved };
3758
3951
  } else if (existingConnectionPage && previousChoice) {
@@ -3769,8 +3962,9 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3769
3962
  savedSession,
3770
3963
  savedPin,
3771
3964
  allowRelayFallback,
3965
+ relayEndpoint: parsed.relay,
3772
3966
  openBrowser: parsed.openBrowserOnStart
3773
- });
3967
+ });
3774
3968
  }
3775
3969
  connectionPage = existingConnectionPage || choice.connectionPage || null;
3776
3970
  if (choice.type === 'pin') {
@@ -3792,26 +3986,39 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3792
3986
  : 'Connected by LiveDesk PIN.');
3793
3987
  } else {
3794
3988
  const cacheOwnerKey = hubCacheOwnerForSession(choice.session);
3795
- let resolved = await resolveManagerFromCachedTarget(cacheOwnerKey);
3796
3989
  let session = choice.session;
3797
- discoverySource = resolved ? 'cache' : 'supabase';
3798
- if (resolved) {
3799
- connectionTransport = resolved.connectionTransport || 'direct-tcp';
3800
- directReachable = resolved.directReachable !== false;
3990
+ let initialDiscoveryError = null;
3991
+ let resolved = null;
3992
+ try {
3993
+ resolved = await resolveManagerWithCachedRecovery(cacheOwnerKey, {
3994
+ allowRelayFallback,
3995
+ relayEndpoint: parsed.relay,
3996
+ resolveFreshTarget: async () => {
3997
+ // The signed registry is authoritative for Hub replacement
3998
+ // and pair-token rotation. Cache direct/relay recovery is
3999
+ // considered only after this one current lookup reports
4000
+ // an explicitly recoverable availability failure.
4001
+ try {
4002
+ session = await activateSupabaseSession(supabase, choice.session);
4003
+ } catch (error) {
4004
+ if (!isRefreshTokenAlreadyUsedError(error)) throw error;
4005
+ session = readSavedSessionFromFile() || choice.session;
4006
+ console.warn('[LiveDesk Client] Another auth owner rotated the refresh token. Keeping the runtime alive while the saved session catches up.');
4007
+ }
4008
+ return await resolveManagerFromSupabase(supabase, {
4009
+ allowRelayFallback
4010
+ });
4011
+ }
4012
+ });
4013
+ } catch (error) {
4014
+ initialDiscoveryError = error;
4015
+ }
4016
+ discoverySource = resolved?.discoverySource || 'supabase';
4017
+ if (resolved?.discoverySource === 'cache') {
3801
4018
  writeSavedSessionToFile(session);
3802
- console.log(`Found cached LiveDesk Hub at ${resolved.manager}; skipped registry discovery.`);
3803
- } else {
3804
- // The browser and local runtime are separate auth owners.
3805
- // Activate the accepted browser session only when registry
3806
- // discovery is needed; a reachable account-bound cache can
3807
- // start the Agent without a Supabase round trip.
3808
- try {
3809
- session = await activateSupabaseSession(supabase, choice.session);
3810
- } catch (error) {
3811
- if (!isRefreshTokenAlreadyUsedError(error)) throw error;
3812
- session = readSavedSessionFromFile() || choice.session;
3813
- console.warn('[LiveDesk Client] Another auth owner rotated the refresh token. Keeping the runtime alive while the saved session catches up.');
3814
- }
4019
+ console.log(resolved.connectionTransport === 'relay-fallback'
4020
+ ? `Fresh Hub registry lookup was unavailable; using saved account-bound Hub identity ${resolved.hubDeviceId} for the bounded encrypted relay path.`
4021
+ : `Fresh Hub registry lookup was unavailable; using the responding saved direct Hub endpoint ${resolved.manager}.`);
3815
4022
  }
3816
4023
  choice.session = session;
3817
4024
  const email = session?.user?.email ? ` as ${session.user.email}` : '';
@@ -3819,18 +4026,20 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3819
4026
  if (!resolved) {
3820
4027
  resolved = await waitForManagerFromSupabase(supabase, {
3821
4028
  allowRelayFallback,
4029
+ initialError: initialDiscoveryError,
3822
4030
  shouldStop: () => Boolean(roleRestartRequest?.role)
3823
4031
  });
4032
+ discoverySource = 'supabase';
3824
4033
  }
3825
4034
  if (!resolved && roleRestartRequest?.role) {
3826
- return {
3827
- ...parsed,
3828
- manager,
3829
- pair,
3830
- slot: normalizeSlotNumber(parsed.slot),
3831
- connectionPage,
3832
- forwarded,
3833
- rediscoverOnDisconnect: shouldLogin,
4035
+ return {
4036
+ ...parsed,
4037
+ manager,
4038
+ pair,
4039
+ slot: normalizeSlotNumber(parsed.slot),
4040
+ connectionPage,
4041
+ forwarded,
4042
+ rediscoverOnDisconnect: shouldLogin,
3834
4043
  rediscoverOnInvalidPair: shouldLogin
3835
4044
  };
3836
4045
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.204",
3
+ "version": "0.1.206",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,10 +40,10 @@
40
40
  "ws": "^8.18.3"
41
41
  },
42
42
  "optionalDependencies": {
43
- "@livedesk/fast-linux-x64": "0.1.411",
44
- "@livedesk/fast-osx-arm64": "0.1.411",
45
- "@livedesk/fast-osx-x64": "0.1.411",
46
- "@livedesk/fast-win-x64": "0.1.411"
43
+ "@livedesk/fast-linux-x64": "0.1.412",
44
+ "@livedesk/fast-osx-arm64": "0.1.412",
45
+ "@livedesk/fast-osx-x64": "0.1.412",
46
+ "@livedesk/fast-win-x64": "0.1.412"
47
47
  },
48
48
  "publishConfig": {
49
49
  "access": "public"
@@ -150,6 +150,11 @@ function normalizeSlotNumber(value) {
150
150
  : 0;
151
151
  }
152
152
 
153
+ function normalizeRoleVersion(value) {
154
+ const roleVersion = Number(String(value ?? '').trim());
155
+ return Number.isInteger(roleVersion) && roleVersion >= 0 ? roleVersion : 0;
156
+ }
157
+
153
158
  function readCpuTotals() {
154
159
  return os.cpus().reduce((totals, cpu) => {
155
160
  const times = Object.values(cpu.times || {}).map(Number);
@@ -655,9 +660,9 @@ export function createClientRuntimeServer(options = {}) {
655
660
  manager: '',
656
661
  pairToken: '',
657
662
  slotNumber: normalizeSlotNumber(options.slot),
658
- assignedHubId: '',
663
+ assignedHubId: normalizeString(options.assignedHubId ?? process.env.LIVEDESK_ASSIGNED_HUB_ID, 160),
659
664
  endpointCandidates: [],
660
- roleVersion: 0,
665
+ roleVersion: normalizeRoleVersion(options.roleVersion ?? process.env.LIVEDESK_ROLE_VERSION),
661
666
  connectedAt: '',
662
667
  message: 'Sign in with Google or enter a Hub PIN to start this Client.',
663
668
  startup: false,
@@ -788,6 +793,12 @@ export function createClientRuntimeServer(options = {}) {
788
793
  name: accountProfile.name || state.auth.name,
789
794
  avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
790
795
  };
796
+ state.lastError = '';
797
+ if (state.message.startsWith('Client authentication needs attention:')) {
798
+ state.message = state.agent.state === 'running'
799
+ ? 'Connected to the LiveDesk Hub.'
800
+ : normalizeString(message, 1000) || 'Client credentials accepted. Finding the Hub.';
801
+ }
791
802
  }
792
803
  return;
793
804
  }
@@ -795,7 +806,9 @@ export function createClientRuntimeServer(options = {}) {
795
806
  loggedOut = false;
796
807
  state.connectedAt = new Date().toISOString();
797
808
  state.manager = normalizeString(choice?.manager, 256);
798
- state.assignedHubId = normalizeString(choice?.hubDeviceId, 160);
809
+ if (Object.prototype.hasOwnProperty.call(choice || {}, 'hubDeviceId')) {
810
+ state.assignedHubId = normalizeString(choice?.hubDeviceId, 160);
811
+ }
799
812
  state.endpointCandidates = Array.isArray(choice?.endpointCandidates)
800
813
  ? choice.endpointCandidates.map(value => normalizeString(value, 256)).filter(Boolean)
801
814
  : [];
@@ -1337,7 +1350,9 @@ export function createClientRuntimeServer(options = {}) {
1337
1350
  if (patch.manager) state.manager = normalizeString(patch.manager, 256);
1338
1351
  if (patch.pairToken) state.pairToken = normalizeString(patch.pairToken, 256);
1339
1352
  if (normalizeSlotNumber(patch.slotNumber)) state.slotNumber = normalizeSlotNumber(patch.slotNumber);
1340
- if (patch.assignedHubId) state.assignedHubId = normalizeString(patch.assignedHubId, 160);
1353
+ if (Object.prototype.hasOwnProperty.call(patch, 'assignedHubId')) {
1354
+ state.assignedHubId = normalizeString(patch.assignedHubId, 160);
1355
+ }
1341
1356
  if (patch.message) state.message = normalizeString(patch.message, 1000);
1342
1357
  if (Number.isInteger(patch.roleVersion)) state.roleVersion = patch.roleVersion;
1343
1358
  if (Array.isArray(patch.endpointCandidates)) state.endpointCandidates = patch.endpointCandidates;