fix(mobile): tolerate transient connect failures without bouncing the user
A single fast probe (2.5s per transport) used to be the only chance a connection got on cold launch and resume, so a just-woken network, a WireGuard re-handshake, or a relay cold start (TLS + WS + E2EE) regularly produced false "unreachable" verdicts that kicked the user to the connect screen. Now: - cold launch releases the splash on the fast verdict and retries once in the background with the full connect budget — a reachable instance reconnects on its own, and a manual connect started meanwhile wins; - resume retries on a 4s/10s ladder, the last attempt with the full budget, before tearing the connection down; needs-login still disconnects immediately on every path; - full-budget relay probes are capped at the shared 8s connect budget instead of inheriting the 15s relay session default, so a genuinely dead server does not pin the retry for 15 extra seconds. Probe steps, budgets, and retry decisions all land in the connection log.
This commit is contained in:
@@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
- Remote access: pairing QR codes created while the app is open through a public domain (for example behind a reverse proxy) now include that domain as a connection address, so paired phones can reach the server over it instead of relying only on the local network address or the relay.
|
- Remote access: pairing QR codes created while the app is open through a public domain (for example behind a reverse proxy) now include that domain as a connection address, so paired phones can reach the server over it instead of relying only on the local network address or the relay.
|
||||||
|
- Mobile: a brief network hiccup when opening or returning to the app no longer bounces a working connection to the connect screen — the app retries in the background and reconnects on its own, while an unreachable server shows the connect screen within a few seconds instead of holding the launch logo.
|
||||||
|
- Mobile: long-pressing the logo on the connect screen (or the instances list) opens a connection log with a copy button, for reporting connection problems.
|
||||||
- Usage: quota limits enabled for display now refresh every three minutes on desktop, mobile, and VS Code, with a manual refresh action available at any time.
|
- Usage: quota limits enabled for display now refresh every three minutes on desktop, mobile, and VS Code, with a manual refresh action available at any time.
|
||||||
|
|
||||||
## [1.18.2] - 2026-08-10
|
## [1.18.2] - 2026-08-10
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import { MobileSessionsSheet } from './MobileSessionsSheet';
|
|||||||
import { MobileFullscreenSurface } from './MobileFullscreenSurface';
|
import { MobileFullscreenSurface } from './MobileFullscreenSurface';
|
||||||
import { MobileWorkspaceDrawer, type MobileWorkspaceTab } from './MobileWorkspaceDrawer';
|
import { MobileWorkspaceDrawer, type MobileWorkspaceTab } from './MobileWorkspaceDrawer';
|
||||||
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
||||||
import { autoConnectLastInstance, getAutoConnectTargetLabel, reprobeActiveConnection, type AutoConnectOutcome } from './mobileConnections';
|
import { autoConnectLastInstance, getAutoConnectTargetLabel, logMobileConnectEvent, reprobeActiveConnection, type AutoConnectOutcome } from './mobileConnections';
|
||||||
import { isCapacitorMobileApp, useNativeAndroidBackButton, useNativeMobileChrome, useNativeMobileLifecycle } from './mobileNativeChrome';
|
import { isCapacitorMobileApp, useNativeAndroidBackButton, useNativeMobileChrome, useNativeMobileLifecycle } from './mobileNativeChrome';
|
||||||
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
||||||
import { useAppFontEffects } from './useAppFontEffects';
|
import { useAppFontEffects } from './useAppFontEffects';
|
||||||
@@ -660,9 +660,11 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
// saved instance instead of dead-ending on the connect screen until the
|
// saved instance instead of dead-ending on the connect screen until the
|
||||||
// user restarts the app. Success fires runtime-endpoint-changed, which
|
// user restarts the app. Success fires runtime-endpoint-changed, which
|
||||||
// re-bootstraps everything.
|
// re-bootstraps everything.
|
||||||
|
logMobileConnectEvent('resume:auto-connect', {});
|
||||||
void autoConnectLastInstance();
|
void autoConnectLastInstance();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
logMobileConnectEvent('resume:reprobe', {});
|
||||||
|
|
||||||
// Re-probe the active device's transports on resume: the network may have
|
// Re-probe the active device's transports on resume: the network may have
|
||||||
// changed while the app slept, so hot-switch LAN⇄relay if a better transport
|
// changed while the app slept, so hot-switch LAN⇄relay if a better transport
|
||||||
@@ -675,7 +677,8 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
|
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
|
||||||
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
|
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
|
||||||
};
|
};
|
||||||
const disconnect = () => {
|
const disconnect = (reason: string) => {
|
||||||
|
logMobileConnectEvent('resume:disconnect', { reason });
|
||||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
||||||
setConnectionEpoch((value) => value + 1);
|
setConnectionEpoch((value) => value + 1);
|
||||||
};
|
};
|
||||||
@@ -683,36 +686,50 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
void reprobeActiveConnection().then((outcome) => {
|
void reprobeActiveConnection().then((outcome) => {
|
||||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||||
if (outcome === 'no-connection') {
|
if (outcome === 'no-connection') {
|
||||||
disconnect();
|
disconnect('no-connection');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (outcome === 'needs-login') {
|
if (outcome === 'needs-login') {
|
||||||
// Token explicitly rejected (revoked/expired) — tell the user why they
|
// Token explicitly rejected (revoked/expired) — tell the user why they
|
||||||
// land back on the connect screen instead of silently bouncing them.
|
// land back on the connect screen instead of silently bouncing them.
|
||||||
setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' });
|
setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' });
|
||||||
disconnect();
|
disconnect('needs-login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (outcome === 'unreachable') {
|
if (outcome === 'unreachable') {
|
||||||
// Right after a resume or Wi-Fi switch the network is often still
|
// Right after a resume or Wi-Fi switch the network is often still
|
||||||
// settling (on Android without a SIM there is NO connectivity at all for
|
// settling (Android without a SIM has NO connectivity for a few
|
||||||
// a few seconds), so a single fast probe races the network coming up.
|
// seconds; a WireGuard tunnel re-handshakes; a relay cold start pays
|
||||||
// Retry once after a grace period before tearing the connection down.
|
// TLS + WS + E2EE before it can answer), so a single fast probe races
|
||||||
window.setTimeout(() => {
|
// the network coming up. Retry on a widening grace ladder before
|
||||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
// tearing the connection down — the last attempt runs with the full
|
||||||
void reprobeActiveConnection().then((retry) => {
|
// connect budget so slow-but-alive transports get a real chance.
|
||||||
|
const retryDelaysMs = [4000, 10000];
|
||||||
|
const retryAt = (attempt: number) => {
|
||||||
|
window.setTimeout(() => {
|
||||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||||
if (retry === 'switched') return;
|
const lastAttempt = attempt === retryDelaysMs.length - 1;
|
||||||
if (retry === 'unchanged') {
|
void reprobeActiveConnection({ fast: !lastAttempt }).then((retry) => {
|
||||||
refreshInPlace();
|
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||||
return;
|
if (retry === 'switched') return;
|
||||||
}
|
if (retry === 'unchanged') {
|
||||||
if (retry === 'needs-login') {
|
refreshInPlace();
|
||||||
setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' });
|
return;
|
||||||
}
|
}
|
||||||
disconnect();
|
if (retry === 'needs-login') {
|
||||||
});
|
setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' });
|
||||||
}, 4000);
|
disconnect('retry-needs-login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lastAttempt) {
|
||||||
|
retryAt(attempt + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
disconnect(`retry-${retry}`);
|
||||||
|
});
|
||||||
|
}, retryDelaysMs[attempt]);
|
||||||
|
};
|
||||||
|
retryAt(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (outcome === 'switched') return;
|
if (outcome === 'switched') return;
|
||||||
@@ -764,6 +781,15 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
// stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too.
|
// stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return subscribeRuntimeEndpointChanged((detail) => {
|
return subscribeRuntimeEndpointChanged((detail) => {
|
||||||
|
// Catch-all trail entry: EVERY endpoint change lands here regardless of
|
||||||
|
// which code path triggered it, so a "kicked to the connect screen"
|
||||||
|
// report always shows what dropped the runtime even when the trigger
|
||||||
|
// itself is not instrumented.
|
||||||
|
logMobileConnectEvent('endpoint:changed', {
|
||||||
|
runtimeKey: detail.runtimeKey || 'none',
|
||||||
|
previousRuntimeKey: detail.previousRuntimeKey || 'none',
|
||||||
|
connected: Boolean(detail.apiBaseUrl),
|
||||||
|
});
|
||||||
// A LAN⇄relay swap for the SAME device keeps the runtime key stable. Treat
|
// A LAN⇄relay swap for the SAME device keeps the runtime key stable. Treat
|
||||||
// that as a transport-only change: rebind the sync layer to the new
|
// that as a transport-only change: rebind the sync layer to the new
|
||||||
// transport but keep the user's session/connection state — no reconnecting
|
// transport but keep the user's session/connection state — no reconnecting
|
||||||
@@ -800,19 +826,30 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setAutoConnectPhase('attempting');
|
setAutoConnectPhase('attempting');
|
||||||
void autoConnectLastInstance()
|
void (async () => {
|
||||||
.catch((): AutoConnectOutcome => ({ status: 'no-candidate' }))
|
const outcome = await autoConnectLastInstance()
|
||||||
.then((outcome) => {
|
.catch((): AutoConnectOutcome => ({ status: 'no-candidate' }));
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
// Landing on the connect screen silently reads as data loss — say WHY
|
// Landing on the connect screen silently reads as data loss — say WHY
|
||||||
// the saved instance didn't come back (unreachable vs revoked auth).
|
// the saved instance didn't come back (unreachable vs revoked auth).
|
||||||
if (outcome.status === 'unreachable') {
|
if (outcome.status === 'unreachable') {
|
||||||
setAutoConnectNotice({ kind: 'unreachable', label: outcome.label });
|
setAutoConnectNotice({ kind: 'unreachable', label: outcome.label });
|
||||||
} else if (outcome.status === 'needs-login') {
|
} else if (outcome.status === 'needs-login') {
|
||||||
setAutoConnectNotice({ kind: 'auth-expired', label: outcome.label });
|
setAutoConnectNotice({ kind: 'auth-expired', label: outcome.label });
|
||||||
}
|
}
|
||||||
setAutoConnectPhase('done');
|
// Release the splash on the fast verdict — a dead server must not pin
|
||||||
});
|
// the logo for the full connect budget. The fast probe races a
|
||||||
|
// just-woken network/relay (WireGuard re-handshake, relay TLS + WS +
|
||||||
|
// E2EE cold start), so a false "unreachable" is common right after
|
||||||
|
// launch: retry once IN THE BACKGROUND with the full budget. A success
|
||||||
|
// switches the runtime and the app moves in from the connect screen on
|
||||||
|
// its own; a manual connect the user started meanwhile wins via
|
||||||
|
// skipIfConnected.
|
||||||
|
setAutoConnectPhase('done');
|
||||||
|
if (outcome.status === 'unreachable') {
|
||||||
|
void autoConnectLastInstance({ fast: false, skipIfConnected: true }).catch(() => null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
@@ -833,6 +870,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
if (!isNativeMobileApp || !getRuntimeApiBaseUrl()) return;
|
if (!isNativeMobileApp || !getRuntimeApiBaseUrl()) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const dropToConnectScreen = (notice: MobileConnectionNotice | null) => {
|
const dropToConnectScreen = (notice: MobileConnectionNotice | null) => {
|
||||||
|
logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' });
|
||||||
if (notice) setAutoConnectNotice(notice);
|
if (notice) setAutoConnectNotice(notice);
|
||||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
||||||
setConnectionEpoch((value) => value + 1);
|
setConnectionEpoch((value) => value + 1);
|
||||||
@@ -847,7 +885,14 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (outcome === 'unreachable') {
|
if (outcome === 'unreachable') {
|
||||||
|
// A fast probe racing the just-woken network/relay produces false
|
||||||
|
// "unreachable" verdicts (seen in the field: the same LAN candidate
|
||||||
|
// refuses on launch and answers 200 two minutes later). Show the
|
||||||
|
// connect screen on the fast verdict — no splash hostage — and retry
|
||||||
|
// once in the background with the full budget; a success reconnects
|
||||||
|
// the app from the connect screen on its own.
|
||||||
dropToConnectScreen(label ? { kind: 'unreachable', label } : null);
|
dropToConnectScreen(label ? { kind: 'unreachable', label } : null);
|
||||||
|
void autoConnectLastInstance({ fast: false, skipIfConnected: true }).catch(() => null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 'no-connection': at cold start the runtime key may not map to a saved
|
// 'no-connection': at cold start the runtime key may not map to a saved
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
|||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||||
|
|
||||||
|
import { recordMobileConnectDebug } from './mobileConnectionDebug';
|
||||||
|
|
||||||
const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1';
|
const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1';
|
||||||
const MOBILE_SECURE_STORAGE_PREFIX = 'openchamber.mobile.';
|
const MOBILE_SECURE_STORAGE_PREFIX = 'openchamber.mobile.';
|
||||||
const MOBILE_DEVICE_ID_STORAGE_KEY = 'openchamber.mobile.deviceId';
|
const MOBILE_DEVICE_ID_STORAGE_KEY = 'openchamber.mobile.deviceId';
|
||||||
@@ -304,11 +306,22 @@ const logDetail = (detail: Record<string, unknown>): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logConnect = (step: string, detail: Record<string, unknown> = {}): void => {
|
const logConnect = (step: string, detail: Record<string, unknown> = {}): void => {
|
||||||
console.info('[mobile-connect]', step, logDetail(detail));
|
const serialized = logDetail(detail);
|
||||||
|
console.info('[mobile-connect]', step, serialized);
|
||||||
|
recordMobileConnectDebug(step, serialized);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exported for surfaces that participate in the connection lifecycle outside
|
||||||
|
// this module (resume/online re-probes in MobileApp) so their decisions land in
|
||||||
|
// the same console + debug-panel trail as the probes themselves.
|
||||||
|
export const logMobileConnectEvent = (step: string, detail: Record<string, unknown> = {}): void => {
|
||||||
|
logConnect(step, detail);
|
||||||
};
|
};
|
||||||
|
|
||||||
const logStorage = (step: string, detail: Record<string, unknown> = {}): void => {
|
const logStorage = (step: string, detail: Record<string, unknown> = {}): void => {
|
||||||
console.info('[mobile-storage]', step, logDetail(detail));
|
const serialized = logDetail(detail);
|
||||||
|
console.info('[mobile-storage]', step, serialized);
|
||||||
|
recordMobileConnectDebug(step, serialized);
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseMaybeJson = (value: unknown): unknown => {
|
const parseMaybeJson = (value: unknown): unknown => {
|
||||||
@@ -347,14 +360,14 @@ const nativeHttpRequest = async (url: string, init?: RequestInit): Promise<Mobil
|
|||||||
json: async () => parseMaybeJson(response.data),
|
json: async () => parseMaybeJson(response.data),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[mobile-connect]', 'native-http failed', logDetail({ url, error: error instanceof Error ? error.message : String(error) }));
|
logConnect('native-http:failed', { url, error: error instanceof Error ? error.message : String(error) });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const browserFetchRequest = async (url: string, init?: RequestInit): Promise<MobileFetchResponse | null> => {
|
const browserFetchRequest = async (url: string, init?: RequestInit): Promise<MobileFetchResponse | null> => {
|
||||||
const response = await fetch(url, init).catch((error) => {
|
const response = await fetch(url, init).catch((error) => {
|
||||||
console.warn('[mobile-connect]', 'browser-fetch failed', logDetail({ url, error: error instanceof Error ? error.message : String(error) }));
|
logConnect('browser-fetch:failed', { url, error: error instanceof Error ? error.message : String(error) });
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (!response) return null;
|
if (!response) return null;
|
||||||
@@ -835,6 +848,7 @@ const probeConnectionCandidates = async (
|
|||||||
// /health is unauthenticated by design — never send the bearer token to an
|
// /health is unauthenticated by design — never send the bearer token to an
|
||||||
// address whose identity has not been checked yet.
|
// address whose identity has not been checked yet.
|
||||||
const health = await requestWithTimeout(`${url}/health`, { method: 'GET' }, requestOptions);
|
const health = await requestWithTimeout(`${url}/health`, { method: 'GET' }, requestOptions);
|
||||||
|
logConnect('probe:direct:health', { url, ok: health?.ok === true, status: health?.status ?? null, source: health?.source ?? null });
|
||||||
if (!health?.ok) continue;
|
if (!health?.ok) continue;
|
||||||
if (expectedServerId) {
|
if (expectedServerId) {
|
||||||
const payload = await health.json().catch(() => null);
|
const payload = await health.json().catch(() => null);
|
||||||
@@ -850,6 +864,7 @@ const probeConnectionCandidates = async (
|
|||||||
// the probe passes, and the app dies later on bootstrap's bearer-only
|
// the probe passes, and the app dies later on bootstrap's bearer-only
|
||||||
// requests. Cookie auth stays for the token-less (browser) flow.
|
// requests. Cookie auth stays for the token-less (browser) flow.
|
||||||
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions);
|
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions);
|
||||||
|
logConnect('probe:direct:session', { url, ok: session?.ok === true, status: session?.status ?? null, source: session?.source ?? null, hasToken: Boolean(token) });
|
||||||
if (session?.status === 401) return { status: 'needs-login' };
|
if (session?.status === 401) return { status: 'needs-login' };
|
||||||
if (!session || (!session.ok && session.status !== 404)) continue;
|
if (!session || (!session.ok && session.status !== 404)) continue;
|
||||||
const status = await readSessionStatus(session);
|
const status = await readSessionStatus(session);
|
||||||
@@ -868,11 +883,15 @@ const probeConnectionCandidates = async (
|
|||||||
if (!relayCandidate) return { status: 'unreachable' };
|
if (!relayCandidate) return { status: 'unreachable' };
|
||||||
// keepTunnel: an 'ok' probe hands its live tunnel to switchToTransport,
|
// keepTunnel: an 'ok' probe hands its live tunnel to switchToTransport,
|
||||||
// which adopts it as the runtime tunnel — no second connect + handshake.
|
// which adopts it as the runtime tunnel — no second connect + handshake.
|
||||||
|
// Full-budget probes align relay with the direct-transport connect budget
|
||||||
|
// (8s) instead of inheriting probeRelaySession's 15s default: 8s is ample
|
||||||
|
// for TLS + WS + E2EE handshake, and a dead host must not pin the connect
|
||||||
|
// splash (or a resume retry) for 15 extra seconds.
|
||||||
const { outcome, tunnel } = await probeRelaySession(
|
const { outcome, tunnel } = await probeRelaySession(
|
||||||
relayCandidate.relay,
|
relayCandidate.relay,
|
||||||
token,
|
token,
|
||||||
undefined,
|
undefined,
|
||||||
options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : undefined,
|
options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : MOBILE_CONNECT_TIMEOUT_MS,
|
||||||
{ keepTunnel: true },
|
{ keepTunnel: true },
|
||||||
);
|
);
|
||||||
if (outcome === 'ok') return { status: 'ok', transport: { kind: 'relay', relay: relayCandidate.relay, tunnel } };
|
if (outcome === 'ok') return { status: 'ok', transport: { kind: 'relay', relay: relayCandidate.relay, tunnel } };
|
||||||
@@ -989,9 +1008,11 @@ export type AutoConnectOutcome =
|
|||||||
/** The saved token was rejected (expired/revoked) — the user must sign in again. */
|
/** The saved token was rejected (expired/revoked) — the user must sign in again. */
|
||||||
| { status: 'needs-login'; label: string };
|
| { status: 'needs-login'; label: string };
|
||||||
|
|
||||||
export const autoConnectLastInstance = async (): Promise<AutoConnectOutcome> => {
|
export const autoConnectLastInstance = async (options?: { fast?: boolean; skipIfConnected?: boolean }): Promise<AutoConnectOutcome> => {
|
||||||
|
const fast = options?.fast !== false;
|
||||||
await migrateLegacyInlineTokens();
|
await migrateLegacyInlineTokens();
|
||||||
const candidate = readConnections()[0]; // sorted most-recent-first
|
const candidate = readConnections()[0]; // sorted most-recent-first
|
||||||
|
logConnect('auto-connect:start', { hasCandidate: Boolean(candidate), fast });
|
||||||
if (!candidate) return { status: 'no-candidate' };
|
if (!candidate) return { status: 'no-candidate' };
|
||||||
|
|
||||||
// The runtime transport needs a bearer token; only auto-connect when one is
|
// The runtime transport needs a bearer token; only auto-connect when one is
|
||||||
@@ -1010,13 +1031,24 @@ export const autoConnectLastInstance = async (): Promise<AutoConnectOutcome> =>
|
|||||||
if (!token) return { status: 'no-candidate' };
|
if (!token) return { status: 'no-candidate' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast probe: the cold-launch splash should decide in a couple of seconds,
|
// Fast probe by default: the cold-launch splash should decide in a couple of
|
||||||
// not sit through the full connect timeouts on a dead LAN candidate. A slow
|
// seconds, not sit through the full connect timeouts on a dead LAN candidate.
|
||||||
// network that fails the fast probe still lands on the connect screen where
|
// Callers retrying after an 'unreachable' verdict pass fast:false so the slow
|
||||||
// a manual tap retries with the full budget.
|
// retry gets the full connect budget (relay cold starts — TLS + WS + E2EE
|
||||||
const result = await probeConnectionCandidates(candidate.candidates, token, { fast: true });
|
// handshake — regularly overrun the fast window).
|
||||||
|
const result = await probeConnectionCandidates(candidate.candidates, token, { fast });
|
||||||
|
logConnect('auto-connect:probe', { status: result.status, candidates: candidate.candidates.map((c) => c.kind) });
|
||||||
if (result.status === 'needs-login') return { status: 'needs-login', label: candidate.label };
|
if (result.status === 'needs-login') return { status: 'needs-login', label: candidate.label };
|
||||||
if (result.status !== 'ok') return { status: 'unreachable', label: candidate.label };
|
if (result.status !== 'ok') return { status: 'unreachable', label: candidate.label };
|
||||||
|
// Background-retry guard: while this slow probe ran, the user may have
|
||||||
|
// connected manually from the connect screen. Their choice wins — discard
|
||||||
|
// this result instead of hijacking the runtime (close the probe's unused
|
||||||
|
// relay tunnel; a direct transport holds nothing).
|
||||||
|
if (options?.skipIfConnected && getRuntimeApiBaseUrl()) {
|
||||||
|
if (result.transport.kind === 'relay') result.transport.tunnel?.close();
|
||||||
|
logConnect('auto-connect:superseded', {});
|
||||||
|
return { status: 'no-candidate' };
|
||||||
|
}
|
||||||
await upsertMobileConnection({ id: candidate.id, label: candidate.label, candidates: candidate.candidates }); // bump lastUsedAt (keeps token)
|
await upsertMobileConnection({ id: candidate.id, label: candidate.label, candidates: candidate.candidates }); // bump lastUsedAt (keeps token)
|
||||||
switchToTransport(result.transport, token, { runtimeKey: secureTokenKeyOf(candidate) });
|
switchToTransport(result.transport, token, { runtimeKey: secureTokenKeyOf(candidate) });
|
||||||
return { status: 'connected' };
|
return { status: 'connected' };
|
||||||
@@ -1163,9 +1195,13 @@ export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'needs-l
|
|||||||
// validates the current transport over its live channel; only if that is dead does
|
// validates the current transport over its live channel; only if that is dead does
|
||||||
// it fall through to the lower-priority candidates. 'unchanged' → keep the runtime
|
// it fall through to the lower-priority candidates. 'unchanged' → keep the runtime
|
||||||
// and just refresh; 'unreachable'/'no-connection' → show the connect screen.
|
// and just refresh; 'unreachable'/'no-connection' → show the connect screen.
|
||||||
export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
|
export const reprobeActiveConnection = async (options?: { fast?: boolean }): Promise<ReprobeOutcome> => {
|
||||||
|
const fast = options?.fast !== false;
|
||||||
const active = findActiveConnection();
|
const active = findActiveConnection();
|
||||||
if (!active) return 'no-connection';
|
if (!active) {
|
||||||
|
logConnect('reprobe:no-connection', { runtimeKey: Boolean(getRuntimeKey()) });
|
||||||
|
return 'no-connection';
|
||||||
|
}
|
||||||
|
|
||||||
let token: string | undefined;
|
let token: string | undefined;
|
||||||
if (isCapacitorApp()) {
|
if (isCapacitorApp()) {
|
||||||
@@ -1173,7 +1209,11 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
|
|||||||
} else {
|
} else {
|
||||||
token = active.clientToken;
|
token = active.clientToken;
|
||||||
}
|
}
|
||||||
if (!token) return 'unreachable';
|
if (!token) {
|
||||||
|
logConnect('reprobe:no-token', { hasToken: Boolean(active.hasToken) });
|
||||||
|
return 'unreachable';
|
||||||
|
}
|
||||||
|
logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast });
|
||||||
|
|
||||||
const currentIndex = active.candidates.findIndex(
|
const currentIndex = active.candidates.findIndex(
|
||||||
(candidate) => transportMatchesCurrentRuntime(candidate.kind === 'relay' ? { kind: 'relay', relay: candidate.relay } : { kind: 'direct', url: candidate.url }),
|
(candidate) => transportMatchesCurrentRuntime(candidate.kind === 'relay' ? { kind: 'relay', relay: candidate.relay } : { kind: 'direct', url: candidate.url }),
|
||||||
@@ -1181,7 +1221,8 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
|
|||||||
|
|
||||||
// 1. A higher-priority transport becoming reachable means "came home" (relay → LAN).
|
// 1. A higher-priority transport becoming reachable means "came home" (relay → LAN).
|
||||||
const higher = currentIndex >= 0 ? active.candidates.slice(0, currentIndex) : active.candidates;
|
const higher = currentIndex >= 0 ? active.candidates.slice(0, currentIndex) : active.candidates;
|
||||||
const better = await probeConnectionCandidates(higher, token, { fast: true });
|
const better = await probeConnectionCandidates(higher, token, { fast });
|
||||||
|
logConnect('reprobe:better', { status: better.status, probed: higher.length });
|
||||||
if (better.status === 'ok') {
|
if (better.status === 'ok') {
|
||||||
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
|
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
|
||||||
switchToTransport(better.transport, token, { runtimeKey: secureTokenKeyOf(active) });
|
switchToTransport(better.transport, token, { runtimeKey: secureTokenKeyOf(active) });
|
||||||
@@ -1192,7 +1233,8 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
|
|||||||
|
|
||||||
// 2. No better transport — is the current one still alive on its live channel?
|
// 2. No better transport — is the current one still alive on its live channel?
|
||||||
if (currentIndex >= 0) {
|
if (currentIndex >= 0) {
|
||||||
const stillValid = await validateActiveRuntimeSession({ url: getRuntimeApiBaseUrl(), clientToken: token }, { fast: true });
|
const stillValid = await validateActiveRuntimeSession({ url: getRuntimeApiBaseUrl(), clientToken: token }, { fast });
|
||||||
|
logConnect('reprobe:current', { stillValid });
|
||||||
if (stillValid) {
|
if (stillValid) {
|
||||||
// Still on the same transport (typically: woke up on the relay, old LAN
|
// Still on the same transport (typically: woke up on the relay, old LAN
|
||||||
// candidate dead). Ask the server for its current LAN addresses in the
|
// candidate dead). Ask the server for its current LAN addresses in the
|
||||||
@@ -1205,7 +1247,8 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
|
|||||||
|
|
||||||
// 3. Current transport is dead — fall through to lower-priority candidates.
|
// 3. Current transport is dead — fall through to lower-priority candidates.
|
||||||
const lower = currentIndex >= 0 ? active.candidates.slice(currentIndex + 1) : [];
|
const lower = currentIndex >= 0 ? active.candidates.slice(currentIndex + 1) : [];
|
||||||
const fallback = await probeConnectionCandidates(lower, token, { fast: true });
|
const fallback = await probeConnectionCandidates(lower, token, { fast });
|
||||||
|
logConnect('reprobe:fallback', { status: fallback.status, probed: lower.length });
|
||||||
if (fallback.status === 'ok') {
|
if (fallback.status === 'ok') {
|
||||||
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
|
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
|
||||||
switchToTransport(fallback.transport, token, { runtimeKey: secureTokenKeyOf(active) });
|
switchToTransport(fallback.transport, token, { runtimeKey: secureTokenKeyOf(active) });
|
||||||
|
|||||||
Reference in New Issue
Block a user