1745 lines
78 KiB
TypeScript
1745 lines
78 KiB
TypeScript
// Saved-connection storage + the shared connect/unlock flow for the dedicated
|
|
// mobile app. Both the onboarding welcome screen and the Instances sheet drive
|
|
// connections through `useMobileConnection` so the health-check + progressive
|
|
// password unlock + client-token issuance + runtime switch all behave identically.
|
|
//
|
|
// Persistence model (deliberately simple so it is correct-by-inspection):
|
|
// - Instance *metadata* (id/label/url/lastUsedAt + a `hasToken` flag) lives in
|
|
// localStorage. On native it NEVER contains the client token.
|
|
// - The client token lives in the OS secure store (iOS Keychain / Android
|
|
// Keystore) via @aparajita/capacitor-secure-storage, keyed per instance URL.
|
|
// - On web (browser-hosted mobile.html) there is no secure store, so the token
|
|
// stays inline in localStorage — that surface is not the native security target.
|
|
//
|
|
// Token writes are AWAITED before we switch the runtime endpoint, so a successful
|
|
// unlock guarantees the token is actually persisted (no fire-and-forget).
|
|
|
|
import { SecureStorage } from '@aparajita/capacitor-secure-storage';
|
|
import { Capacitor } from '@capacitor/core';
|
|
import React from 'react';
|
|
|
|
import { useI18n } from '@/lib/i18n';
|
|
import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/connectionPayload';
|
|
import { isCapacitorApp } from '@/lib/platform';
|
|
import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
|
|
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
|
import { addRuntimeProxyHeaders, runtimeFetch } from '@/lib/runtime-fetch';
|
|
import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
|
|
|
import { recordMobileConnectDebug } from './mobileConnectionDebug';
|
|
|
|
const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1';
|
|
const MOBILE_SECURE_STORAGE_PREFIX = 'openchamber.mobile.';
|
|
const MOBILE_DEVICE_ID_STORAGE_KEY = 'openchamber.mobile.deviceId';
|
|
|
|
// Stable per-install identifier for this phone, persisted in localStorage. Used
|
|
// as the client dedupe key so every way this device authenticates to a given
|
|
// server (pairing redeem OR password re-login) collapses to ONE device record
|
|
// instead of piling up a new row each time a token is minted. Different phones
|
|
// get different ids; browsers never mint device tokens at all.
|
|
const getMobileDeviceId = (): string => {
|
|
try {
|
|
const existing = window.localStorage.getItem(MOBILE_DEVICE_ID_STORAGE_KEY);
|
|
if (existing && existing.trim()) return existing.trim();
|
|
const generated = crypto.randomUUID();
|
|
window.localStorage.setItem(MOBILE_DEVICE_ID_STORAGE_KEY, generated);
|
|
return generated;
|
|
} catch {
|
|
// localStorage unavailable — fall back to an ephemeral id (dedupe degrades to
|
|
// per-session, never worse than today's no-dedupe behavior).
|
|
return crypto.randomUUID();
|
|
}
|
|
};
|
|
|
|
// Server-side client dedupe key for this device (shared across pairing + login).
|
|
const mobileClientDedupeKey = (): string => `mobile:${getMobileDeviceId()}`;
|
|
|
|
// Display-only device metadata shown in the server's device list ("iOS",
|
|
// "Android"). Capacitor knows the native platform; no extra plugin needed.
|
|
const mobileDevicePlatform = (): string | undefined => {
|
|
try {
|
|
const platform = Capacitor.getPlatform();
|
|
return platform === 'ios' || platform === 'android' ? platform : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
const MOBILE_CONNECTIONS_LIMIT = 12;
|
|
const MOBILE_CONNECT_TIMEOUT_MS = 8000;
|
|
const MOBILE_NATIVE_HTTP_TIMEOUT_MS = 2500;
|
|
const MOBILE_SECURE_TIMEOUT_MS = 3000;
|
|
// Resume re-probe budget: on app wake we only need a quick "is this transport
|
|
// reachable right now?" answer, not the full 8s connect budget. A dead LAN
|
|
// candidate must fail fast so the relay fallback (or the switch back to LAN)
|
|
// feels instant instead of hanging for seconds.
|
|
const MOBILE_FAST_PROBE_TIMEOUT_MS = 2500;
|
|
|
|
export const createMobilePasswordOperationTracker = () => {
|
|
let current = 0;
|
|
return {
|
|
begin: (): number => {
|
|
current += 1;
|
|
return current;
|
|
},
|
|
cancel: (): void => {
|
|
current += 1;
|
|
},
|
|
isCurrent: (operation: number): boolean => operation === current,
|
|
};
|
|
};
|
|
|
|
export type MobileConnectionMode = 'direct' | 'relay';
|
|
|
|
// Persisted relay transport config. This is connection metadata, not a secret
|
|
// (the host public key is public by construction) — but never log it raw; mask
|
|
// the key coordinates in any debug output.
|
|
export type MobileRelayConfig = {
|
|
relayUrl: string;
|
|
serverId: string;
|
|
hostEncPubJwk: JsonWebKey;
|
|
};
|
|
|
|
// One reachable transport for a saved device: a direct HTTP URL, or the E2EE
|
|
// relay tunnel. A saved connection holds an ORDERED SET of these (index 0 tried
|
|
// first — LAN preferred, relay fallback) plus a single client token, and the app
|
|
// re-probes them on every connect/reconnect so the same device works at home
|
|
// (direct) and away (relay) without re-pairing.
|
|
export type MobileTransportCandidate =
|
|
| { kind: 'direct'; url: string }
|
|
| { kind: 'relay'; relay: MobileRelayConfig };
|
|
|
|
export type MobileSavedConnection = {
|
|
id: string;
|
|
label: string;
|
|
candidates: MobileTransportCandidate[];
|
|
lastUsedAt: number;
|
|
// Native: a token exists in the secure store, keyed by this connection's `id`.
|
|
hasToken?: boolean;
|
|
// Web only: the token stored inline. On native this stays undefined in the list.
|
|
clientToken?: string;
|
|
};
|
|
|
|
export type MobilePendingConnection = {
|
|
id: string;
|
|
label: string;
|
|
candidates: MobileTransportCandidate[];
|
|
// Present when the password unlock must ride the relay tunnel.
|
|
relay?: MobileRelayConfig;
|
|
relayGrant?: string;
|
|
};
|
|
|
|
// Input to `connect`. Either a raw URL/candidates for a NEW connection, or an
|
|
// existing saved connection's `id` + candidates for reconnect.
|
|
export type MobileConnectInput = {
|
|
id?: string;
|
|
url?: string;
|
|
candidates?: MobileTransportCandidate[];
|
|
clientToken?: string;
|
|
label?: string;
|
|
relay?: MobileRelayConfig;
|
|
relayGrant?: string;
|
|
};
|
|
|
|
type MobileFetchResponse = {
|
|
ok: boolean;
|
|
status: number;
|
|
source: 'native-http' | 'browser-fetch';
|
|
json: () => Promise<unknown>;
|
|
};
|
|
|
|
type MobileSessionStatus = {
|
|
authenticated?: boolean;
|
|
disabled?: boolean;
|
|
scope?: string;
|
|
};
|
|
|
|
type PairingRedeemResponse = {
|
|
ok?: boolean;
|
|
clientToken?: unknown;
|
|
client?: { label?: unknown } | null;
|
|
server?: { label?: unknown; url?: unknown } | null;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// URL helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const normalizeConnectionUrl = (value: string): string => {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return '';
|
|
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
|
const url = new URL(withScheme);
|
|
url.hash = '';
|
|
url.search = '';
|
|
url.pathname = url.pathname.replace(/\/+$/, '');
|
|
return url.toString().replace(/\/+$/, '');
|
|
};
|
|
|
|
const getConnectionLabel = (url: string): string => {
|
|
try {
|
|
return new URL(url).host;
|
|
} catch {
|
|
return url;
|
|
}
|
|
};
|
|
|
|
const getConnectionStorageKey = (url: string): string => {
|
|
try {
|
|
return normalizeConnectionUrl(url);
|
|
} catch {
|
|
return url.trim().replace(/\/+$/g, '');
|
|
}
|
|
};
|
|
|
|
const isSameConnectionUrl = (left: string, right: string): boolean =>
|
|
getConnectionStorageKey(left) === getConnectionStorageKey(right);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Relay helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Stable identity for a relay connection. Also used as the runtime key passed
|
|
// to switchRuntimeEndpoint so "is this saved entry the active runtime?" checks
|
|
// can compare against getRuntimeKey().
|
|
const relayConnectionRuntimeKey = (relay: MobileRelayConfig): string =>
|
|
`relay:${relay.serverId}@${relay.relayUrl.trim()}`;
|
|
|
|
// Stable, non-fetchable pseudo-URL for a relay-only device (display only).
|
|
const canonicalRelayUrl = (relay: MobileRelayConfig): string => `relay://${relay.serverId}`;
|
|
|
|
// --- Transport-candidate helpers on a saved connection ---
|
|
const directCandidates = (connection: { candidates: MobileTransportCandidate[] }): Array<{ kind: 'direct'; url: string }> =>
|
|
connection.candidates.filter((c): c is { kind: 'direct'; url: string } => c.kind === 'direct');
|
|
|
|
const relayCandidateOf = (connection: { candidates: MobileTransportCandidate[] }): MobileRelayConfig | null => {
|
|
const found = connection.candidates.find((c) => c.kind === 'relay');
|
|
return found && found.kind === 'relay' ? found.relay : null;
|
|
};
|
|
|
|
// Display URL for a saved connection: the first direct URL, else the relay
|
|
// pseudo-URL. Used only for the connections list UI.
|
|
export const connectionDisplayUrl = (connection: { candidates: MobileTransportCandidate[] }): string => {
|
|
const direct = directCandidates(connection)[0];
|
|
if (direct) return direct.url;
|
|
const relay = relayCandidateOf(connection);
|
|
return relay ? canonicalRelayUrl(relay) : '';
|
|
};
|
|
|
|
// Secure-store / dedupe key for a saved device. A device has ONE token that
|
|
// works over all its transports; the key is stable and transport-derived: the
|
|
// relay identity when the device can use relay, else its direct URL. This keeps
|
|
// existing single-transport tokens findable (same key as before this refactor).
|
|
const secureTokenKeyOf = (connection: { candidates: MobileTransportCandidate[] }): string => {
|
|
const relay = relayCandidateOf(connection);
|
|
if (relay) return relayConnectionRuntimeKey(relay);
|
|
const direct = directCandidates(connection)[0];
|
|
return direct ? getConnectionStorageKey(direct.url) : '';
|
|
};
|
|
|
|
// Two candidate sets are the same device if they share a relay serverId or a
|
|
// normalized direct URL — used to dedupe saved connections on upsert.
|
|
const candidateSetsMatch = (a: MobileTransportCandidate[], b: MobileTransportCandidate[]): boolean => {
|
|
const aRelay = a.find((c) => c.kind === 'relay');
|
|
const aServerId = aRelay && aRelay.kind === 'relay' ? aRelay.relay.serverId : null;
|
|
const aUrls = new Set(a.filter((c) => c.kind === 'direct').map((c) => getConnectionStorageKey((c as { url: string }).url)));
|
|
return b.some((c) => {
|
|
if (c.kind === 'relay') return aServerId !== null && c.relay.serverId === aServerId;
|
|
return aUrls.has(getConnectionStorageKey(c.url));
|
|
});
|
|
};
|
|
|
|
// Build the ordered candidate set for a newly typed/pasted server URL.
|
|
const directCandidatesFromUrl = (url: string): MobileTransportCandidate[] => {
|
|
const normalized = (() => {
|
|
try {
|
|
return normalizeConnectionUrl(url);
|
|
} catch {
|
|
return '';
|
|
}
|
|
})();
|
|
return normalized ? [{ kind: 'direct', url: normalized }] : [];
|
|
};
|
|
|
|
// Resolve a connect request into an ordered candidate set: an explicit set
|
|
// (saved reconnect / pairing) wins, otherwise a typed URL and/or a relay
|
|
// descriptor. Direct is preferred (index 0), relay is the fallback.
|
|
const buildCandidatesFromInput = (input: MobileConnectInput): MobileTransportCandidate[] => {
|
|
if (input.candidates && input.candidates.length > 0) return input.candidates;
|
|
const list: MobileTransportCandidate[] = [];
|
|
if (typeof input.url === 'string' && input.url.trim() && !/^relay:\/\//i.test(input.url.trim())) {
|
|
list.push(...directCandidatesFromUrl(input.url));
|
|
}
|
|
if (input.relay) list.push({ kind: 'relay', relay: input.relay });
|
|
return list;
|
|
};
|
|
|
|
const parseRelayConfig = (value: unknown): MobileRelayConfig | null => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
const record = value as Record<string, unknown>;
|
|
if (typeof record.relayUrl !== 'string' || !record.relayUrl.trim()) return null;
|
|
if (typeof record.serverId !== 'string' || !record.serverId.trim()) return null;
|
|
const jwk = record.hostEncPubJwk;
|
|
if (!jwk || typeof jwk !== 'object' || Array.isArray(jwk)) return null;
|
|
const key = jwk as Record<string, unknown>;
|
|
if (key.kty !== 'EC' || key.crv !== 'P-256') return null;
|
|
if (typeof key.x !== 'string' || !key.x || typeof key.y !== 'string' || !key.y) return null;
|
|
return {
|
|
relayUrl: record.relayUrl,
|
|
serverId: record.serverId,
|
|
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: key.x, y: key.y },
|
|
};
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Request helpers (native CapacitorHttp first — needed to reach plain-http LAN
|
|
// servers the secure webview cannot fetch — then a browser-fetch fallback).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Android logcat prints objects as "[object Object]" — serialize so device logs
|
|
// are actually readable.
|
|
const logDetail = (detail: Record<string, unknown>): string => {
|
|
try {
|
|
return JSON.stringify(detail);
|
|
} catch {
|
|
return String(detail);
|
|
}
|
|
};
|
|
|
|
const logConnect = (step: string, detail: Record<string, unknown> = {}): void => {
|
|
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 serialized = logDetail(detail);
|
|
console.info('[mobile-storage]', step, serialized);
|
|
recordMobileConnectDebug(step, serialized);
|
|
};
|
|
|
|
const parseMaybeJson = (value: unknown): unknown => {
|
|
if (typeof value !== 'string') return value;
|
|
try {
|
|
return JSON.parse(value) as unknown;
|
|
} catch {
|
|
return value;
|
|
}
|
|
};
|
|
|
|
const getJsonRequestData = (body: BodyInit | null | undefined): unknown => {
|
|
if (typeof body !== 'string') return body ?? undefined;
|
|
try {
|
|
return JSON.parse(body) as unknown;
|
|
} catch {
|
|
return body;
|
|
}
|
|
};
|
|
|
|
const nativeHttpRequest = async (url: string, init?: RequestInit): Promise<MobileFetchResponse | null> => {
|
|
if (!isCapacitorApp()) return null;
|
|
try {
|
|
const { CapacitorHttp } = await import('@capacitor/core');
|
|
const requestHeaders = addRuntimeProxyHeaders(url, new Headers(init?.headers));
|
|
const response = await CapacitorHttp.request({
|
|
url,
|
|
method: init?.method || 'GET',
|
|
headers: Object.fromEntries(requestHeaders.entries()),
|
|
data: getJsonRequestData(init?.body),
|
|
});
|
|
return {
|
|
ok: response.status >= 200 && response.status < 300,
|
|
status: response.status,
|
|
source: 'native-http',
|
|
json: async () => parseMaybeJson(response.data),
|
|
};
|
|
} catch (error) {
|
|
logConnect('native-http:failed', { url, error: error instanceof Error ? error.message : String(error) });
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const browserFetchRequest = async (url: string, init?: RequestInit): Promise<MobileFetchResponse | null> => {
|
|
const response = await fetch(url, init).catch((error) => {
|
|
logConnect('browser-fetch:failed', { url, error: error instanceof Error ? error.message : String(error) });
|
|
return null;
|
|
});
|
|
if (!response) return null;
|
|
return { ok: response.ok, status: response.status, source: 'browser-fetch', json: () => response.json() };
|
|
};
|
|
|
|
const raceWithTimeout = async <T,>(timeoutMs: number, operation: Promise<T | null>, onTimeout?: () => void): Promise<T | null> => {
|
|
let timeoutId: number | undefined;
|
|
const timeout = new Promise<null>((resolve) => {
|
|
timeoutId = window.setTimeout(() => {
|
|
onTimeout?.();
|
|
resolve(null);
|
|
}, timeoutMs);
|
|
});
|
|
try {
|
|
return await Promise.race([operation, timeout]);
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
|
|
const requestWithTimeout = async (
|
|
url: string,
|
|
init?: RequestInit,
|
|
options?: { totalTimeoutMs?: number },
|
|
): Promise<MobileFetchResponse | null> => {
|
|
const total = options?.totalTimeoutMs ?? MOBILE_CONNECT_TIMEOUT_MS;
|
|
const startedAt = Date.now();
|
|
const native = await raceWithTimeout(
|
|
Math.min(MOBILE_NATIVE_HTTP_TIMEOUT_MS, total),
|
|
nativeHttpRequest(url, init),
|
|
);
|
|
if (native) return native;
|
|
|
|
const controller = new AbortController();
|
|
const remainingMs = Math.max(500, total - (Date.now() - startedAt));
|
|
return raceWithTimeout(
|
|
remainingMs,
|
|
browserFetchRequest(url, { ...init, signal: controller.signal }),
|
|
() => controller.abort(),
|
|
);
|
|
};
|
|
|
|
const readSessionStatus = async (response: { json: () => Promise<unknown> } | null): Promise<MobileSessionStatus | null> => {
|
|
if (!response) return null;
|
|
const payload = await response.json().catch(() => null);
|
|
if (!payload || typeof payload !== 'object') return null;
|
|
const record = payload as Record<string, unknown>;
|
|
return {
|
|
authenticated: typeof record.authenticated === 'boolean' ? record.authenticated : undefined,
|
|
disabled: typeof record.disabled === 'boolean' ? record.disabled : undefined,
|
|
scope: typeof record.scope === 'string' ? record.scope : undefined,
|
|
};
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Relay connect helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const RELAY_CONNECT_TIMEOUT_MS = 15_000;
|
|
|
|
type RelayProbeOutcome = 'ok' | 'needs-login' | 'auth-failed' | 'unreachable';
|
|
|
|
type RelayProbeResult = {
|
|
outcome: RelayProbeOutcome;
|
|
// The live tunnel on 'ok' when the caller asked to keep it (adopted as the
|
|
// runtime tunnel by switchToTransport, saving a second connect+handshake).
|
|
tunnel?: ReturnType<typeof createRelayTunnelClient>;
|
|
};
|
|
|
|
// Probe /auth/session through a tunnel — the relay counterpart of the direct
|
|
// flow's pre-switch reachability/auth probe. No /health round-trip: the E2EE
|
|
// handshake already proves the host's identity (only the paired server owns
|
|
// the private key for the pinned hostEncPubJwk), and /auth/session proves both
|
|
// liveness and token validity in one request. Cookies never ride the tunnel,
|
|
// so the cookie-only-session special case from the direct flow does not apply.
|
|
// With `keepTunnel`, an 'ok' result RETURNS the open tunnel (caller owns it);
|
|
// every other path closes it.
|
|
const probeRelaySession = async (
|
|
relay: MobileRelayConfig,
|
|
token?: string,
|
|
grant?: string,
|
|
timeoutMs: number = RELAY_CONNECT_TIMEOUT_MS,
|
|
options?: { keepTunnel?: boolean },
|
|
): Promise<RelayProbeResult> => {
|
|
const tunnel = createRelayTunnelClient({
|
|
relayUrl: relay.relayUrl,
|
|
serverId: relay.serverId,
|
|
hostEncPubJwk: relay.hostEncPubJwk,
|
|
...(grant ? { grant } : {}),
|
|
});
|
|
const finish = (outcome: RelayProbeOutcome): RelayProbeResult => {
|
|
if (outcome === 'ok' && options?.keepTunnel) return { outcome, tunnel };
|
|
tunnel.close();
|
|
return { outcome };
|
|
};
|
|
try {
|
|
const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
|
|
const session = await raceWithTimeout(timeoutMs, tunnel.fetch('/auth/session', { headers }).catch(() => null));
|
|
logConnect('relay:session', { ok: session?.ok === true, status: session?.status ?? null, hasToken: Boolean(token) });
|
|
if (!session) return finish('unreachable');
|
|
if (session.status === 401) return finish(token ? 'auth-failed' : 'needs-login');
|
|
if (!session.ok && session.status !== 404) return finish('auth-failed');
|
|
const status = await readSessionStatus(session);
|
|
if (status && status.disabled !== true && status.authenticated === false) {
|
|
return finish(token ? 'auth-failed' : 'needs-login');
|
|
}
|
|
return finish('ok');
|
|
} catch (error) {
|
|
tunnel.close();
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const switchToRelayRuntime = (
|
|
relay: MobileRelayConfig,
|
|
clientToken: string | null,
|
|
grant?: string,
|
|
runtimeKey?: string,
|
|
liveTunnel?: ReturnType<typeof createRelayTunnelClient>,
|
|
): void => {
|
|
// Relay mode has no network base URL: runtimeFetch intercepts runtime paths on
|
|
// the current window origin and rides the E2EE tunnel, so the window origin is
|
|
// the correct virtual API base. The runtime key carries the real device
|
|
// identity (stable across a device's transports so LAN⇄relay is not treated
|
|
// as an instance switch).
|
|
const apiBaseUrl = typeof window !== 'undefined' ? window.location.origin : '';
|
|
const descriptor = {
|
|
relayUrl: relay.relayUrl,
|
|
serverId: relay.serverId,
|
|
hostEncPubJwk: relay.hostEncPubJwk,
|
|
...(grant ? { grant } : {}),
|
|
};
|
|
// Adopt the probe/redeem tunnel as the runtime tunnel BEFORE the switch: the
|
|
// activate call inside switchRuntimeEndpoint sees an equal descriptor and
|
|
// reuses it, skipping a second WebSocket connect + E2EE handshake.
|
|
if (liveTunnel) {
|
|
adoptRelayTunnel(descriptor, liveTunnel);
|
|
}
|
|
switchRuntimeEndpoint({
|
|
apiBaseUrl,
|
|
clientToken,
|
|
runtimeKey: runtimeKey ?? relayConnectionRuntimeKey(relay),
|
|
relay: descriptor,
|
|
});
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Metadata storage (localStorage) — never holds the token on native.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const parseCandidate = (value: unknown): MobileTransportCandidate | null => {
|
|
if (!value || typeof value !== 'object') return null;
|
|
const c = value as Record<string, unknown>;
|
|
if (c.kind === 'direct') {
|
|
return typeof c.url === 'string' && c.url.trim() ? { kind: 'direct', url: c.url } : null;
|
|
}
|
|
if (c.kind === 'relay') {
|
|
const relay = parseRelayConfig(c.relay);
|
|
return relay ? { kind: 'relay', relay } : null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Migrate a pre-candidates entry ({ url, mode, relay }) to a candidate set.
|
|
const migrateLegacyCandidates = (c: Record<string, unknown>): MobileTransportCandidate[] => {
|
|
if (c.mode === 'relay') {
|
|
const relay = parseRelayConfig(c.relay);
|
|
return relay ? [{ kind: 'relay', relay }] : [];
|
|
}
|
|
return typeof c.url === 'string' ? directCandidatesFromUrl(c.url) : [];
|
|
};
|
|
|
|
const readConnections = (): MobileSavedConnection[] => {
|
|
if (typeof window === 'undefined') return [];
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]');
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (!Array.isArray(parsed)) return [];
|
|
const native = isCapacitorApp();
|
|
return parsed
|
|
.flatMap((item): MobileSavedConnection[] => {
|
|
if (!item || typeof item !== 'object') return [];
|
|
const c = item as Record<string, unknown>;
|
|
if (typeof c.id !== 'string') return [];
|
|
// Prefer the candidates array; fall back to migrating a legacy entry. An
|
|
// entry with no usable transport is dropped rather than misrepresented.
|
|
const candidates = Array.isArray(c.candidates)
|
|
? c.candidates.map(parseCandidate).filter((x): x is MobileTransportCandidate => Boolean(x))
|
|
: migrateLegacyCandidates(c);
|
|
if (candidates.length === 0) return [];
|
|
const inlineToken = typeof c.clientToken === 'string' && c.clientToken.trim() ? c.clientToken : undefined;
|
|
const label = typeof c.label === 'string' && c.label.trim() ? c.label : getConnectionLabel(connectionDisplayUrl({ candidates }));
|
|
const base: MobileSavedConnection = {
|
|
id: c.id,
|
|
label,
|
|
candidates,
|
|
lastUsedAt: typeof c.lastUsedAt === 'number' ? c.lastUsedAt : 0,
|
|
};
|
|
if (native) return [{ ...base, hasToken: Boolean(c.hasToken) || Boolean(inlineToken) }];
|
|
return [{ ...base, clientToken: inlineToken, hasToken: Boolean(inlineToken) }];
|
|
})
|
|
.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
};
|
|
|
|
const serializeCandidate = (c: MobileTransportCandidate): unknown =>
|
|
c.kind === 'relay'
|
|
? { kind: 'relay', relay: { relayUrl: c.relay.relayUrl, serverId: c.relay.serverId, hostEncPubJwk: c.relay.hostEncPubJwk } }
|
|
: { kind: 'direct', url: c.url };
|
|
|
|
const writeConnections = (connections: MobileSavedConnection[]): void => {
|
|
if (typeof window === 'undefined') return;
|
|
const native = isCapacitorApp();
|
|
const serialized = connections.slice(0, MOBILE_CONNECTIONS_LIMIT).map((c) => {
|
|
// grant/token never land here — only transport metadata.
|
|
const shared = {
|
|
id: c.id,
|
|
label: c.label,
|
|
candidates: c.candidates.map(serializeCandidate),
|
|
lastUsedAt: c.lastUsedAt,
|
|
};
|
|
return native
|
|
? { ...shared, hasToken: Boolean(c.hasToken || c.clientToken) }
|
|
: { ...shared, clientToken: c.clientToken };
|
|
});
|
|
try {
|
|
window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(serialized));
|
|
} catch (error) {
|
|
console.warn('[mobile-storage] failed to persist connection metadata', error);
|
|
}
|
|
};
|
|
|
|
const upsertConnectionInList = (
|
|
connections: MobileSavedConnection[],
|
|
draft: { id?: string; label: string; candidates: MobileTransportCandidate[]; clientToken?: string; hasToken?: boolean },
|
|
): MobileSavedConnection[] => {
|
|
const existing = connections.find(
|
|
(item) => (draft.id && item.id === draft.id) || candidateSetsMatch(item.candidates, draft.candidates),
|
|
);
|
|
const native = isCapacitorApp();
|
|
const next: MobileSavedConnection = {
|
|
id: draft.id || existing?.id || crypto.randomUUID(),
|
|
label: draft.label,
|
|
candidates: draft.candidates,
|
|
lastUsedAt: Date.now(),
|
|
...(native
|
|
? { hasToken: draft.hasToken ?? (Boolean(draft.clientToken) || existing?.hasToken || false) }
|
|
: { clientToken: draft.clientToken ?? existing?.clientToken, hasToken: Boolean(draft.clientToken ?? existing?.clientToken) }),
|
|
};
|
|
return [
|
|
next,
|
|
...connections.filter((item) => item.id !== next.id && !candidateSetsMatch(item.candidates, draft.candidates)),
|
|
].slice(0, MOBILE_CONNECTIONS_LIMIT);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Secure token storage (native only), per-instance URL. Every call is bounded
|
|
// so a hung/unavailable Keychain can never block the connect flow.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// We call the plugin's NATIVE methods (`internalSetItem`/`internalGetItem`/
|
|
// `internalRemoveItem`) directly. Capacitor routes native methods straight to the
|
|
// iOS/Android plugin via the bridge — unlike the high-level `setItem`/`setKeyPrefix`
|
|
// JS methods, which make the `registerPlugin` proxy lazy-load its platform JS module
|
|
// (the step that stalls in this webview). We also build the prefixed key ourselves
|
|
// so we never touch the JS-only `setKeyPrefix`.
|
|
type NativeSecureStorage = {
|
|
internalSetItem: (options: { prefixedKey: string; data: string; sync: boolean; access: number }) => Promise<void>;
|
|
internalGetItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ data: string | null }>;
|
|
internalRemoveItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ success: boolean }>;
|
|
};
|
|
|
|
const nativeSecure = SecureStorage as unknown as NativeSecureStorage;
|
|
const KEYCHAIN_ACCESS_WHEN_UNLOCKED = 0; // KeychainAccess.whenUnlocked
|
|
|
|
// `key` is a connection storage key (connectionKeyOf): the normalized URL for
|
|
// direct connections (unchanged historical format, existing tokens stay valid)
|
|
// or the relay identity key for relay connections.
|
|
const prefixedTokenKey = (key: string): string =>
|
|
`${MOBILE_SECURE_STORAGE_PREFIX}token.${encodeURIComponent(key)}`;
|
|
|
|
const withTimeout = async <T,>(operation: Promise<T>, fallback: T): Promise<T> => {
|
|
let timeoutId: number | undefined;
|
|
const timeout = new Promise<T>((resolve) => {
|
|
timeoutId = window.setTimeout(() => resolve(fallback), MOBILE_SECURE_TIMEOUT_MS);
|
|
});
|
|
try {
|
|
return await Promise.race([operation.catch(() => fallback), timeout]);
|
|
} finally {
|
|
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
|
|
// Bound a native Keychain call so a stalled/failed bridge can never hang the flow.
|
|
const boundedSecure = async <T,>(label: string, run: () => Promise<T>, fallback: T): Promise<T> => {
|
|
if (!isCapacitorApp()) return fallback;
|
|
return withTimeout(
|
|
run().catch((error) => {
|
|
console.warn(`[mobile-storage] ${label} failed`, error);
|
|
return fallback;
|
|
}),
|
|
fallback,
|
|
);
|
|
};
|
|
|
|
const readSecureToken = async (key: string): Promise<string | undefined> => {
|
|
logStorage('secure:read-start', { key });
|
|
const value = await boundedSecure(
|
|
'secure:read',
|
|
async () => (await nativeSecure.internalGetItem({ prefixedKey: prefixedTokenKey(key), sync: false })).data,
|
|
null,
|
|
);
|
|
const token = typeof value === 'string' && value.trim() ? value : undefined;
|
|
logStorage('secure:read', { key, hasToken: Boolean(token) });
|
|
return token;
|
|
};
|
|
|
|
const writeSecureToken = async (key: string, token: string): Promise<boolean> => {
|
|
logStorage('secure:write-start', { key });
|
|
const ok = await boundedSecure('secure:write', async () => {
|
|
await nativeSecure.internalSetItem({
|
|
prefixedKey: prefixedTokenKey(key),
|
|
data: token,
|
|
sync: false,
|
|
access: KEYCHAIN_ACCESS_WHEN_UNLOCKED,
|
|
});
|
|
return true;
|
|
}, false);
|
|
logStorage('secure:write', { key, ok });
|
|
return ok;
|
|
};
|
|
|
|
const deleteSecureToken = async (key: string): Promise<void> => {
|
|
await boundedSecure('secure:delete', async () => {
|
|
await nativeSecure.internalRemoveItem({ prefixedKey: prefixedTokenKey(key), sync: false });
|
|
return true;
|
|
}, false);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public storage API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// One-time migration: a legacy localStorage record on native might still carry an
|
|
// inline `clientToken`. Move it into the secure store and strip the metadata.
|
|
export const migrateLegacyInlineTokenRecords = async (
|
|
records: unknown[],
|
|
migrateToken: (url: string, token: string) => Promise<boolean>,
|
|
): Promise<{ records: unknown[]; migrated: number; failed: number }> => {
|
|
let migrated = 0;
|
|
let failed = 0;
|
|
const next = await Promise.all(records.map(async (item) => {
|
|
if (!item || typeof item !== 'object') return item;
|
|
const record = item as Record<string, unknown>;
|
|
const url = typeof record.url === 'string' ? record.url : null;
|
|
const token = typeof record.clientToken === 'string' ? record.clientToken.trim() : '';
|
|
if (!url || !token) return item;
|
|
if (!await migrateToken(url, token)) {
|
|
failed += 1;
|
|
return item;
|
|
}
|
|
migrated += 1;
|
|
const { clientToken: _removed, ...metadata } = record;
|
|
void _removed;
|
|
return { ...metadata, hasToken: true };
|
|
}));
|
|
return { records: next, migrated, failed };
|
|
};
|
|
|
|
const migrateLegacyInlineTokens = async (): Promise<void> => {
|
|
if (typeof window === 'undefined' || !isCapacitorApp()) return;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]');
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!Array.isArray(parsed)) return;
|
|
const legacy = parsed.filter((item): item is { url: string; clientToken: string } =>
|
|
Boolean(item) && typeof item === 'object'
|
|
&& typeof (item as { url?: unknown }).url === 'string'
|
|
&& typeof (item as { clientToken?: unknown }).clientToken === 'string'
|
|
&& Boolean((item as { clientToken: string }).clientToken.trim()));
|
|
if (legacy.length === 0) return;
|
|
logStorage('secure:migrate-start', { count: legacy.length });
|
|
const result = await migrateLegacyInlineTokenRecords(parsed, async (url, token) => {
|
|
const key = getConnectionStorageKey(url);
|
|
if (!await writeSecureToken(key, token)) return false;
|
|
return await readSecureToken(key) === token;
|
|
});
|
|
if (result.migrated > 0) {
|
|
try {
|
|
window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(result.records));
|
|
} catch (error) {
|
|
console.warn('[mobile-storage] failed to finalize secure token migration', error);
|
|
return;
|
|
}
|
|
}
|
|
logStorage('secure:migrate-done', { migrated: result.migrated, failed: result.failed });
|
|
};
|
|
|
|
export const loadMobileConnections = async (): Promise<MobileSavedConnection[]> => {
|
|
await migrateLegacyInlineTokens();
|
|
return readConnections();
|
|
};
|
|
|
|
export const upsertMobileConnection = async (
|
|
connection: { id?: string; label: string; candidates: MobileTransportCandidate[]; clientToken?: string },
|
|
): Promise<MobileSavedConnection[]> => {
|
|
const next = upsertConnectionInList(readConnections(), connection);
|
|
writeConnections(next);
|
|
if (isCapacitorApp() && connection.clientToken) {
|
|
await writeSecureToken(secureTokenKeyOf({ candidates: connection.candidates }), connection.clientToken);
|
|
}
|
|
return next;
|
|
};
|
|
|
|
const deleteMobileConnection = async (id: string): Promise<MobileSavedConnection[]> => {
|
|
const connections = readConnections();
|
|
const removed = connections.find((connection) => connection.id === id) ?? null;
|
|
const next = connections.filter((connection) => connection.id !== id);
|
|
writeConnections(next);
|
|
if (removed && isCapacitorApp()) await deleteSecureToken(secureTokenKeyOf(removed));
|
|
return next;
|
|
};
|
|
|
|
// The transport a connect/reconnect settled on. A relay transport MAY carry
|
|
// the already-open probe tunnel; switchToTransport adopts it as the runtime
|
|
// tunnel instead of dialing a fresh one.
|
|
type ChosenTransport =
|
|
| { kind: 'direct'; url: string }
|
|
| { kind: 'relay'; relay: MobileRelayConfig; tunnel?: ReturnType<typeof createRelayTunnelClient> };
|
|
|
|
type ProbeResult =
|
|
| { status: 'ok'; transport: ChosenTransport }
|
|
| { status: 'needs-login' }
|
|
| { status: 'unreachable' };
|
|
|
|
// How long the direct (LAN/tunnel) candidates keep the track to themselves
|
|
// before the relay probe starts. At home a live LAN answers well inside this
|
|
// window, so nothing changes there; with a dead/stale LAN candidate the relay
|
|
// probe is already mid-flight instead of queued behind the full direct timeout
|
|
// (which alone cost up to MOBILE_CONNECT_TIMEOUT_MS per stale address).
|
|
const RELAY_RACE_HEADSTART_MS = 1_500;
|
|
|
|
// Probe a saved device's candidates and return the first transport that is both
|
|
// reachable AND accepts the token. This is the heart of "one device, many
|
|
// transports": at home the LAN candidate answers; away it is unreachable so we
|
|
// fall through to relay — no re-pairing. Direct candidates are probed in order
|
|
// and keep priority; the relay probe races them after a short headstart instead
|
|
// of waiting for every direct timeout. An explicit auth rejection (401 /
|
|
// authenticated:false) applies to every transport (same token), so it
|
|
// short-circuits to needs-login; a merely unreachable candidate is skipped.
|
|
const probeConnectionCandidates = async (
|
|
candidates: MobileTransportCandidate[],
|
|
token: string | undefined,
|
|
options?: { fast?: boolean },
|
|
): Promise<ProbeResult> => {
|
|
const requestOptions = options?.fast ? { totalTimeoutMs: MOBILE_FAST_PROBE_TIMEOUT_MS } : undefined;
|
|
// Identity gate for direct probes: when the device knows its server's identity
|
|
// (via its relay pairing), a direct candidate must report the SAME serverId in
|
|
// /health before we send the bearer token to it — a re-assigned LAN address may
|
|
// now belong to a different machine. Older servers omit serverId from /health;
|
|
// the gate only rejects an explicit mismatch (matching today's behavior otherwise).
|
|
const expectedServerId = relayCandidateOf({ candidates })?.serverId ?? null;
|
|
const relayCandidate = candidates.find((c): c is Extract<MobileTransportCandidate, { kind: 'relay' }> => c.kind === 'relay') ?? null;
|
|
const directList = candidates.filter((c): c is Extract<MobileTransportCandidate, { kind: 'direct' }> => c.kind === 'direct');
|
|
|
|
const probeDirectChain = async (): Promise<ProbeResult> => {
|
|
for (const candidate of directList) {
|
|
const url = normalizeConnectionUrl(candidate.url) || candidate.url;
|
|
const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
|
|
// /health is unauthenticated by design — never send the bearer token to an
|
|
// address whose identity has not been checked yet.
|
|
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 (expectedServerId) {
|
|
const payload = await health.json().catch(() => null);
|
|
const reported = payload && typeof payload === 'object' ? (payload as Record<string, unknown>).serverId : null;
|
|
if (typeof reported === 'string' && reported && reported !== expectedServerId) {
|
|
logConnect('probe:server-id-mismatch', { url });
|
|
continue;
|
|
}
|
|
}
|
|
// With a bearer token, probe EXACTLY the way the runtime authenticates:
|
|
// bearer-only, no cookies. A leftover valid oc_ui_session cookie in the
|
|
// WebView otherwise answers "authenticated" for a revoked/expired token,
|
|
// the probe passes, and the app dies later on bootstrap's bearer-only
|
|
// 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);
|
|
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 || (!session.ok && session.status !== 404)) continue;
|
|
const status = await readSessionStatus(session);
|
|
if (status && status.disabled !== true && status.authenticated === false) return { status: 'needs-login' };
|
|
// A cookie-only native session (authenticated, but not a `client` bearer scope
|
|
// and not auth-disabled) is not enough — the native runtime transport needs a
|
|
// bearer token, so fall through to the password flow to mint one.
|
|
const authDisabled = status?.disabled === true;
|
|
if (!token && isCapacitorApp() && !authDisabled && status?.scope !== 'client') return { status: 'needs-login' };
|
|
return { status: 'ok', transport: { kind: 'direct', url } };
|
|
}
|
|
return { status: 'unreachable' };
|
|
};
|
|
|
|
const probeRelay = async (): Promise<ProbeResult> => {
|
|
if (!relayCandidate) return { status: 'unreachable' };
|
|
// keepTunnel: an 'ok' probe hands its live tunnel to switchToTransport,
|
|
// 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(
|
|
relayCandidate.relay,
|
|
token,
|
|
undefined,
|
|
options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : MOBILE_CONNECT_TIMEOUT_MS,
|
|
{ keepTunnel: true },
|
|
);
|
|
if (outcome === 'ok') return { status: 'ok', transport: { kind: 'relay', relay: relayCandidate.relay, tunnel } };
|
|
if (outcome === 'needs-login' || outcome === 'auth-failed') return { status: 'needs-login' };
|
|
return { status: 'unreachable' };
|
|
};
|
|
|
|
if (!relayCandidate) return probeDirectChain();
|
|
if (directList.length === 0) return probeRelay();
|
|
|
|
// Race: direct keeps its priority via the headstart; the loser's work is
|
|
// discarded (an unused relay tunnel is closed, a late direct success is
|
|
// reconciled later by reprobe/candidate-refresh which already prefer direct).
|
|
return new Promise<ProbeResult>((resolve) => {
|
|
let settled = false;
|
|
let relayCancelled = false;
|
|
let headstartTimer: number | undefined;
|
|
let directResult: ProbeResult | null = null;
|
|
let relayResult: ProbeResult | null = null;
|
|
|
|
const closeUnusedRelayTunnel = (result: ProbeResult | null) => {
|
|
if (result?.status === 'ok' && result.transport.kind === 'relay') result.transport.tunnel?.close();
|
|
};
|
|
const finish = (result: ProbeResult) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(result);
|
|
};
|
|
|
|
const startRelayProbe = () => {
|
|
if (relayCancelled || settled) return;
|
|
if (headstartTimer !== undefined) {
|
|
window.clearTimeout(headstartTimer);
|
|
headstartTimer = undefined;
|
|
}
|
|
void probeRelay().then((result) => {
|
|
relayResult = result;
|
|
if (settled || relayCancelled) {
|
|
closeUnusedRelayTunnel(result);
|
|
return;
|
|
}
|
|
if (result.status === 'ok' || result.status === 'needs-login') {
|
|
finish(result);
|
|
return;
|
|
}
|
|
// Relay unreachable: direct is the only hope left.
|
|
if (directResult) finish(directResult);
|
|
});
|
|
};
|
|
|
|
void probeDirectChain().then((result) => {
|
|
directResult = result;
|
|
if (settled) return;
|
|
if (result.status === 'ok' || result.status === 'needs-login') {
|
|
relayCancelled = true;
|
|
if (headstartTimer !== undefined) window.clearTimeout(headstartTimer);
|
|
closeUnusedRelayTunnel(relayResult);
|
|
finish(result);
|
|
return;
|
|
}
|
|
// Every direct candidate is unreachable: hand over to relay immediately
|
|
// (skipping any remaining headstart) or settle on its finished verdict.
|
|
if (relayResult) {
|
|
finish(relayResult);
|
|
return;
|
|
}
|
|
startRelayProbe();
|
|
});
|
|
|
|
headstartTimer = window.setTimeout(startRelayProbe, RELAY_RACE_HEADSTART_MS);
|
|
});
|
|
};
|
|
|
|
// Switch the runtime to a chosen transport. `runtimeKey` is the STABLE device
|
|
// identity — passing the same key for a device's LAN and relay transports makes a
|
|
// LAN⇄relay swap a transport-only change (not an instance switch), so the app can
|
|
// keep the user's session instead of tearing everything down.
|
|
const switchToTransport = (
|
|
transport: ChosenTransport,
|
|
token: string | null,
|
|
options?: { runtimeKey?: string; grant?: string },
|
|
): void => {
|
|
if (transport.kind === 'relay') {
|
|
switchToRelayRuntime(transport.relay, token, options?.grant, options?.runtimeKey, transport.tunnel);
|
|
} else {
|
|
switchRuntimeEndpoint({ apiBaseUrl: transport.url, clientToken: token, runtimeKey: options?.runtimeKey });
|
|
}
|
|
// Every live connection is an opportunity to learn the server's CURRENT LAN
|
|
// addresses (pairing-payload candidates go stale when DHCP reassigns the
|
|
// host's IP). Background-only: never blocks or repaints the connect flow.
|
|
scheduleCandidateRefresh();
|
|
};
|
|
|
|
// The display label of the instance cold-launch auto-connect will try (the
|
|
// most-recently-used saved connection) — shown on the launch splash while the
|
|
// connect races run. Null when there is nothing to auto-connect to.
|
|
export const getAutoConnectTargetLabel = (): string | null => {
|
|
const candidate = readConnections()[0];
|
|
return candidate?.label?.trim() ? candidate.label : null;
|
|
};
|
|
|
|
// Cold-launch auto-connect: silently reconnect to the most-recently-used saved
|
|
// instance so a returning user (and notification deep-links) land straight in the
|
|
// app instead of the connect screen. Probes the device's candidates in order, so
|
|
// it lands on whichever transport is reachable right now. Returns true and switches
|
|
// the runtime endpoint when reachable AND we already have a usable bearer token;
|
|
// returns false — caller shows the connect screen — when there is no saved
|
|
// instance, it's unreachable, or it needs a (re)login. No prompts or UI state.
|
|
export type AutoConnectOutcome =
|
|
| { status: 'connected' }
|
|
/** No saved instance / no saved token — nothing to report to the user. */
|
|
| { status: 'no-candidate' }
|
|
| { status: 'unreachable'; label: string }
|
|
/** The saved token was rejected (expired/revoked) — the user must sign in again. */
|
|
| { status: 'needs-login'; label: string };
|
|
|
|
export const autoConnectLastInstance = async (options?: { fast?: boolean; skipIfConnected?: boolean }): Promise<AutoConnectOutcome> => {
|
|
const fast = options?.fast !== false;
|
|
await migrateLegacyInlineTokens();
|
|
const candidate = readConnections()[0]; // sorted most-recent-first
|
|
logConnect('auto-connect:start', { hasCandidate: Boolean(candidate), fast });
|
|
if (!candidate) return { status: 'no-candidate' };
|
|
|
|
// The runtime transport authenticates with a bearer token when the server
|
|
// issued one. A connection saved WITHOUT a token means its last successful
|
|
// connect was tokenless (server auth disabled) — probe it the same way; the
|
|
// probe itself reports needs-login if the server has since enabled auth. Only
|
|
// an EXPECTED token that cannot be read must go through the login UI.
|
|
let token: string | undefined;
|
|
if (isCapacitorApp()) {
|
|
if (candidate.hasToken) {
|
|
token = await readSecureToken(secureTokenKeyOf(candidate));
|
|
if (!token) return { status: 'no-candidate' };
|
|
}
|
|
} else {
|
|
token = candidate.clientToken;
|
|
if (!token && candidate.hasToken) return { status: 'no-candidate' };
|
|
}
|
|
|
|
// Fast probe by default: the cold-launch splash should decide in a couple of
|
|
// seconds, not sit through the full connect timeouts on a dead LAN candidate.
|
|
// Callers retrying after an 'unreachable' verdict pass fast:false so the slow
|
|
// retry gets the full connect budget (relay cold starts — TLS + WS + E2EE
|
|
// 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 !== '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)
|
|
switchToTransport(result.transport, token ?? null, { runtimeKey: secureTokenKeyOf(candidate) });
|
|
return { status: 'connected' };
|
|
};
|
|
|
|
export const validateMobileConnectionSession = async (input: {
|
|
url: string;
|
|
clientToken?: string | null;
|
|
}, options?: { fast?: boolean }): Promise<boolean> => {
|
|
let url = '';
|
|
try {
|
|
url = normalizeConnectionUrl(input.url);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!url) return false;
|
|
|
|
const token = input.clientToken?.trim() || undefined;
|
|
const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
|
|
const requestOptions = options?.fast ? { totalTimeoutMs: MOBILE_FAST_PROBE_TIMEOUT_MS } : undefined;
|
|
|
|
const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }, requestOptions);
|
|
if (!health?.ok) return false;
|
|
|
|
// Bearer-only when a token is present — see the probe note about stale
|
|
// session cookies masking a revoked token.
|
|
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions);
|
|
if (!session || (!session.ok && session.status !== 404)) return false;
|
|
|
|
const status = await readSessionStatus(session);
|
|
return !(status && status.disabled !== true && status.authenticated === false);
|
|
};
|
|
|
|
// A live transport a redeem/login settled on: a reachable direct URL, or an OPEN
|
|
// relay tunnel the caller must close after use.
|
|
type LiveTransport =
|
|
| { kind: 'direct'; url: string }
|
|
| { kind: 'relay'; relay: MobileRelayConfig; tunnel: ReturnType<typeof createRelayTunnelClient> };
|
|
|
|
// Convert pairing-payload candidates into ordered mobile transport candidates:
|
|
// priority number ascending, relay last on ties (relay is the fallback), invalid
|
|
// entries dropped. The resulting order is what gets persisted and re-probed.
|
|
const pairingCandidatesToMobile = (candidates: PairingEndpointCandidate[]): MobileTransportCandidate[] =>
|
|
[...candidates]
|
|
.sort((left, right) => {
|
|
const delta = (left.priority ?? 100) - (right.priority ?? 100);
|
|
if (delta !== 0) return delta;
|
|
const rank = (c: PairingEndpointCandidate): number => (c.type === 'relay' ? 2 : c.url.startsWith('https://') ? 0 : 1);
|
|
return rank(left) - rank(right);
|
|
})
|
|
.flatMap((c): MobileTransportCandidate[] => {
|
|
if (c.type === 'relay') {
|
|
const relay = parseRelayConfig({ relayUrl: c.relayUrl, serverId: c.serverId, hostEncPubJwk: c.hostEncPubJwk });
|
|
return relay ? [{ kind: 'relay', relay }] : [];
|
|
}
|
|
return directCandidatesFromUrl(c.url);
|
|
});
|
|
|
|
// Establish the first reachable LIVE transport for an ordered candidate set:
|
|
// health-check a direct URL, or open + health-check a relay tunnel. A returned
|
|
// relay transport owns an OPEN tunnel the caller must close.
|
|
const establishLiveTransport = async (
|
|
candidates: MobileTransportCandidate[],
|
|
): Promise<LiveTransport | null> => {
|
|
// Same identity gate as probeConnectionCandidates: a redeem/login must not send
|
|
// its secret to a direct address that reports a different server identity.
|
|
const expectedServerId = relayCandidateOf({ candidates })?.serverId ?? null;
|
|
for (const candidate of candidates) {
|
|
if (candidate.kind === 'relay') {
|
|
const tunnel = createRelayTunnelClient(candidate.relay);
|
|
const health = await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, tunnel.fetch('/health').catch(() => null));
|
|
logConnect('establish:relay:health', { ok: health?.ok === true, status: health?.status ?? null });
|
|
if (health?.ok) return { kind: 'relay', relay: candidate.relay, tunnel };
|
|
tunnel.close();
|
|
continue;
|
|
}
|
|
const url = normalizeConnectionUrl(candidate.url) || candidate.url;
|
|
const health = await requestWithTimeout(`${url}/health`, { method: 'GET' });
|
|
logConnect('establish:direct:health', { ok: health?.ok === true, status: health?.status ?? null });
|
|
if (!health?.ok) continue;
|
|
if (expectedServerId) {
|
|
const payload = await health.json().catch(() => null);
|
|
const reported = payload && typeof payload === 'object' ? (payload as Record<string, unknown>).serverId : null;
|
|
if (typeof reported === 'string' && reported && reported !== expectedServerId) {
|
|
logConnect('establish:server-id-mismatch', { url });
|
|
continue;
|
|
}
|
|
}
|
|
return { kind: 'direct', url };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Relay-aware session validation for the ACTIVE runtime (native resume path).
|
|
// In relay mode there is no reachable URL to probe — validate through the live
|
|
// tunnel via runtimeFetch. A transport failure/timeout is transient (the tunnel
|
|
// reconnects on its own) and must not masquerade as a revoked session, so only
|
|
// an explicit auth rejection reports invalid.
|
|
const validateActiveRuntimeSession = async (input: {
|
|
url: string;
|
|
clientToken?: string | null;
|
|
}, options?: { fast?: boolean }): Promise<boolean> => {
|
|
if (!isRelayModeActive()) return validateMobileConnectionSession(input, options);
|
|
const session = await raceWithTimeout(
|
|
options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : RELAY_CONNECT_TIMEOUT_MS,
|
|
runtimeFetch('/auth/session').then((response): Response | null => response).catch(() => null),
|
|
);
|
|
if (!session) return true;
|
|
if (session.status === 401) return false;
|
|
if (!session.ok && session.status !== 404) return true;
|
|
const status = await readSessionStatus(session);
|
|
return !(status && status.disabled !== true && status.authenticated === false);
|
|
};
|
|
|
|
// Which TRANSPORT is currently live? The runtime key is the stable device
|
|
// identity (same for a device's LAN and relay), so the active transport is read
|
|
// from the runtime's mode instead: relay when the tunnel is active, else the
|
|
// direct base URL.
|
|
const transportMatchesCurrentRuntime = (transport: ChosenTransport): boolean =>
|
|
transport.kind === 'relay'
|
|
? isRelayModeActive()
|
|
: !isRelayModeActive() && isSameConnectionUrl(transport.url, getRuntimeApiBaseUrl());
|
|
|
|
// The saved device currently bound to the runtime, matched by its stable key.
|
|
const findActiveConnection = (): MobileSavedConnection | null => {
|
|
const runtimeKey = getRuntimeKey();
|
|
if (!runtimeKey) return null;
|
|
return readConnections().find((connection) => secureTokenKeyOf(connection) === runtimeKey) ?? null;
|
|
};
|
|
|
|
// Exported for the connections list: is this saved device the active runtime?
|
|
export const isActiveRuntimeConnection = (connection: MobileSavedConnection): boolean => {
|
|
const runtimeKey = getRuntimeKey();
|
|
return Boolean(runtimeKey) && secureTokenKeyOf(connection) === runtimeKey;
|
|
};
|
|
|
|
export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'needs-login' | 'no-connection';
|
|
|
|
// App-resume re-probe: when the app wakes (Capacitor `isActive`), the network may
|
|
// have changed while it slept, so re-select the active device's transport and
|
|
// hot-switch if a better one is reachable — the seamless "LAN at home ⇄ relay
|
|
// away, no re-pairing" swap. Efficient: it only probes candidates HIGHER priority
|
|
// than the current one ("did a better transport come back?"); if none, it
|
|
// 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
|
|
// and just refresh; 'unreachable'/'no-connection' → show the connect screen.
|
|
export const reprobeActiveConnection = async (options?: { fast?: boolean }): Promise<ReprobeOutcome> => {
|
|
const fast = options?.fast !== false;
|
|
const active = findActiveConnection();
|
|
if (!active) {
|
|
logConnect('reprobe:no-connection', { runtimeKey: Boolean(getRuntimeKey()) });
|
|
return 'no-connection';
|
|
}
|
|
|
|
let token: string | undefined;
|
|
if (isCapacitorApp()) {
|
|
token = active.hasToken ? await readSecureToken(secureTokenKeyOf(active)) : undefined;
|
|
} else {
|
|
token = active.clientToken;
|
|
}
|
|
// Tokenless is valid (server auth disabled — the probe reports needs-login if
|
|
// that changed); bail only when an EXPECTED token cannot be read. 'unreachable'
|
|
// (not needs-login) so the resume retry ladder re-reads the token — a transient
|
|
// secure-storage failure must not force a re-login.
|
|
if (!token && active.hasToken) {
|
|
logConnect('reprobe:no-token', { hasToken: true });
|
|
return 'unreachable';
|
|
}
|
|
logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast, hasToken: Boolean(token) });
|
|
|
|
const currentIndex = active.candidates.findIndex(
|
|
(candidate) => transportMatchesCurrentRuntime(candidate.kind === 'relay' ? { kind: 'relay', relay: candidate.relay } : { kind: 'direct', url: candidate.url }),
|
|
);
|
|
|
|
// 1. A higher-priority transport becoming reachable means "came home" (relay → LAN).
|
|
const higher = currentIndex >= 0 ? active.candidates.slice(0, currentIndex) : active.candidates;
|
|
const better = await probeConnectionCandidates(higher, token, { fast });
|
|
logConnect('reprobe:better', { status: better.status, probed: higher.length });
|
|
if (better.status === 'ok') {
|
|
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
|
|
switchToTransport(better.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) });
|
|
return 'switched';
|
|
}
|
|
// The shared token was explicitly rejected — no transport will accept it.
|
|
if (better.status === 'needs-login') return 'needs-login';
|
|
|
|
// 2. No better transport — is the current one still alive on its live channel?
|
|
if (currentIndex >= 0) {
|
|
const stillValid = await validateActiveRuntimeSession({ url: getRuntimeApiBaseUrl(), clientToken: token }, { fast });
|
|
logConnect('reprobe:current', { stillValid });
|
|
if (stillValid) {
|
|
// 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
|
|
// background — if it moved, the refreshed candidates trigger one more
|
|
// re-probe and the hot-switch back to direct.
|
|
scheduleCandidateRefresh();
|
|
return 'unchanged';
|
|
}
|
|
}
|
|
|
|
// 3. Current transport is dead — fall through to lower-priority candidates.
|
|
const lower = currentIndex >= 0 ? active.candidates.slice(currentIndex + 1) : [];
|
|
const fallback = await probeConnectionCandidates(lower, token, { fast });
|
|
logConnect('reprobe:fallback', { status: fallback.status, probed: lower.length });
|
|
if (fallback.status === 'ok') {
|
|
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
|
|
switchToTransport(fallback.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) });
|
|
return 'switched';
|
|
}
|
|
if (fallback.status === 'needs-login') return 'needs-login';
|
|
return 'unreachable';
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Candidate refresh: learn the server's CURRENT direct addresses over the live
|
|
// (authenticated) runtime transport and update the saved candidate set, so a
|
|
// device that paired under an old DHCP lease is not stuck on the relay forever.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Let the post-switch bootstrap traffic settle before adding our own request.
|
|
const CANDIDATE_REFRESH_DELAY_MS = 5_000;
|
|
|
|
type CandidateRefreshResult = 'updated' | 'unchanged' | 'skipped';
|
|
|
|
let candidateRefreshInFlight = false;
|
|
|
|
// Fetch /api/client-auth/connection/candidates through the ACTIVE runtime
|
|
// transport (direct or relay — runtimeFetch routes it) and merge the reported
|
|
// LAN addresses into the active saved connection:
|
|
// - fresh `lan` candidates REPLACE the previous http:// (LAN-class) direct
|
|
// candidates — a LAN address the server no longer holds is dead weight that
|
|
// slows every future re-probe;
|
|
// - https:// (tunnel-class) direct candidates are preserved — the server does
|
|
// not know its own public tunnel hostnames;
|
|
// - the relay candidate is preserved as the last-resort transport.
|
|
// Only runs for relay-paired connections: their token/runtime key derives from
|
|
// the stable relay identity, so rewriting direct URLs cannot orphan the stored
|
|
// token. The response must echo the connection's serverId or it is ignored.
|
|
const refreshActiveConnectionCandidates = async (): Promise<CandidateRefreshResult> => {
|
|
if (candidateRefreshInFlight) return 'skipped';
|
|
const active = findActiveConnection();
|
|
if (!active) {
|
|
logConnect('candidates:refresh-skip', { reason: 'no-active-connection' });
|
|
return 'skipped';
|
|
}
|
|
const relay = relayCandidateOf(active);
|
|
if (!relay) {
|
|
logConnect('candidates:refresh-skip', { reason: 'no-relay-candidate' });
|
|
return 'skipped';
|
|
}
|
|
candidateRefreshInFlight = true;
|
|
try {
|
|
const response = await raceWithTimeout(
|
|
RELAY_CONNECT_TIMEOUT_MS,
|
|
runtimeFetch('/api/client-auth/connection/candidates').then((r): Response | null => r).catch(() => null),
|
|
);
|
|
if (!response?.ok) {
|
|
logConnect('candidates:refresh-skip', { reason: 'fetch-failed', status: response?.status ?? null });
|
|
return 'skipped';
|
|
}
|
|
const payload = await response.json().catch(() => null) as { serverId?: unknown; candidates?: unknown } | null;
|
|
// Identity gate: the refresh must come from the server this device paired
|
|
// with. Old servers (no serverId) are skipped rather than trusted blindly.
|
|
if (!payload || payload.serverId !== relay.serverId) {
|
|
logConnect('candidates:refresh-skip', { reason: 'server-id-mismatch' });
|
|
return 'skipped';
|
|
}
|
|
const reported = Array.isArray(payload.candidates) ? payload.candidates : [];
|
|
const lanUrls: string[] = [];
|
|
for (const entry of reported) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const record = entry as Record<string, unknown>;
|
|
if (record.type !== 'lan' || typeof record.url !== 'string') continue;
|
|
try {
|
|
const url = normalizeConnectionUrl(record.url);
|
|
if (url && !lanUrls.includes(url)) lanUrls.push(url);
|
|
} catch {
|
|
// invalid URL → drop
|
|
}
|
|
}
|
|
// No LAN reported (loopback-only bind or interface-scan failure): keep the
|
|
// existing candidates — deleting them on a possibly-transient empty answer
|
|
// would be silent data loss; a stale entry only costs one fast probe.
|
|
if (lanUrls.length === 0) {
|
|
logConnect('candidates:refresh-skip', { reason: 'no-lan-reported' });
|
|
return 'skipped';
|
|
}
|
|
const preservedHttps = directCandidates(active).filter((candidate) => candidate.url.startsWith('https://'));
|
|
const next: MobileTransportCandidate[] = [
|
|
...lanUrls.map((url): MobileTransportCandidate => ({ kind: 'direct', url })),
|
|
...preservedHttps,
|
|
{ kind: 'relay', relay },
|
|
];
|
|
const unchanged = JSON.stringify(active.candidates.map(serializeCandidate)) === JSON.stringify(next.map(serializeCandidate));
|
|
if (unchanged) return 'unchanged';
|
|
logConnect('candidates:refreshed', { lanCount: lanUrls.length });
|
|
await upsertMobileConnection({ id: active.id, label: active.label, candidates: next });
|
|
return 'updated';
|
|
} finally {
|
|
candidateRefreshInFlight = false;
|
|
}
|
|
};
|
|
|
|
// Background candidate refresh + opportunistic hot-switch. Fire-and-forget by
|
|
// design: no UI state, no rerenders — the only visible effect is the runtime
|
|
// quietly switching relay → direct when a fresh LAN address turns out reachable
|
|
// (reprobeActiveConnection re-reads storage and applies its usual identity-gated
|
|
// probe + stable-runtime-key switch). Converges: a re-entered refresh reports
|
|
// 'unchanged'/'skipped', which never triggers another re-probe.
|
|
const scheduleCandidateRefresh = (): void => {
|
|
if (typeof window === 'undefined') return;
|
|
window.setTimeout(() => {
|
|
void (async () => {
|
|
const result = await refreshActiveConnectionCandidates().catch((): CandidateRefreshResult => 'skipped');
|
|
logConnect('candidates:refresh-result', { result });
|
|
if (result === 'updated' && isRelayModeActive()) {
|
|
await reprobeActiveConnection().catch(() => null);
|
|
}
|
|
})();
|
|
}, CANDIDATE_REFRESH_DELAY_MS);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared connection controller
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type UseMobileConnection = {
|
|
connections: MobileSavedConnection[];
|
|
isBusy: boolean;
|
|
isPasswordBusy: boolean;
|
|
error: string | null;
|
|
pendingConnection: MobilePendingConnection | null;
|
|
connect: (input: MobileConnectInput) => Promise<void>;
|
|
redeemPairingConnection: (payload: PairingConnectionPayload) => Promise<void>;
|
|
submitPassword: (password: string) => Promise<void>;
|
|
cancelPassword: () => void;
|
|
saveConnection: (input: MobileConnectInput) => Promise<MobileSavedConnection | null>;
|
|
removeConnection: (id: string) => Promise<MobileSavedConnection | null>;
|
|
setError: (message: string | null) => void;
|
|
};
|
|
|
|
// `onConnected` fires once the runtime endpoint is switched (the caller navigates
|
|
// away / closes its surface from there).
|
|
export const useMobileConnection = (onConnected: () => void): UseMobileConnection => {
|
|
const { t } = useI18n();
|
|
const [connections, setConnections] = React.useState<MobileSavedConnection[]>(() => readConnections());
|
|
const [busyOperation, setBusyOperation] = React.useState<'connect' | 'password' | 'pairing' | null>(null);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [pendingConnection, setPendingConnection] = React.useState<MobilePendingConnection | null>(null);
|
|
const connectionsRef = React.useRef(connections);
|
|
const busyRef = React.useRef<'connect' | 'password' | 'pairing' | null>(null);
|
|
const passwordOperationRef = React.useRef(createMobilePasswordOperationTracker());
|
|
|
|
const applyConnections = React.useCallback((next: MobileSavedConnection[]) => {
|
|
connectionsRef.current = next;
|
|
setConnections(next);
|
|
}, []);
|
|
|
|
const beginBusy = React.useCallback((operation: 'connect' | 'password' | 'pairing') => {
|
|
busyRef.current = operation;
|
|
setBusyOperation(operation);
|
|
}, []);
|
|
|
|
const endBusy = React.useCallback((operation: 'connect' | 'password' | 'pairing') => {
|
|
if (busyRef.current !== operation) return;
|
|
busyRef.current = null;
|
|
setBusyOperation(null);
|
|
}, []);
|
|
|
|
// Refresh from storage on mount (runs the legacy-token migration too).
|
|
React.useEffect(() => {
|
|
let disposed = false;
|
|
void loadMobileConnections().then((loaded) => {
|
|
if (!disposed) applyConnections(loaded);
|
|
});
|
|
return () => { disposed = true; };
|
|
}, [applyConnections]);
|
|
|
|
// Persist metadata for a connection and reflect it in state immediately.
|
|
const persistMetadata = React.useCallback((draft: { id?: string; label: string; candidates: MobileTransportCandidate[]; clientToken?: string }) => {
|
|
const next = upsertConnectionInList(connectionsRef.current, draft);
|
|
applyConnections(next);
|
|
writeConnections(next);
|
|
return next;
|
|
}, [applyConnections]);
|
|
|
|
const connect = React.useCallback(async (input: MobileConnectInput) => {
|
|
setError(null);
|
|
beginBusy('connect');
|
|
try {
|
|
const candidates = buildCandidatesFromInput(input);
|
|
if (candidates.length === 0) {
|
|
setError(t('mobile.connect.error.urlRequired'));
|
|
return;
|
|
}
|
|
const saved = input.id
|
|
? connectionsRef.current.find((c) => c.id === input.id)
|
|
: connectionsRef.current.find((c) => candidateSetsMatch(c.candidates, candidates));
|
|
const label = input.label?.trim() || saved?.label || getConnectionLabel(connectionDisplayUrl({ candidates }));
|
|
const grant = input.relayGrant;
|
|
|
|
// Resolve a token: explicit input wins, otherwise read the saved one.
|
|
let token = input.clientToken?.trim() || undefined;
|
|
const tokenIsNew = Boolean(token);
|
|
if (!token) {
|
|
if (isCapacitorApp()) {
|
|
if (saved?.hasToken) token = await readSecureToken(secureTokenKeyOf({ candidates }));
|
|
} else {
|
|
token = saved?.clientToken;
|
|
}
|
|
}
|
|
|
|
logConnect('connect:start', { candidates: candidates.map((c) => c.kind), hasToken: Boolean(token) });
|
|
const result = await probeConnectionCandidates(candidates, token);
|
|
logConnect('connect:probe', { status: result.status });
|
|
|
|
if (result.status === 'unreachable') {
|
|
setError(t('mobile.connect.error.unreachable'));
|
|
return;
|
|
}
|
|
if (result.status === 'needs-login') {
|
|
persistMetadata({ id: saved?.id, label, candidates });
|
|
setPendingConnection({
|
|
id: saved?.id ?? crypto.randomUUID(),
|
|
label,
|
|
candidates,
|
|
relay: relayCandidateOf({ candidates }) ?? undefined,
|
|
relayGrant: grant,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Connected. Persist a user-supplied token before switching so a cold
|
|
// restart won't re-prompt.
|
|
if (token && tokenIsNew && isCapacitorApp()) {
|
|
await writeSecureToken(secureTokenKeyOf({ candidates }), token);
|
|
}
|
|
persistMetadata({ id: saved?.id, label, candidates, clientToken: token });
|
|
switchToTransport(result.transport, token ?? null, { runtimeKey: secureTokenKeyOf({ candidates }), grant });
|
|
onConnected();
|
|
} catch (error) {
|
|
console.warn('[mobile-connect] connect threw', error);
|
|
setError(t('mobile.connect.error.invalidUrl'));
|
|
} finally {
|
|
endBusy('connect');
|
|
}
|
|
}, [beginBusy, endBusy, onConnected, persistMetadata, t]);
|
|
|
|
const redeemPairingConnection = React.useCallback(async (payload: PairingConnectionPayload) => {
|
|
if (busyRef.current === 'pairing') return;
|
|
setError(null);
|
|
beginBusy('pairing');
|
|
const deviceCandidates = pairingCandidatesToMobile(payload.candidates);
|
|
// A chosen relay transport owns an open tunnel; close it unless the switch
|
|
// adopted it as the runtime tunnel.
|
|
let chosen: LiveTransport | null = null;
|
|
let adopted = false;
|
|
try {
|
|
// 1. Find the first reachable transport across all candidates.
|
|
chosen = await establishLiveTransport(deviceCandidates);
|
|
if (!chosen) {
|
|
setError(t('mobile.connect.error.unreachable'));
|
|
return;
|
|
}
|
|
|
|
// 2. Redeem the one-time secret over that transport. Single-use: we never
|
|
// retry other candidates once redeem runs (the secret is consumed).
|
|
const redeemBody = JSON.stringify({
|
|
pairingId: payload.pairingId,
|
|
secret: payload.secret,
|
|
clientLabel: 'OpenChamber Mobile',
|
|
clientKind: 'mobile',
|
|
deviceName: 'OpenChamber Mobile',
|
|
devicePlatform: mobileDevicePlatform(),
|
|
// Re-pairing this same phone reuses its one device record instead of
|
|
// adding a duplicate row on the server.
|
|
dedupeKey: mobileClientDedupeKey(),
|
|
});
|
|
const redeemInit = {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: redeemBody,
|
|
} as const;
|
|
const response = chosen.kind === 'relay'
|
|
? await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, chosen.tunnel.fetch('/api/client-auth/pairing/redeem', redeemInit).catch(() => null))
|
|
: await requestWithTimeout(`${chosen.url}/api/client-auth/pairing/redeem`, redeemInit);
|
|
if (!response?.ok) {
|
|
setError(t('mobile.connect.error.authRequired'));
|
|
return;
|
|
}
|
|
const result = await response.json().catch(() => null) as PairingRedeemResponse | null;
|
|
const issuedToken = typeof result?.clientToken === 'string' ? result.clientToken.trim() : '';
|
|
if (!issuedToken) {
|
|
setError(t('mobile.connect.error.authRequired'));
|
|
return;
|
|
}
|
|
// Name the connection by the issuing server (its hostname), not the
|
|
// per-device pairing label — that label is the operator's name for THIS
|
|
// phone in their device list, not a name for the server we connect to.
|
|
const serverLabel = typeof result?.server?.label === 'string' ? result.server.label : '';
|
|
const label = payload.label || serverLabel || getConnectionLabel(connectionDisplayUrl({ candidates: deviceCandidates }));
|
|
|
|
// 3. Persist the device with ALL its candidates + one token, then switch to
|
|
// whichever transport answered. Reconnect re-probes the full set so the
|
|
// device works at home (direct) and away (relay) with no re-pairing.
|
|
if (isCapacitorApp()) {
|
|
const stored = await writeSecureToken(secureTokenKeyOf({ candidates: deviceCandidates }), issuedToken);
|
|
if (!stored) {
|
|
setError(t('mobile.connect.error.authRequired'));
|
|
return;
|
|
}
|
|
}
|
|
persistMetadata({ label, candidates: deviceCandidates, clientToken: issuedToken });
|
|
// A relay transport hands its live redeem tunnel to the runtime (adopted
|
|
// inside switchToTransport) — closing it here would tear down the runtime.
|
|
switchToTransport(
|
|
chosen.kind === 'relay' ? { kind: 'relay', relay: chosen.relay, tunnel: chosen.tunnel } : { kind: 'direct', url: chosen.url },
|
|
issuedToken,
|
|
{ runtimeKey: secureTokenKeyOf({ candidates: deviceCandidates }) },
|
|
);
|
|
adopted = chosen.kind === 'relay';
|
|
onConnected();
|
|
} catch (error) {
|
|
console.warn('[mobile-connect] pairing threw', error);
|
|
setError(t('mobile.connect.error.authRequired'));
|
|
} finally {
|
|
if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close();
|
|
endBusy('pairing');
|
|
}
|
|
}, [beginBusy, endBusy, onConnected, persistMetadata, t]);
|
|
|
|
const submitPassword = React.useCallback(async (password: string) => {
|
|
if (!pendingConnection || !password.trim() || busyRef.current === 'password') return;
|
|
setError(null);
|
|
beginBusy('password');
|
|
const operation = passwordOperationRef.current.begin();
|
|
const isCurrentOperation = () => passwordOperationRef.current.isCurrent(operation);
|
|
const { id, label, candidates } = pendingConnection;
|
|
// A chosen relay transport owns an open tunnel; close it unless the switch
|
|
// adopted it as the runtime tunnel.
|
|
let chosen: LiveTransport | null = null;
|
|
let adopted = false;
|
|
try {
|
|
// Log in over whichever transport is reachable. Relay login rides the
|
|
// tunnel; cookies never cross it, so an issued bearer token is mandatory
|
|
// there. `issueClientToken` mints the device's token in one round-trip.
|
|
chosen = await establishLiveTransport(candidates);
|
|
if (!isCurrentOperation()) return;
|
|
if (!chosen) {
|
|
setError(t('mobile.connect.error.unreachable'));
|
|
return;
|
|
}
|
|
const loginInit = {
|
|
method: 'POST',
|
|
credentials: 'include' as const,
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
// Same dedupe key as pairing: re-authenticating after a token expires
|
|
// reuses this phone's existing device record instead of duplicating it.
|
|
body: JSON.stringify({ password, trustDevice: true, issueClientToken: true, clientLabel: 'OpenChamber Mobile', clientKind: 'mobile', devicePlatform: mobileDevicePlatform(), dedupeKey: mobileClientDedupeKey() }),
|
|
};
|
|
logConnect('password:start', { transport: chosen.kind });
|
|
const response = chosen.kind === 'relay'
|
|
? await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, chosen.tunnel.fetch('/auth/session', loginInit).catch(() => null))
|
|
: await requestWithTimeout(`${chosen.url}/auth/session`, loginInit);
|
|
if (!isCurrentOperation()) return;
|
|
logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null });
|
|
if (!response?.ok) {
|
|
setError(t('mobile.connect.error.passwordFailed'));
|
|
return;
|
|
}
|
|
const body = await response.json().catch(() => null) as { clientToken?: unknown } | null;
|
|
if (!isCurrentOperation()) return;
|
|
const issuedToken = typeof body?.clientToken === 'string' ? body.clientToken.trim() : '';
|
|
logConnect('password:token', { issued: Boolean(issuedToken) });
|
|
|
|
// A bearer token is required for relay (no cookies over the tunnel) and for
|
|
// the native runtime transport; a cookie-only success is only enough for a
|
|
// direct connection in a browser.
|
|
if (!issuedToken) {
|
|
if (chosen.kind === 'direct' && !isCapacitorApp()) {
|
|
persistMetadata({ id, label, candidates });
|
|
setPendingConnection(null);
|
|
switchToTransport({ kind: 'direct', url: chosen.url }, null, { runtimeKey: secureTokenKeyOf({ candidates }) });
|
|
onConnected();
|
|
return;
|
|
}
|
|
setError(t('mobile.connect.error.authRequired'));
|
|
return;
|
|
}
|
|
|
|
// Persist the token BEFORE switching (no fire-and-forget).
|
|
if (isCapacitorApp()) {
|
|
if (!isCurrentOperation()) return;
|
|
await writeSecureToken(secureTokenKeyOf({ candidates }), issuedToken);
|
|
if (!isCurrentOperation()) return;
|
|
}
|
|
if (!isCurrentOperation()) return;
|
|
persistMetadata({ id, label, candidates, clientToken: issuedToken });
|
|
setPendingConnection(null);
|
|
// A relay transport hands its live login tunnel to the runtime (adopted
|
|
// inside switchToTransport) — closing it here would tear down the runtime.
|
|
switchToTransport(
|
|
chosen.kind === 'relay' ? { kind: 'relay', relay: chosen.relay, tunnel: chosen.tunnel } : { kind: 'direct', url: chosen.url },
|
|
issuedToken,
|
|
{ runtimeKey: secureTokenKeyOf({ candidates }) },
|
|
);
|
|
adopted = chosen.kind === 'relay';
|
|
if (!isCurrentOperation()) return;
|
|
onConnected();
|
|
} catch (error) {
|
|
if (!isCurrentOperation()) return;
|
|
console.warn('[mobile-connect] password threw', error);
|
|
setError(t('mobile.connect.error.passwordFailed'));
|
|
} finally {
|
|
if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close();
|
|
if (isCurrentOperation()) endBusy('password');
|
|
}
|
|
}, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]);
|
|
|
|
const cancelPassword = React.useCallback(() => {
|
|
passwordOperationRef.current.cancel();
|
|
endBusy('password');
|
|
setPendingConnection(null);
|
|
setError(null);
|
|
}, [endBusy]);
|
|
|
|
const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise<MobileSavedConnection | null> => {
|
|
setError(null);
|
|
let candidates = buildCandidatesFromInput(input);
|
|
const existing = input.id ? connectionsRef.current.find((connection) => connection.id === input.id) ?? null : null;
|
|
if (existing) {
|
|
// EDIT must never silently drop transports the form does not show. The
|
|
// form carries one URL, but a paired device also has a relay candidate
|
|
// (whose identity derives the Keychain token key) and possibly https
|
|
// tunnel candidates. Same merge policy as the background candidate
|
|
// refresh: the typed URL replaces the http:// (LAN-class) directs;
|
|
// https:// directs and the relay candidate are preserved. Dropping the
|
|
// relay here used to change the token key and orphan the stored token.
|
|
const inputDirects = candidates.filter((c): c is Extract<MobileTransportCandidate, { kind: 'direct' }> => c.kind === 'direct');
|
|
const preservedHttps = directCandidates(existing).filter(
|
|
(c) => c.url.startsWith('https://') && !inputDirects.some((n) => isSameConnectionUrl(n.url, c.url)),
|
|
);
|
|
const relay = relayCandidateOf(existing);
|
|
candidates = [...inputDirects, ...preservedHttps, ...(relay ? [{ kind: 'relay' as const, relay }] : [])];
|
|
}
|
|
if (candidates.length === 0) {
|
|
setError(t('mobile.connect.error.urlRequired'));
|
|
return null;
|
|
}
|
|
const clientToken = input.clientToken?.trim() || undefined;
|
|
const label = input.label?.trim() || getConnectionLabel(connectionDisplayUrl({ candidates }));
|
|
// Awaited token writes so "Save" truly persisted the secret before returning.
|
|
if (isCapacitorApp()) {
|
|
const nextKey = secureTokenKeyOf({ candidates });
|
|
if (clientToken) {
|
|
await writeSecureToken(nextKey, clientToken);
|
|
} else if (existing?.hasToken) {
|
|
// No new token typed but the edit changed the token key (e.g. a
|
|
// direct-only instance got a new URL): move the stored token to the
|
|
// new key instead of leaving it stranded under the old one.
|
|
const previousKey = secureTokenKeyOf(existing);
|
|
if (previousKey && nextKey && previousKey !== nextKey) {
|
|
const storedToken = await readSecureToken(previousKey);
|
|
if (storedToken) await writeSecureToken(nextKey, storedToken);
|
|
}
|
|
}
|
|
}
|
|
const next = persistMetadata({ id: input.id, label, candidates, clientToken });
|
|
return next.find((connection) => candidateSetsMatch(connection.candidates, candidates)) ?? null;
|
|
}, [persistMetadata, t]);
|
|
|
|
const removeConnection = React.useCallback(async (id: string): Promise<MobileSavedConnection | null> => {
|
|
const removed = connectionsRef.current.find((connection) => connection.id === id) ?? null;
|
|
const next = await deleteMobileConnection(id);
|
|
applyConnections(next);
|
|
return removed;
|
|
}, [applyConnections]);
|
|
|
|
return {
|
|
connections,
|
|
isBusy: busyOperation !== null,
|
|
isPasswordBusy: busyOperation === 'password',
|
|
error,
|
|
pendingConnection,
|
|
connect,
|
|
redeemPairingConnection,
|
|
submitPassword,
|
|
cancelPassword,
|
|
saveConnection,
|
|
removeConnection,
|
|
setError,
|
|
};
|
|
};
|