livedesk 0.1.449 → 0.1.450

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);
@@ -3251,7 +3329,8 @@ async function chooseClientConnection(supabase, options = {}) {
3251
3329
  resolvePin: pin => {
3252
3330
  if (!supabase) throw new Error('supabase-session-required');
3253
3331
  return resolveManagerFromPin(supabase, pin, {
3254
- allowRelayFallback: options.allowRelayFallback === true
3332
+ allowRelayFallback: options.allowRelayFallback === true,
3333
+ relayEndpoint: options.relayEndpoint
3255
3334
  });
3256
3335
  },
3257
3336
  changeRole: async (role, snapshot) => {
@@ -3350,7 +3429,8 @@ async function chooseClientConnection(supabase, options = {}) {
3350
3429
  startupArgs: options.startupArgs,
3351
3430
  savedSession: options.savedSession,
3352
3431
  savedPin: options.savedPin,
3353
- allowRelayFallback: options.allowRelayFallback === true
3432
+ allowRelayFallback: options.allowRelayFallback === true,
3433
+ relayEndpoint: options.relayEndpoint
3354
3434
  });
3355
3435
  if (options.openBrowser !== false) {
3356
3436
  console.log('Opening LiveDesk connection page...');
@@ -3528,10 +3608,16 @@ export async function chooseManagerConnectionTarget(candidates, options = {}) {
3528
3608
  }
3529
3609
 
3530
3610
  export async function resolveManagerFromCachedTarget(ownerKey, options = {}) {
3531
- const cached = options.cachedTarget || readCachedHubTarget(ownerKey);
3611
+ const cached = Object.prototype.hasOwnProperty.call(options, 'cachedTarget')
3612
+ ? normalizeCachedHubTarget(options.cachedTarget, ownerKey, options.now)
3613
+ : readCachedHubTarget(ownerKey);
3532
3614
  if (!cached) return null;
3533
- const manager = await chooseReachableEndpoint(cached.endpointCandidates, { requireReachable: true });
3534
- if (!manager) return null;
3615
+ const target = await chooseManagerConnectionTarget(cached.endpointCandidates, {
3616
+ allowRelayFallback: false,
3617
+ probeEndpoint: options.probeEndpoint
3618
+ });
3619
+ if (!target) return null;
3620
+ const manager = target.manager;
3535
3621
  const resolved = {
3536
3622
  manager,
3537
3623
  pair: cached.pair,
@@ -3540,6 +3626,8 @@ export async function resolveManagerFromCachedTarget(ownerKey, options = {}) {
3540
3626
  manager,
3541
3627
  ...cached.endpointCandidates.filter(endpoint => endpoint !== manager)
3542
3628
  ],
3629
+ directReachable: target.directReachable,
3630
+ connectionTransport: target.connectionTransport,
3543
3631
  discoverySource: 'cache'
3544
3632
  };
3545
3633
  if (options.persist !== false) {
@@ -3548,6 +3636,86 @@ export async function resolveManagerFromCachedTarget(ownerKey, options = {}) {
3548
3636
  return resolved;
3549
3637
  }
3550
3638
 
3639
+ export function resolveManagerFromCachedRelayFallback(ownerKey, options = {}) {
3640
+ if (options.allowRelayFallback !== true) return null;
3641
+ const cached = Object.prototype.hasOwnProperty.call(options, 'cachedTarget')
3642
+ ? normalizeCachedHubTarget(options.cachedTarget, ownerKey, options.now)
3643
+ : readCachedHubTarget(ownerKey);
3644
+ if (!cached) return null;
3645
+ const normalizedOwner = String(cached.ownerKey || '');
3646
+ const accountIdentityComplete = normalizedOwner.startsWith('user:')
3647
+ && normalizedOwner.length > 'user:'.length
3648
+ && Boolean(cached.hubDeviceId);
3649
+ // The public PIN resolver intentionally does not expose node_id. Its
3650
+ // previous signed pair/endpoints remain bound to the exact saved six-digit
3651
+ // PIN owner in the private, finite-lifetime cache.
3652
+ const pinIdentityComplete = /^pin:[0-9]{6}$/.test(normalizedOwner);
3653
+ const relayEndpoint = String(options.relayEndpoint || '').trim().replace(/^tcp:\/\//i, '');
3654
+ if ((!accountIdentityComplete && !pinIdentityComplete) || !parseManagerEndpoint(relayEndpoint)) {
3655
+ return null;
3656
+ }
3657
+ return {
3658
+ manager: cached.endpointCandidates[0],
3659
+ pair: cached.pair,
3660
+ hubDeviceId: cached.hubDeviceId,
3661
+ endpointCandidates: [...cached.endpointCandidates],
3662
+ directReachable: false,
3663
+ connectionTransport: 'relay-fallback',
3664
+ discoverySource: 'cache'
3665
+ };
3666
+ }
3667
+
3668
+ export async function resolveManagerWithCachedRecovery(ownerKey, options = {}) {
3669
+ const cached = Object.prototype.hasOwnProperty.call(options, 'cachedTarget')
3670
+ ? normalizeCachedHubTarget(options.cachedTarget, ownerKey, options.now)
3671
+ : readCachedHubTarget(ownerKey);
3672
+ try {
3673
+ if (typeof options.resolveFreshTarget !== 'function') {
3674
+ throw createHubDiscoveryError('fresh-registry-resolver-required', 'Fresh Hub registry resolver is required.');
3675
+ }
3676
+ const configuredFreshTimeoutMs = Number(options.freshTimeoutMs);
3677
+ const freshTimeoutMs = Number.isFinite(configuredFreshTimeoutMs)
3678
+ ? Math.max(FRESH_REGISTRY_TIMEOUT_MIN_MS, Math.min(FRESH_REGISTRY_TIMEOUT_MAX_MS, configuredFreshTimeoutMs))
3679
+ : FRESH_REGISTRY_TIMEOUT_MS;
3680
+ let timeoutHandle = null;
3681
+ const fresh = await Promise.race([
3682
+ Promise.resolve().then(() => options.resolveFreshTarget()),
3683
+ new Promise((_, reject) => {
3684
+ timeoutHandle = setTimeout(() => reject(createHubDiscoveryError(
3685
+ 'hub-registry-timeout',
3686
+ `Fresh LiveDesk Hub registry lookup exceeded ${freshTimeoutMs}ms.`
3687
+ )), freshTimeoutMs);
3688
+ })
3689
+ ]).finally(() => clearTimeout(timeoutHandle));
3690
+ if (!fresh?.manager || !fresh?.pair) {
3691
+ throw createHubDiscoveryError('fresh-registry-target-invalid', 'Fresh Hub registry target is incomplete.');
3692
+ }
3693
+ return {
3694
+ ...fresh,
3695
+ discoverySource: fresh.discoverySource || 'supabase'
3696
+ };
3697
+ } catch (error) {
3698
+ if (canUseCachedRelayAfterRegistryError(error)) {
3699
+ const direct = await resolveManagerFromCachedTarget(ownerKey, {
3700
+ cachedTarget: cached,
3701
+ probeEndpoint: options.probeEndpoint,
3702
+ // A TCP listener is not pair-token authentication. Only a
3703
+ // fresh signed registry result may extend cache trust.
3704
+ persist: false
3705
+ });
3706
+ if (direct) return direct;
3707
+ const relay = resolveManagerFromCachedRelayFallback(ownerKey, {
3708
+ cachedTarget: cached,
3709
+ allowRelayFallback: options.allowRelayFallback === true,
3710
+ relayEndpoint: options.relayEndpoint,
3711
+ now: options.now
3712
+ });
3713
+ if (relay) return relay;
3714
+ }
3715
+ throw error;
3716
+ }
3717
+ }
3718
+
3551
3719
  export function getDiscoveryRetryDelay(attempt, configuredIntervalMs = 0, random = Math.random) {
3552
3720
  const configured = Number(configuredIntervalMs);
3553
3721
  if (Number.isFinite(configured) && configured > 0) {
@@ -3599,34 +3767,50 @@ function normalizeEndpointCandidates(target) {
3599
3767
  return [...new Set(rawCandidates.map(value => String(value || '').trim()).filter(Boolean))];
3600
3768
  }
3601
3769
 
3602
- async function resolveManagerFromSupabase(supabase, options = {}) {
3770
+ export async function resolveManagerFromSupabase(supabase, options = {}) {
3603
3771
  await refreshSessionIfNeeded(supabase);
3604
3772
  const { data, error } = await supabase
3605
3773
  .from('livedesk_remote_host_targets')
3606
3774
  .select('node_id, endpoint, endpoint_candidates, pair_token, active, expires_at, updated_at, manager_version')
3607
3775
  .eq('product_key', 'livedesk')
3608
3776
  .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
- }
3777
+ if (error) {
3778
+ throw error;
3779
+ }
3780
+ if (!data?.active) {
3781
+ throw createHubDiscoveryError(
3782
+ 'hub-registry-missing',
3783
+ 'No active LiveDesk Hub was found for this Google account. Start LiveDesk Hub on the screen wall computer and sign in there first.'
3784
+ );
3785
+ }
3786
+ if (data.expires_at && Date.parse(data.expires_at) <= Date.now()) {
3787
+ throw createHubDiscoveryError(
3788
+ 'hub-registry-expired',
3789
+ 'The LiveDesk Hub record is expired. Open the Hub screen again while signed in.'
3790
+ );
3791
+ }
3792
+ if (!data.pair_token) {
3793
+ throw createHubDiscoveryError(
3794
+ 'hub-registry-pair-token-missing',
3795
+ 'The LiveDesk Hub record is missing its private connection token.'
3796
+ );
3797
+ }
3798
+ const endpointCandidates = normalizeEndpointCandidates(data);
3799
+ if (endpointCandidates.length === 0) {
3800
+ throw createHubDiscoveryError(
3801
+ 'hub-registry-endpoints-missing',
3802
+ 'The LiveDesk Hub record does not contain a reachable local address.'
3803
+ );
3804
+ }
3625
3805
  const target = await chooseManagerConnectionTarget(endpointCandidates, {
3626
- allowRelayFallback: options.allowRelayFallback === true
3806
+ allowRelayFallback: options.allowRelayFallback === true,
3807
+ probeEndpoint: options.probeEndpoint
3627
3808
  });
3628
3809
  if (!target) {
3629
- throw new Error(`No reachable LiveDesk Hub endpoint yet. Checked ${endpointCandidates.join(', ')}.`);
3810
+ throw createHubDiscoveryError(
3811
+ 'hub-direct-unreachable',
3812
+ `No reachable LiveDesk Hub endpoint yet. Checked ${endpointCandidates.join(', ')}.`
3813
+ );
3630
3814
  }
3631
3815
  return {
3632
3816
  manager: target.manager,
@@ -3669,6 +3853,7 @@ async function waitForManagerFromSupabase(supabase, options = {}) {
3669
3853
  let attempts = 0;
3670
3854
  let lastMessage = '';
3671
3855
  let wakeListener = null;
3856
+ let initialError = options.initialError || null;
3672
3857
  console.log('Waiting for a LiveDesk Hub. LiveDesk is listening for Hub-online events with adaptive registry retries as a fallback.');
3673
3858
  try {
3674
3859
  while (true) {
@@ -3677,6 +3862,11 @@ async function waitForManagerFromSupabase(supabase, options = {}) {
3677
3862
  }
3678
3863
  attempts += 1;
3679
3864
  try {
3865
+ if (initialError) {
3866
+ const error = initialError;
3867
+ initialError = null;
3868
+ throw error;
3869
+ }
3680
3870
  return await resolveManagerFromSupabase(supabase, {
3681
3871
  allowRelayFallback: options.allowRelayFallback === true
3682
3872
  });
@@ -3752,7 +3942,8 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3752
3942
  choice = { type: 'google', session: savedSession };
3753
3943
  } else if ((parsed.startupRun || existingConnectionPage) && savedPin) {
3754
3944
  const resolved = await waitForManagerFromSavedPin(supabase, savedPin, {
3755
- allowRelayFallback
3945
+ allowRelayFallback,
3946
+ relayEndpoint: parsed.relay
3756
3947
  });
3757
3948
  choice = { type: 'pin', ...resolved };
3758
3949
  } else if (existingConnectionPage && previousChoice) {
@@ -3769,8 +3960,9 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3769
3960
  savedSession,
3770
3961
  savedPin,
3771
3962
  allowRelayFallback,
3963
+ relayEndpoint: parsed.relay,
3772
3964
  openBrowser: parsed.openBrowserOnStart
3773
- });
3965
+ });
3774
3966
  }
3775
3967
  connectionPage = existingConnectionPage || choice.connectionPage || null;
3776
3968
  if (choice.type === 'pin') {
@@ -3792,26 +3984,39 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3792
3984
  : 'Connected by LiveDesk PIN.');
3793
3985
  } else {
3794
3986
  const cacheOwnerKey = hubCacheOwnerForSession(choice.session);
3795
- let resolved = await resolveManagerFromCachedTarget(cacheOwnerKey);
3796
3987
  let session = choice.session;
3797
- discoverySource = resolved ? 'cache' : 'supabase';
3798
- if (resolved) {
3799
- connectionTransport = resolved.connectionTransport || 'direct-tcp';
3800
- directReachable = resolved.directReachable !== false;
3988
+ let initialDiscoveryError = null;
3989
+ let resolved = null;
3990
+ try {
3991
+ resolved = await resolveManagerWithCachedRecovery(cacheOwnerKey, {
3992
+ allowRelayFallback,
3993
+ relayEndpoint: parsed.relay,
3994
+ resolveFreshTarget: async () => {
3995
+ // The signed registry is authoritative for Hub replacement
3996
+ // and pair-token rotation. Cache direct/relay recovery is
3997
+ // considered only after this one current lookup reports
3998
+ // an explicitly recoverable availability failure.
3999
+ try {
4000
+ session = await activateSupabaseSession(supabase, choice.session);
4001
+ } catch (error) {
4002
+ if (!isRefreshTokenAlreadyUsedError(error)) throw error;
4003
+ session = readSavedSessionFromFile() || choice.session;
4004
+ console.warn('[LiveDesk Client] Another auth owner rotated the refresh token. Keeping the runtime alive while the saved session catches up.');
4005
+ }
4006
+ return await resolveManagerFromSupabase(supabase, {
4007
+ allowRelayFallback
4008
+ });
4009
+ }
4010
+ });
4011
+ } catch (error) {
4012
+ initialDiscoveryError = error;
4013
+ }
4014
+ discoverySource = resolved?.discoverySource || 'supabase';
4015
+ if (resolved?.discoverySource === 'cache') {
3801
4016
  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
- }
4017
+ console.log(resolved.connectionTransport === 'relay-fallback'
4018
+ ? `Fresh Hub registry lookup was unavailable; using saved account-bound Hub identity ${resolved.hubDeviceId} for the bounded encrypted relay path.`
4019
+ : `Fresh Hub registry lookup was unavailable; using the responding saved direct Hub endpoint ${resolved.manager}.`);
3815
4020
  }
3816
4021
  choice.session = session;
3817
4022
  const email = session?.user?.email ? ` as ${session.user.email}` : '';
@@ -3819,18 +4024,20 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3819
4024
  if (!resolved) {
3820
4025
  resolved = await waitForManagerFromSupabase(supabase, {
3821
4026
  allowRelayFallback,
4027
+ initialError: initialDiscoveryError,
3822
4028
  shouldStop: () => Boolean(roleRestartRequest?.role)
3823
4029
  });
4030
+ discoverySource = 'supabase';
3824
4031
  }
3825
4032
  if (!resolved && roleRestartRequest?.role) {
3826
- return {
3827
- ...parsed,
3828
- manager,
3829
- pair,
3830
- slot: normalizeSlotNumber(parsed.slot),
3831
- connectionPage,
3832
- forwarded,
3833
- rediscoverOnDisconnect: shouldLogin,
4033
+ return {
4034
+ ...parsed,
4035
+ manager,
4036
+ pair,
4037
+ slot: normalizeSlotNumber(parsed.slot),
4038
+ connectionPage,
4039
+ forwarded,
4040
+ rediscoverOnDisconnect: shouldLogin,
3834
4041
  rediscoverOnInvalidPair: shouldLogin
3835
4042
  };
3836
4043
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.204",
3
+ "version": "0.1.205",
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"
package/hub/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -4232,9 +4232,11 @@ export function createRemoteHub(options = {}) {
4232
4232
  const captureTimingAvailable = hasFrameCaptureTiming(message);
4233
4233
  const videoTransport = transport === 'udp-p2p'
4234
4234
  ? 'p2p-udp'
4235
- : transport === 'ws-binary'
4236
- ? 'direct-ws'
4237
- : 'direct-tcp';
4235
+ : transport === 'relay-binary'
4236
+ ? 'encrypted-relay'
4237
+ : transport === 'ws-binary'
4238
+ ? 'direct-ws'
4239
+ : 'direct-tcp';
4238
4240
  device.latestLiveFrame = {
4239
4241
  streamId,
4240
4242
  frameSeq,
@@ -4322,9 +4324,16 @@ export function createRemoteHub(options = {}) {
4322
4324
  deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
4323
4325
  deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
4324
4326
  videoTransport,
4325
- frameTransportActive: safeString(message.frameTransportActive, 20) || (videoTransport === 'p2p-udp' ? 'p2p' : 'direct'),
4327
+ frameTransportActive: safeString(message.frameTransportActive, 20)
4328
+ || (videoTransport === 'p2p-udp' ? 'p2p' : videoTransport === 'encrypted-relay' ? 'relay' : 'direct'),
4326
4329
  frameTransportProtocol: safeString(message.frameTransportProtocol, 20)
4327
- || (videoTransport === 'p2p-udp' ? 'udp' : videoTransport === 'direct-ws' ? 'ws' : 'tcp'),
4330
+ || (videoTransport === 'p2p-udp'
4331
+ ? 'udp'
4332
+ : videoTransport === 'encrypted-relay'
4333
+ ? 'relay'
4334
+ : videoTransport === 'direct-ws'
4335
+ ? 'ws'
4336
+ : 'tcp'),
4328
4337
  frameTransportState: safeString(message.frameTransportState, 20) || 'active',
4329
4338
  frameTransportP2pReady: message.frameTransportP2pReady === true,
4330
4339
  frameTransportReason: safeString(message.frameTransportReason, 240),
@@ -4518,7 +4527,11 @@ export function createRemoteHub(options = {}) {
4518
4527
  }
4519
4528
 
4520
4529
  const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
4521
- const binaryTransport = state.binaryTransport === 'ws-binary' ? 'ws-binary' : 'binary';
4530
+ const binaryTransport = socket.__liveDeskRelayControl === true
4531
+ ? 'relay-binary'
4532
+ : state.binaryTransport === 'ws-binary'
4533
+ ? 'ws-binary'
4534
+ : 'binary';
4522
4535
  if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
4523
4536
  applyThumbnailFrame(device, header, framePayload, binaryTransport);
4524
4537
  return;