Updated a lot of stuff
This commit is contained in:
parent
4ee57ab459
commit
c0102df54f
44 changed files with 1610 additions and 707 deletions
|
|
@ -1,5 +1,7 @@
|
|||
import * as React 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,
|
||||
|
|
@ -14,18 +16,90 @@ import {
|
|||
import Loading from "@tensamin/ui/screens/loading";
|
||||
import ErrorScreen from "@tensamin/ui/screens/error";
|
||||
|
||||
const FATAL_IDENTIFICATION_ERROR_TYPES = new Set([
|
||||
"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",
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
type ContextType = {
|
||||
send: BoundSendFn<Schemas>;
|
||||
readyState: () => number;
|
||||
ownPing: () => number;
|
||||
iotaPing: () => number;
|
||||
identified: () => boolean;
|
||||
};
|
||||
|
||||
const socketContext = React.createContext<ContextType | undefined>(undefined);
|
||||
|
||||
export default function Provider(props: { children: React.ReactNode }) {
|
||||
const [readyState, setReadyState] = React.useState<number>(READY_STATE.CLOSED);
|
||||
const { load } = useStorage();
|
||||
const { decrypt, get_shared_secret } = useCrypto();
|
||||
|
||||
const [readyState, setReadyState] = React.useState<number>(
|
||||
READY_STATE.CLOSED,
|
||||
);
|
||||
const [connected, setConnected] = React.useState<boolean>(false);
|
||||
const [identified, setIdentified] = React.useState<boolean>(false);
|
||||
const [identifying, setIdentifying] = React.useState<boolean>(false);
|
||||
|
||||
const [ownPing, setOwnPing] = React.useState<number>(0);
|
||||
const [iotaPing, setIotaPing] = React.useState<number>(0);
|
||||
|
|
@ -36,6 +110,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
const clientRef = React.useRef<ReturnType<
|
||||
typeof createTransportClient<Schemas>
|
||||
> | null>(null);
|
||||
const identificationStartedRef = React.useRef(false);
|
||||
|
||||
const send = React.useCallback<BoundSendFn<Schemas>>(
|
||||
((
|
||||
|
|
@ -65,7 +140,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!connected) {
|
||||
if (!connected || !identified) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +167,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [connected, send]);
|
||||
}, [connected, identified, send]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let attempts = 0;
|
||||
|
|
@ -141,17 +216,34 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
if (state === READY_STATE.OPEN) {
|
||||
attempts = 0;
|
||||
clearReconnectTimer();
|
||||
identificationStartedRef.current = false;
|
||||
setConnected(true);
|
||||
setIdentified(false);
|
||||
setError("");
|
||||
setErrorDescription("");
|
||||
log(1, "Socket", "green", "Connected");
|
||||
return;
|
||||
}
|
||||
|
||||
identificationStartedRef.current = false;
|
||||
setConnected(false);
|
||||
setIdentified(false);
|
||||
},
|
||||
onClose: ({ error: closeError, intentional }) => {
|
||||
if (isStopSendingError(closeError)) {
|
||||
clearReconnectTimer();
|
||||
setConnected(false);
|
||||
setIdentified(false);
|
||||
setIdentifying(false);
|
||||
setError("Connection closed");
|
||||
setErrorDescription(
|
||||
"The connection was forcefully closed by the Omikron.",
|
||||
);
|
||||
log(0, "Socket", "red", "Connection closed", closeError);
|
||||
return;
|
||||
}
|
||||
|
||||
setConnected(false);
|
||||
},
|
||||
onClose: ({ error: closeError, intentional }) => {
|
||||
setConnected(false);
|
||||
setIdentified(false);
|
||||
|
||||
if (disposed || intentional) {
|
||||
return;
|
||||
|
|
@ -176,6 +268,13 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (isStopSendingError(connectError)) {
|
||||
clearReconnectTimer();
|
||||
setError("Connection closed");
|
||||
setErrorDescription("Connection closed");
|
||||
return;
|
||||
}
|
||||
|
||||
log(0, "Socket", "red", "Connection attempt failed", connectError);
|
||||
scheduleReconnect(connectError);
|
||||
}
|
||||
|
|
@ -194,14 +293,147 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
void transportClient.close("context-dispose");
|
||||
setReadyState(READY_STATE.CLOSED);
|
||||
setConnected(false);
|
||||
setIdentified(false);
|
||||
setIdentifying(false);
|
||||
identificationStartedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.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);
|
||||
|
||||
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 get_shared_secret(
|
||||
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("Connection closed");
|
||||
setIdentified(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isFatal = isFatalIdentificationError(identificationError);
|
||||
|
||||
log(
|
||||
isFatal ? 0 : 1,
|
||||
"Socket",
|
||||
isFatal ? "red" : "yellow",
|
||||
"Identification handshake failed",
|
||||
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, get_shared_secret, load, send]);
|
||||
|
||||
const progress = React.useMemo(() => {
|
||||
if (readyState === READY_STATE.CONNECTING) return 70;
|
||||
if (!connected) return 90;
|
||||
if (readyState === READY_STATE.CONNECTING) return 30;
|
||||
if (!connected) return 45;
|
||||
if (identifying) return 75;
|
||||
if (!identified) return 90;
|
||||
return 100;
|
||||
}, [connected, readyState]);
|
||||
}, [connected, identified, identifying, readyState]);
|
||||
|
||||
const loadingTitle = React.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 = React.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 = React.useMemo<ContextType>(
|
||||
() => ({
|
||||
|
|
@ -209,16 +441,24 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
readyState: () => readyState,
|
||||
ownPing: () => ownPing,
|
||||
iotaPing: () => iotaPing,
|
||||
identified: () => identified,
|
||||
}),
|
||||
[iotaPing, ownPing, readyState, send],
|
||||
[identified, iotaPing, ownPing, readyState, send],
|
||||
);
|
||||
|
||||
if (error !== "" && errorDescription !== "") {
|
||||
return <ErrorScreen error={error} description={errorDescription} />;
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
return <Loading progress={progress} />;
|
||||
if (!connected || !identified) {
|
||||
return (
|
||||
<Loading
|
||||
progress={progress}
|
||||
title={loadingTitle}
|
||||
description={loadingDescription}
|
||||
fullscreen
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -234,4 +474,4 @@ export function useSocket(): ContextType {
|
|||
throw new Error("useSocket must be used within a SocketProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,6 +269,7 @@ registerDataKinds("number", [
|
|||
"omikron_id",
|
||||
"send_time",
|
||||
"sub_level",
|
||||
"sub_end",
|
||||
]);
|
||||
|
||||
registerDataKinds("string", [
|
||||
|
|
@ -301,6 +302,7 @@ registerDataKinds("string", [
|
|||
"new_token",
|
||||
"call_token",
|
||||
"challenge",
|
||||
"online_status",
|
||||
]);
|
||||
|
||||
registerDataKinds({ array: "container" }, [
|
||||
|
|
@ -351,10 +353,8 @@ registerDataKinds("null", [
|
|||
"watcher",
|
||||
"created_at",
|
||||
"status",
|
||||
"sub_end",
|
||||
"community_address",
|
||||
"community_title",
|
||||
"online_status",
|
||||
"call_invited",
|
||||
"call_members",
|
||||
"calls",
|
||||
|
|
@ -476,9 +476,31 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
notifyClosed(connection, error);
|
||||
};
|
||||
|
||||
const closeFromStopSending = (
|
||||
connection: ActiveConnection,
|
||||
error: unknown,
|
||||
) => {
|
||||
if (!isStopSendingError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
connection.transport.close({
|
||||
closeCode: APPLICATION_CLOSE_CODE,
|
||||
reason: "stop-sending",
|
||||
});
|
||||
} catch {
|
||||
// Ignore close failures while handling STOP_SENDING.
|
||||
}
|
||||
|
||||
handleConnectionFailure(connection, error);
|
||||
};
|
||||
|
||||
const handleIncomingMessage = (message: TypedMessage) => {
|
||||
if (message.type !== "pong") {
|
||||
log(2, "Socket", "blue", message.type, message.data);
|
||||
log(2, "Socket", "blue", "Received:", message.type, message.data, {
|
||||
id: message.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (message.id !== 0) {
|
||||
|
|
@ -728,13 +750,20 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
payload = coercePayload(input ?? {});
|
||||
}
|
||||
|
||||
if (!options?.id && !options?.noResponse) {
|
||||
const array = new Uint32Array(1);
|
||||
crypto.getRandomValues(array);
|
||||
options = options ?? {};
|
||||
options.id = array[0];
|
||||
}
|
||||
|
||||
if (type !== "ping") {
|
||||
log(2, "Socket", "purple", type, payload);
|
||||
log(2, "Socket", "purple", "Sent:", type, payload, { id: options.id });
|
||||
}
|
||||
|
||||
const expectsResponse = !options?.noResponse;
|
||||
const requestId = resolveRequestId(
|
||||
options?.id,
|
||||
options.id,
|
||||
expectsResponse,
|
||||
pending,
|
||||
() => {
|
||||
|
|
@ -751,7 +780,10 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
});
|
||||
|
||||
if (!expectsResponse) {
|
||||
return writeMessage(connection.transport, messageBytes);
|
||||
return writeMessage(connection.transport, messageBytes).catch((error) => {
|
||||
closeFromStopSending(connection, error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise<TypedMessage>((resolve, reject) => {
|
||||
|
|
@ -772,6 +804,7 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
});
|
||||
|
||||
void writeMessage(connection.transport, messageBytes).catch((error) => {
|
||||
closeFromStopSending(connection, error);
|
||||
clearTimeout(timeoutId);
|
||||
pending.delete(requestId);
|
||||
reject(error);
|
||||
|
|
@ -829,6 +862,34 @@ function normalizeName(value: string) {
|
|||
return value.toLowerCase().replaceAll("_", "");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function coercePayload(value: unknown): Record<string, unknown> {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error("Protocol payload must be a plain object");
|
||||
|
|
@ -1175,7 +1236,9 @@ function encodeArrayPayload(
|
|||
|
||||
if (!isBoolKindMarker(encodedItem.kind)) {
|
||||
if (encodedItem.payload.byteLength > 0xffff) {
|
||||
throw new Error(`Array item at "${path}" is too large for protocol encoding`);
|
||||
throw new Error(
|
||||
`Array item at "${path}" is too large for protocol encoding`,
|
||||
);
|
||||
}
|
||||
|
||||
totalLength += 2 + encodedItem.payload.byteLength;
|
||||
|
|
@ -1222,10 +1285,13 @@ function encodeContainerPayload(value: Record<string, unknown>, path: string) {
|
|||
);
|
||||
|
||||
if (entries.length > 0xffff) {
|
||||
throw new Error(`Container at "${path}" has too many entries for protocol encoding`);
|
||||
throw new Error(
|
||||
`Container at "${path}" has too many entries for protocol encoding`,
|
||||
);
|
||||
}
|
||||
|
||||
const encodedEntries: Array<{ keyIndex: number; value: EncodedDataValue }> = [];
|
||||
const encodedEntries: Array<{ keyIndex: number; value: EncodedDataValue }> =
|
||||
[];
|
||||
let totalLength = 2;
|
||||
|
||||
for (const [name, entry] of entries) {
|
||||
|
|
@ -1242,7 +1308,9 @@ function encodeContainerPayload(value: Record<string, unknown>, path: string) {
|
|||
totalLength += 2;
|
||||
} else {
|
||||
if (encodedValue.payload.byteLength > 0xffff) {
|
||||
throw new Error(`Container entry "${pathForEntry}" is too large for protocol encoding`);
|
||||
throw new Error(
|
||||
`Container entry "${pathForEntry}" is too large for protocol encoding`,
|
||||
);
|
||||
}
|
||||
|
||||
totalLength += 4 + encodedValue.payload.byteLength;
|
||||
|
|
@ -1365,7 +1433,10 @@ function decodeContainerPayload(reader: ByteReader) {
|
|||
);
|
||||
}
|
||||
|
||||
value[key] = normalizeIncomingValue(key, decodeValuePayload(marker, payload));
|
||||
value[key] = normalizeIncomingValue(
|
||||
key,
|
||||
decodeValuePayload(marker, payload),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
|
|
@ -1382,7 +1453,8 @@ function getDataTypeNameByIndex(index: number) {
|
|||
|
||||
function isBoolKindMarker(marker: number) {
|
||||
return (
|
||||
marker === DATA_VALUE_KIND_BOOL_TRUE || marker === DATA_VALUE_KIND_BOOL_FALSE
|
||||
marker === DATA_VALUE_KIND_BOOL_TRUE ||
|
||||
marker === DATA_VALUE_KIND_BOOL_FALSE
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue