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 "@tensamin/ttp-core"; import { isTauri } from "@tauri-apps/api/core"; import { onResume } from "tauri-plugin-app-events-api"; import type { PushHandler } from "@tensamin/ttp-core"; import { PING_INTERVAL, RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL, } from "./values"; import { type Calls, type Communities, type Contacts, ttp as schemas, type TTP as Schemas, } from "@tensamin/shared/data"; import { LoadingScreen as Loading } from "@tensamin/ui"; import { ErrorScreen } from "@tensamin/ui"; import { version } from "../../../package.json"; import { decryptText } from "@tensamin/crypto/worker"; 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, }; } function isTauriMobile() { return isTauri() && /Android|iPhone|iPad|iPod/.test(navigator.userAgent); } type ContextType = { send: BoundSendFn; subscribePush: (handler: PushHandler) => () => void; readyState: number; ownPing: number; iotaPing: number; identified: boolean; freshContacts: Contacts; freshCommunities: Communities; freshCalls: Calls; }; const TTPContext = createContext(undefined); /** * Provides ttp transport state and authenticated send operations to children. * @param props Component props with children. * @returns Loading, error, or provider-wrapped JSX. */ export 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 [freshCommunities, setFreshCommunities] = useState([]); const [freshContacts, setFreshContacts] = useState([]); const [freshCalls, setFreshCalls] = useState([]); const clientRef = useRef > | null>(null); const identificationStartedRef = useRef(false); const identificationCancelRef = useRef(false); // Load ttp url const [ttpUrl, setTtpUrl] = useState(null); const [ttpServerCert, setTtpServerCert] = useState(""); useEffect(() => { load("ttp_url").then((url) => { setTtpUrl(url); }); load("ttp_server_cert").then((cert) => { setTtpServerCert(cert.trim()); }); }, [load]); /** * Sends typed protocol messages through the active transport client. * @param type Protocol message type. * @param data Optional request payload. * @param options Optional request id. * @returns A promise for the typed message payload. */ const send: BoundSendFn = useMemo( () => (( type: string, data?: Record, options?: { id?: number }, ) => { const client = clientRef.current; if (!client) { return Promise.reject(new Error("ttp is not connected")); } return client.send(type as keyof Schemas & string, data as never, { ...options, }); }) as BoundSendFn, [], ); /** * Subscribes to unsolicited push events from the active transport client. * @param handler Callback invoked for each push message. * @returns Unsubscribe function. */ const subscribePush = useCallback((handler: PushHandler) => { const client = clientRef.current; if (!client) { return () => {}; } return client.subscribePush(handler); }, []); // Check for error_no_iota useEffect(() => { if (!connected) return; return subscribePush((message) => { if (message.type !== "error_no_iota") return; identificationCancelRef.current = true; setIdentified(false); setIdentifying(false); setError("We couldn't reach your Iota"); setErrorDescription( "You could try to restart your Iota, check for updates or check your network connection.", ); }); }, [connected, subscribePush]); 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, "ttp", "yellow", "Ping failed", intervalError); } }, PING_INTERVAL); return () => { clearInterval(interval); }; }, [connected, identified, send]); useEffect(() => { if (!ttpUrl) { return; } let attempts = 0; let reconnectTimer: ReturnType | null = null; let reconnectResetTimer: ReturnType | null = null; let reconnectScheduled = false; let disposed = false; let resumeListenerRegistered = 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, "ttp", "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: ttpUrl, serverCert: ttpServerCert || undefined, onReadyStateChange: (state) => { if (state === READY_STATE.CONNECTING) { log(2, "ttp", "blue", ttpUrl); } setReadyState(state); if (state === READY_STATE.OPEN) { clearReconnectTimer(); scheduleReconnectReset(); identificationStartedRef.current = false; identificationCancelRef.current = false; setConnected(true); setIdentified(false); setError(""); setErrorDescription(""); return; } clearReconnectResetTimer(); identificationStartedRef.current = false; setConnected(false); setIdentified(false); setIdentifying(false); }, onClose: ({ error: closeError, intentional }) => { clearReconnectResetTimer(); setConnected(false); setIdentified(false); setIdentifying(false); if (disposed || intentional) { return; } log(0, "ttp", "red", "Disconnected", 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; } if (!ttpUrl) { return; } try { await transportClient?.connect(ttpUrl); } catch (connectError) { if (disposed) { return; } log(0, "ttp", "red", "Connection attempt failed", connectError); scheduleReconnect(connectError); } } /** * Starts a reconnect after resume only when the transport is disconnected. * @returns Promise that resolves after any needed resume reconnect starts. */ async function reconnectAfterResume() { if (disposed || !transportClient) { return; } clearReconnectTimer(); clearReconnectResetTimer(); attempts = 0; reconnectScheduled = false; setError(""); setErrorDescription(""); await connect(); } void connect(); if (!props.blockConnection && isTauriMobile()) { resumeListenerRegistered = true; onResume(() => { void reconnectAfterResume(); }); } return () => { disposed = true; clearReconnectTimer(); clearReconnectResetTimer(); if (resumeListenerRegistered) { onResume(); } 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, ttpServerCert, ttpUrl]); useEffect(() => { if (!connected) { identificationStartedRef.current = false; return; } if (identificationCancelRef.current) { 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 sessionId = await load("session_id"); const userId = await load("user_id"); const privateKey = await load("private_key"); if ( !Number.isSafeInteger(userId) || userId <= 0 || privateKey.trim() === "" || !Number.isSafeInteger(sessionId) || sessionId <= 0 ) { throw new Error("Invalid credentials"); } // Challenge Request const challengeEnvelope = await send("identification", { version, session_id: sessionId, user_id: userId, }).catch((challengeError) => { throw new Error( "Failed to obtain identification challenge from server", challengeError, ); }); // Shared Secret const sharedSecret = await getSharedSecret( privateKey, "", challengeEnvelope.data.public_key, ).catch((secretError) => { throw new Error( "Failed to derive shared secret for identification", secretError, ); }); // Challenge Decryption const decryptedChallenge = await decryptText( sharedSecret, challengeEnvelope.data.challenge, ).catch((decryptionError) => { throw new Error( "Failed to decrypt identification challenge: " + String(decryptionError), ); }); // Challenge Response const finalResponse = await send("challenge_response", { challenge: decryptedChallenge, }).catch((error) => { if (!identificationCancelRef.current) { setError("Identification Failed"); setErrorDescription( "Unable to complete secure identification. Please verify your credentials and try again.", ); } throw error; }); // Data handling setFreshContacts(finalResponse.data.contacts); setFreshCommunities(finalResponse.data.communities); setFreshCalls(finalResponse.data.calls); if (cancelled || identificationCancelRef.current) { return; } setError(""); setErrorDescription(""); setIdentified(true); } catch (identificationError) { if (cancelled) { return; } if (identificationCancelRef.current) { 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, "ttp", 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 && !identificationCancelRef.current) { setIdentifying(false); } } }; void identify(); return () => { cancelled = true; }; }, [connected, decrypt, getSharedSecret, load, send]); const progress = useMemo(() => { if (!ttpUrl) return 10; 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, ttpUrl]); const loadingTitle = useMemo(() => { if (!ttpUrl) { return "Looking up configuration"; } if (readyState === READY_STATE.CONNECTING || !connected) { return "Connecting to Tensamin"; } if (identifying || !identified) { return "Identifying secure session"; } return "Loading"; }, [connected, identified, identifying, readyState, ttpUrl]); const loadingDescription = useMemo(() => { if (!ttpUrl) { return "Loading connection details"; } if (readyState === READY_STATE.CONNECTING || !connected) { return "Establishing transport channel"; } if (identifying || !identified) { return "Verifying challenge-response handshake"; } return undefined; }, [connected, identified, identifying, readyState, ttpUrl]); if (error !== "" && errorDescription !== "") { return ; } if (!connected || !identified || !ttpUrl) { return ( ); } return ( {props.children} ); } /** * Returns the active ttp context and enforces provider usage. * @returns ttp context API for transport operations and connection state. */ export function useTTP(): ContextType { const context = useContext(TTPContext); if (!context) { throw new Error("useTTP must be used within a TTPProvider"); } return context; }