import { useState, createContext, type ReactNode, useRef, useEffect, useContext, useCallback, useMemo, } from "react"; import { useCrypto } from "@tensamin/crypto/context"; import { log } from "@tensamin/shared/log"; import { useStorage } from "@tensamin/storage/context"; import { createTransportClient, READY_STATE, type BoundSendFn } from "./core"; import { PING_INTERVAL, RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL, TRANSPORT_URL, } from "./values"; import { socket as schemas, type Socket as Schemas, } from "@tensamin/shared/data"; import Loading from "@tensamin/ui/screens/loading"; import ErrorScreen from "@tensamin/ui/screens/error"; const FATAL_IDENTIFICATION_ERROR_TYPES = new Set([ "error", "error_invalid_user_id", "error_no_user_id", "error_invalid_challenge", "error_invalid_secret", "error_invalid_private_key", "error_invalid_public_key", "error_not_authenticated", ]); /** * Detects whether an error chain contains a STOP_SENDING transport signal. * @param error Unknown error value from transport operations. * @returns True when the error represents a STOP_SENDING condition. */ function isStopSendingError(error: unknown) { if (typeof error === "string") { return error.includes("STOP_SENDING"); } if (error instanceof Error) { if (error.message.includes("STOP_SENDING")) { return true; } const errorWithCause = error as Error & { cause?: unknown }; if (errorWithCause.cause !== undefined) { return isStopSendingError(errorWithCause.cause); } return false; } if (typeof error === "object" && error !== null) { const maybeMessage = (error as { message?: unknown }).message; if (typeof maybeMessage === "string") { return maybeMessage.includes("STOP_SENDING"); } } return false; } /** * Classifies identification errors that should be treated as terminal. * @param error Unknown error raised during identification. * @returns True when identification should fail without retry. */ function isFatalIdentificationError(error: unknown) { if (typeof error === "object" && error !== null && "type" in error) { const type = (error as { type?: unknown }).type; if ( typeof type === "string" && FATAL_IDENTIFICATION_ERROR_TYPES.has(type) ) { return true; } } if (!(error instanceof Error)) { return false; } if ( error.message.includes("Missing or invalid user id") || error.message.includes("Missing private key") || error.message.includes("Identification challenge was rejected") || error.message.includes("timed out after") || error.message.includes("Response validation failed") ) { return true; } return false; } /** * Extracts structured protocol error details for identification logging. * @param error Unknown error raised during identification. * @returns Structured protocol error details when available. */ function getProtocolErrorDetails(error: unknown) { if (typeof error !== "object" || error === null) { return null; } if (!("type" in error)) { return null; } const protocolError = error as { id?: unknown; type?: unknown; data?: unknown; }; return { id: protocolError.id, type: protocolError.type, data: protocolError.data, }; } type ContextType = { send: BoundSendFn; readyState: () => number; ownPing: () => number; iotaPing: () => number; identified: () => boolean; }; const socketContext = createContext(undefined); /** * Provides socket transport state and authenticated send operations to children. * @param props Component props with children. * @returns Loading, error, or provider-wrapped JSX. */ export default function Provider(props: { children: ReactNode; blockConnection?: boolean; }) { const { load } = useStorage(); const { decrypt, getSharedSecret } = useCrypto(); const [readyState, setReadyState] = useState(READY_STATE.CLOSED); const [connected, setConnected] = useState(false); const [identified, setIdentified] = useState(false); const [identifying, setIdentifying] = useState(false); const [ownPing, setOwnPing] = useState(0); const [iotaPing, setIotaPing] = useState(0); const [error, setError] = useState(""); const [errorDescription, setErrorDescription] = useState(""); const clientRef = useRef > | null>(null); const identificationStartedRef = useRef(false); /** * Sends typed protocol messages through the active transport client. * @param type Protocol message type. * @param data Optional request payload. * @param options Optional request id and response mode. * @returns A promise for either void (no response) or typed message payload. */ const send = useCallback>( (( type: string, data?: Record, options?: { id?: number; noResponse?: boolean }, ) => { const client = clientRef.current; if (!client) { return Promise.reject(new Error("Socket is not connected")); } if (options?.noResponse) { return client.send(type as keyof Schemas & string, data as never, { ...options, noResponse: true, }); } return client.send(type as keyof Schemas & string, data as never, { ...options, noResponse: false, }); }) as BoundSendFn, [], ); useEffect(() => { if (!connected || !identified) { return; } const interval = setInterval(async () => { try { const originalNow = Date.now(); const data = await send("ping", { last_ping: originalNow, }); const travelTime = Date.now() - originalNow; setOwnPing(travelTime); const remotePing = data.data.ping_iota; if (typeof remotePing === "number") { setIotaPing(remotePing); } } catch (intervalError) { log(1, "Socket", "yellow", "Ping failed", intervalError); } }, PING_INTERVAL); return () => { clearInterval(interval); }; }, [connected, identified, send]); useEffect(() => { let attempts = 0; let reconnectTimer: ReturnType | null = null; let reconnectResetTimer: ReturnType | null = null; let reconnectScheduled = false; let disposed = false; /** * Clears any scheduled reconnect timeout and resets scheduling flags. * @returns Void. */ const clearReconnectTimer = () => { if (!reconnectTimer) { return; } clearTimeout(reconnectTimer); reconnectTimer = null; reconnectScheduled = false; }; /** * Clears the stability timer that resets reconnect attempt counters. * @returns Void. */ const clearReconnectResetTimer = () => { if (!reconnectResetTimer) { return; } clearTimeout(reconnectResetTimer); reconnectResetTimer = null; }; /** * Starts the stability timer that resets reconnect attempts after uptime. * @returns Void. */ const scheduleReconnectReset = () => { clearReconnectResetTimer(); reconnectResetTimer = setTimeout(() => { attempts = 0; reconnectResetTimer = null; }, RECONNECT_RESET * 1_000); }; /** * Schedules a delayed reconnect attempt unless retries are exhausted. * @param reason Optional reason for reconnect scheduling. * @returns Void. */ const scheduleReconnect = (reason?: unknown) => { if (disposed || reconnectScheduled) { return; } if (attempts >= RECONNECT_TRIES) { setError("Connection Failed"); setErrorDescription( "Unable to connect to the server after multiple attempts. Please check your internet connection or try again later.", ); log(0, "Socket", "red", "Reconnection attempts exhausted", reason); return; } attempts += 1; reconnectScheduled = true; reconnectTimer = setTimeout(() => { reconnectScheduled = false; reconnectTimer = null; void connect(); }, RETRY_INTERVAL); }; const transportClient = !props.blockConnection ? createTransportClient(schemas, { url: TRANSPORT_URL, onReadyStateChange: (state) => { setReadyState(state); if (state === READY_STATE.OPEN) { clearReconnectTimer(); scheduleReconnectReset(); identificationStartedRef.current = false; setConnected(true); setIdentified(false); setError(""); setErrorDescription(""); return; } clearReconnectResetTimer(); identificationStartedRef.current = false; setConnected(false); setIdentified(false); }, onClose: ({ error: closeError, intentional }) => { clearReconnectResetTimer(); setConnected(false); setIdentified(false); setIdentifying(false); if (disposed || intentional) { return; } log(0, "Socket", "red", "Disconnected", closeError, { stopSending: isStopSendingError(closeError), }); scheduleReconnect(closeError); }, }) : null; clientRef.current = transportClient; /** * Establishes the transport connection and schedules reconnect on failures. * @returns Promise that resolves after one connection attempt. */ async function connect() { if (disposed) { return; } try { await transportClient?.connect(TRANSPORT_URL); } catch (connectError) { if (disposed) { return; } log(0, "Socket", "red", "Connection attempt failed", connectError); scheduleReconnect(connectError); } } void connect(); return () => { disposed = true; clearReconnectTimer(); clearReconnectResetTimer(); if (clientRef.current === transportClient) { clientRef.current = null; } void transportClient?.close("context-dispose"); setReadyState(READY_STATE.CLOSED); setConnected(false); setIdentified(false); setIdentifying(false); identificationStartedRef.current = false; }; }, [props.blockConnection]); useEffect(() => { if (!connected) { setIdentifying(false); setIdentified(false); identificationStartedRef.current = false; return; } if (identificationStartedRef.current) { return; } identificationStartedRef.current = true; let cancelled = false; setIdentifying(true); setIdentified(false); /** * Executes the challenge-response identification handshake. * @returns Promise that resolves when identification flow completes. */ const identify = async () => { try { const userId = await load("user_id"); const privateKey = await load("private_key"); if (!Number.isSafeInteger(userId) || userId <= 0) { throw new Error("Missing or invalid user id for identification"); } if (privateKey.trim() === "") { throw new Error("Missing private key for identification"); } const challengeEnvelope = await send("identification", { user_id: userId, }); const sharedSecret = await getSharedSecret( privateKey, "", challengeEnvelope.data.public_key, ); const decryptedChallenge = await decrypt( sharedSecret, challengeEnvelope.data.challenge, ); const verification = await send("challenge_response", { challenge: decryptedChallenge, }); if (verification.data.accepted !== true) { throw new Error("Identification challenge was rejected"); } if (cancelled) { return; } setError(""); setErrorDescription(""); setIdentified(true); } catch (identificationError) { if (cancelled) { return; } if (isStopSendingError(identificationError)) { setError("Connection closed"); setErrorDescription( "The connection was forcefully closed by the Omikron.", ); setIdentified(false); return; } const isFatal = isFatalIdentificationError(identificationError); const protocolErrorDetails = getProtocolErrorDetails(identificationError); log( isFatal ? 0 : 1, "Socket", isFatal ? "red" : "yellow", "Identification handshake failed", protocolErrorDetails ?? identificationError, ); setIdentified(false); setError("Identification Failed"); setErrorDescription( isFatal ? "Unable to complete secure identification. Please verify your credentials and try again." : "Unable to complete secure identification because the transport request failed.", ); } finally { if (!cancelled) { setIdentifying(false); } } }; void identify(); return () => { cancelled = true; }; }, [connected, decrypt, getSharedSecret, load, send]); const progress = useMemo(() => { if (readyState === READY_STATE.CONNECTING) return 30; if (!connected) return 45; if (identifying) return 75; if (!identified) return 90; return 100; }, [connected, identified, identifying, readyState]); const loadingTitle = useMemo(() => { if (readyState === READY_STATE.CONNECTING || !connected) { return "Connecting to Tensamin"; } if (identifying || !identified) { return "Identifying secure session"; } return "Loading"; }, [connected, identified, identifying, readyState]); const loadingDescription = useMemo(() => { if (readyState === READY_STATE.CONNECTING || !connected) { return "Establishing transport channel"; } if (identifying || !identified) { return "Verifying challenge-response handshake"; } return undefined; }, [connected, identified, identifying, readyState]); const contextValue = useMemo( () => ({ send, readyState: () => readyState, ownPing: () => ownPing, iotaPing: () => iotaPing, identified: () => identified, }), [identified, iotaPing, ownPing, readyState, send], ); if (error !== "" && errorDescription !== "") { return ; } if (!connected || !identified) { return ( ); } return ( {props.children} ); } /** * Returns the active socket context and enforces provider usage. * @returns Socket context API for transport operations and connection state. */ export function useSocket(): ContextType { const context = useContext(socketContext); if (!context) { throw new Error("useSocket must be used within a SocketProvider"); } return context; }