import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { toast as sonnerToast } from "@methanium/ui"; import { base64ToBytes, ConnectionState, MTPClient, } from "mtp"; import createAsyncQueue from "@tensamin/shared/asyncQueue"; import { mtp as mtpSchemas, type Calls, type Communities, type Contacts, } from "@tensamin/shared/data"; import { log } from "@tensamin/shared/log"; import { useStorage } from "@tensamin/storage/context"; import { type BoundSendFn, MTPContext, type MTPContextType, type ProtocolMessage, removeMissingContacts, type SealedRelaySend, useMessageHandlers, } from "./mtpContext"; import { DISCOVERY_TIMEOUT, INITIAL_SYNC_TIMEOUT, RECONNECT_JITTER, RECONNECT_LONG_INTERVAL, RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL, STATE_ACK_TIMEOUT, } from "./values"; type BrowserMtpClient = Awaited>; function createBrowserClient( options: Omit[0], "schemas">, ) { return MTPClient.create({ ...options, schemas: mtpSchemas, throwProtocolErrors: true, onValidationError: (error) => { log(1, "mtp", "red", "Failed to validate push message", error); }, }); } function abortError(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason : new Error("Initial state synchronization was cancelled"); } function withDeadline( promise: Promise, timeoutMs: number, timeoutMessage: string, signal: AbortSignal, ): Promise { return new Promise((resolve, reject) => { if (signal.aborted) { reject(abortError(signal)); return; } let settled = false; const finish = (complete: () => void) => { if (settled) return; settled = true; clearTimeout(timeout); signal.removeEventListener("abort", onAbort); complete(); }; const timeout = setTimeout( () => finish(() => reject(new Error(timeoutMessage))), timeoutMs, ); const onAbort = () => finish(() => reject(abortError(signal))); signal.addEventListener("abort", onAbort, { once: true }); promise.then( (value) => finish(() => resolve(value)), (error: unknown) => finish(() => reject(error)), ); }); } async function completeInitialSynchronization( client: BrowserMtpClient, subscribe: MTPContextType["subscribe"], signal: AbortSignal, syncTimeoutMs = INITIAL_SYNC_TIMEOUT, ackTimeoutMs = STATE_ACK_TIMEOUT, ): Promise> { const stateSync = new Promise>( (resolve, reject) => { let unsubscribeStateSync = () => {}; let unsubscribeNoIota = () => {}; const cleanup = () => { clearTimeout(timeout); unsubscribeStateSync(); unsubscribeNoIota(); signal.removeEventListener("abort", onAbort); }; const onAbort = () => { cleanup(); reject(abortError(signal)); }; const timeout = setTimeout(() => { cleanup(); reject(new Error("Initial state synchronization timed out")); }, syncTimeoutMs); signal.addEventListener("abort", onAbort, { once: true }); if (signal.aborted) { onAbort(); return; } unsubscribeStateSync = subscribe("ClientStateSync", (message) => { cleanup(); resolve(message); }); unsubscribeNoIota = subscribe("ErrorNoIota", () => { cleanup(); reject(new Error("No Iota is currently connected")); }); }, ); const [, state] = await Promise.all([ withDeadline( client.auth(), syncTimeoutMs, "MTP authentication timed out", signal, ), stateSync, ]); await withDeadline( client.request("ClientStateAck", { SessionId: state.data.SessionId, VersionNumber: state.data.VersionNumber, }), ackTimeoutMs, "State acknowledgement timed out", signal, ); if (signal.aborted) throw abortError(signal); return state; } function protocolErrorDetails(error: unknown) { if (typeof error !== "object" || error === null || !("type" in error)) { return null; } const protocolError = error as { id?: unknown; type?: unknown; frame?: unknown; }; return { id: protocolError.id, type: protocolError.type, frame: protocolError.frame, }; } export function BrowserProvider(props: { children: ReactNode; blockConnection?: boolean; }) { const { load } = useStorage(); const [readyState, setReadyState] = useState( ConnectionState.Disconnected, ); const [identified, setIdentified] = useState(false); const [identifying, setIdentifying] = useState(false); const [freshCommunities, setFreshCommunities] = useState([]); const [freshContacts, setFreshContacts] = useState([]); const [freshCalls, setFreshCalls] = useState([]); const clientRef = useRef(null); const { addInterceptor, attachSubscriptions, interceptorsRef, subscribe } = useMessageHandlers(); const connected = readyState === ConnectionState.Connected; const [mtpUrl, setMtpUrl] = useState(null); useEffect(() => { load("omega_url").then(setMtpUrl); }, [load]); const send: BoundSendFn = useMemo( () => async (type, data, options) => { const client = clientRef.current; if (!client) throw new Error("mtp is not connected"); const response = await client.request(type, data, options); if (response.type === "GetStates") { setFreshContacts((contacts) => removeMissingContacts( contacts, response as ProtocolMessage<"GetStates">, ), ); } return response; }, [], ); const sendSealedRelay: SealedRelaySend = useMemo( () => async (type, data, options) => { const client = clientRef.current; if (!client) throw new Error("mtp is not connected"); await client.sendSealedRelay(type, data, { nextHopId: options.nextHop.id, finalRecipientId: options.finalRecipientId, metadataRecipients: options.metadataRecipients, contentRecipients: options.contentRecipients, }); return { type: "Success", data: {} }; }, [], ); const resolveConnectionRef = useRef(() => {}); useEffect(() => { if (!mtpUrl) return; let attempts = 0; let reconnectTimer: ReturnType | null = null; let reconnectResetTimer: ReturnType | null = null; let reconnectScheduled = false; let disposed = false; let connectionGeneration = 0; let cleanupConnection = () => {}; const clearReconnectTimer = () => { if (!reconnectTimer) return; clearTimeout(reconnectTimer); reconnectTimer = null; reconnectScheduled = false; }; const clearReconnectResetTimer = () => { if (!reconnectResetTimer) return; clearTimeout(reconnectResetTimer); reconnectResetTimer = null; }; const scheduleReconnect = (error: unknown) => { if (disposed || reconnectScheduled) return; attempts += 1; const shortRetry = attempts <= RECONNECT_TRIES; if (!shortRetry) { log(0, "mtp", "red", "Reconnection attempts exhausted", error); sonnerToast.error("Connection failed", { id: "mtp-connection-toast", description: error instanceof Error ? `${error.message.split(":")[0]}. Retrying in the background.` : "Connection lost. Retrying in the background.", icon: null, duration: Infinity, closeButton: true, promise: null, } as unknown as Parameters[1]); } else { sonnerToast.loading( `Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`, { id: "mtp-connection-toast" }, ); } const baseDelay = shortRetry ? RETRY_INTERVAL : RECONNECT_LONG_INTERVAL; const jitter = 1 + (Math.random() * 2 - 1) * RECONNECT_JITTER; reconnectScheduled = true; reconnectTimer = setTimeout( () => { reconnectScheduled = false; reconnectTimer = null; void connect(); }, Math.round(baseDelay * jitter), ); }; async function connect() { if (disposed || props.blockConnection) return; const generation = ++connectionGeneration; let client: BrowserMtpClient | null = null; let failed = false; let connectionReady = false; let detachSubscriptions = () => {}; let unsubscribeNoIota = () => {}; const attemptAbort = new AbortController(); const cleanup = () => { attemptAbort.abort( new Error("Initial state synchronization was cancelled"), ); unsubscribeNoIota(); detachSubscriptions(); client?.disconnect(); if (clientRef.current === client) clientRef.current = null; clearReconnectResetTimer(); if (generation === connectionGeneration) { setReadyState(ConnectionState.Disconnected); setIdentified(false); setIdentifying(false); } }; cleanupConnection = cleanup; try { setIdentified(false); setIdentifying(false); const [userId, keyring] = await Promise.all([ load("user_id"), load("mtp_keyring"), ]); if (!userId || !keyring) throw new Error("Missing login credentials"); const forcedOmikronUrl = await load("forced_omikron_url"); const forcedOmikronPublicKey = await load("forced_omikron_public_key"); let url = null; let omikronPublicKey = null; if (forcedOmikronUrl && forcedOmikronPublicKey) { url = forcedOmikronUrl; omikronPublicKey = forcedOmikronPublicKey; } else { log(2, "mtp", "purple", "Fetching Omikron data."); const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`, { signal: AbortSignal.any([ attemptAbort.signal, AbortSignal.timeout(DISCOVERY_TIMEOUT), ]), }); if (data.status === 404) { throw new Error("No Omikron assignment is currently available"); } if (!data.ok) throw new Error(`Omikron discovery failed: HTTP ${data.status}`); const omikronData = (await data.json()) as { ip_address: string; port: number; public_key: string; }; if ( !omikronData.ip_address || !omikronData.port || !omikronData.public_key ) { throw new Error("Invalid Omikron data"); } url = `https://${omikronData.ip_address}:${omikronData.port}`; omikronPublicKey = omikronData.public_key; } if (!url || !omikronPublicKey) { throw new Error("Missing Omikron URL or Public Key"); } log(2, "mtp", "green", "Connecting to: " + url); client = await createBrowserClient({ url, credentials: { clientId: userId, keyring: base64ToBytes(keyring) }, hostPublicKey: { value: omikronPublicKey, encoding: "base64" }, descriptor: "client", pings: true, securityProfile: { protectedSignatureSuite: "dual" }, logger: (event) => { if (event.type === "state") { if (generation !== connectionGeneration) return; const state = client?.state ?? ConnectionState.Disconnected; setReadyState(state); if ( state === ConnectionState.Disconnected && clientRef.current === client && !failed ) { failed = true; const error = new Error("MTP connection lost"); attemptAbort.abort(error); if (connectionReady) { cleanup(); scheduleReconnect(error); } } } if (event.type !== "Pong" && event.type !== "Ping") { log( 2, "mtp", event.type === "state" ? "purple" : event.direction === "recv" ? "cyan" : event.direction === "send" ? "gray" : "blue", event.type === "state" ? event.data : event.direction === "recv" ? "< " + event.type : event.direction === "send" ? "> " + event.type : event.type, event, ); } }, }); if (disposed || generation !== connectionGeneration) { client.disconnect(); return; } const activeClient = client; clientRef.current = activeClient; detachSubscriptions = attachSubscriptions(activeClient); unsubscribeNoIota = subscribe("ErrorNoIota", () => { if (clientRef.current !== activeClient || failed) return; failed = true; const error = new Error("No Iota is currently connected"); attemptAbort.abort(error); cleanup(); scheduleReconnect(error); }); setReadyState(activeClient.state); setIdentifying(true); const finalResponse = await completeInitialSynchronization( activeClient, subscribe, attemptAbort.signal, ); if (disposed || clientRef.current !== activeClient) return; setFreshContacts(finalResponse.data.Contacts); setFreshCommunities(finalResponse.data.Communities); setFreshCalls(finalResponse.data.Calls); connectionReady = true; setIdentifying(false); setIdentified(true); clearReconnectTimer(); clearReconnectResetTimer(); reconnectResetTimer = setTimeout(() => { attempts = 0; reconnectResetTimer = null; }, RECONNECT_RESET * 1_000); resolveConnectionRef.current?.(); } catch (connectError) { if (disposed || generation !== connectionGeneration) { client?.disconnect(); return; } failed = true; cleanup(); const message = connectError instanceof Error ? connectError.message : String(connectError ?? "Unknown error"); log( 0, "mtp", "red", `Connection/authentication attempt failed: ${message}`, protocolErrorDetails(connectError) ?? connectError, ); scheduleReconnect(connectError); } } void connect(); return () => { disposed = true; clearReconnectTimer(); clearReconnectResetTimer(); cleanupConnection(); setReadyState(ConnectionState.Disconnected); setIdentified(false); setIdentifying(false); sonnerToast.dismiss("mtp-connection-toast"); }; }, [attachSubscriptions, load, mtpUrl, props.blockConnection, subscribe]); useEffect(() => { return subscribe("ErrorNoIota", () => { setIdentified(false); setIdentifying(false); sonnerToast.error("We couldn't reach your Iota", { description: "Check your network connection and try restarting your Iota", icon: null, duration: Infinity, closeButton: true, }); resolveConnectionRef.current?.(); }); }, [subscribe]); useEffect( () => subscribe("GetStates", (message) => { setFreshContacts((contacts) => removeMissingContacts(contacts, message), ); }), [subscribe], ); const loadingDescription = useMemo(() => { if (!mtpUrl) return "Loading connection details"; if (readyState === ConnectionState.Connecting || !connected) { return "Establishing transport channel"; } if (identifying || !identified) return "Waiting for authenticated session"; return "Loading..."; }, [connected, identified, identifying, readyState, mtpUrl]); const contextReady = connected && identified && mtpUrl !== null; const mtpRef = useMemo(() => createAsyncQueue<{ send: typeof send }>(), []); useEffect(() => { if (connected && identified && mtpUrl) { mtpRef.set({ send }); } }, [connected, identified, mtpUrl, send, mtpRef]); const sendQueued: BoundSendFn = useMemo( () => async (type, data, options) => { const mtp = await mtpRef.get(); const response = await mtp.send(type, data, options); for (const interceptor of interceptorsRef.current) { void Promise.resolve(interceptor({ type, data, response })).catch( (error) => { log(1, "mtp", "yellow", "MTP interceptor failed", error, { type }); }, ); } return response; }, [interceptorsRef, mtpRef], ); return ( {props.children} ); }