(wip): migrate ttp to mtp
This commit is contained in:
parent
b1e8af7ec3
commit
20018f09a9
163 changed files with 8342 additions and 2609 deletions
|
|
@ -1,734 +0,0 @@
|
|||
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<Schemas>;
|
||||
subscribePush: (handler: PushHandler) => () => void;
|
||||
readyState: number;
|
||||
ownPing: number;
|
||||
iotaPing: number;
|
||||
identified: boolean;
|
||||
freshContacts: Contacts;
|
||||
freshCommunities: Communities;
|
||||
freshCalls: Calls;
|
||||
};
|
||||
|
||||
const TTPContext = createContext<ContextType | undefined>(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<number>(READY_STATE.CLOSED);
|
||||
const [connected, setConnected] = useState<boolean>(false);
|
||||
const [identified, setIdentified] = useState<boolean>(false);
|
||||
const [identifying, setIdentifying] = useState<boolean>(false);
|
||||
|
||||
const [ownPing, setOwnPing] = useState<number>(0);
|
||||
const [iotaPing, setIotaPing] = useState<number>(0);
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [errorDescription, setErrorDescription] = useState("");
|
||||
|
||||
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
|
||||
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
|
||||
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
||||
|
||||
const clientRef = useRef<ReturnType<
|
||||
typeof createTransportClient<Schemas>
|
||||
> | null>(null);
|
||||
const identificationStartedRef = useRef(false);
|
||||
const identificationCancelRef = useRef(false);
|
||||
|
||||
// Load ttp url
|
||||
const [ttpUrl, setTtpUrl] = useState<string | null>(null);
|
||||
const [ttpServerCert, setTtpServerCert] = useState<string>("");
|
||||
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<Schemas> = useMemo(
|
||||
() =>
|
||||
((
|
||||
type: string,
|
||||
data?: Record<string, unknown>,
|
||||
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<Schemas>,
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 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<typeof setTimeout> | null = null;
|
||||
let reconnectResetTimer: ReturnType<typeof setTimeout> | 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 <ErrorScreen error={error} description={errorDescription} />;
|
||||
}
|
||||
|
||||
if (!connected || !identified || !ttpUrl) {
|
||||
return (
|
||||
<Loading
|
||||
progress={progress}
|
||||
title={loadingTitle}
|
||||
description={loadingDescription}
|
||||
fullscreen
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TTPContext.Provider
|
||||
value={{
|
||||
send,
|
||||
subscribePush,
|
||||
readyState,
|
||||
ownPing,
|
||||
iotaPing,
|
||||
identified,
|
||||
freshContacts,
|
||||
freshCommunities,
|
||||
freshCalls,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</TTPContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Loading…
Reference in a new issue