client/packages/mtp/src/browser.tsx
Alois 042141c781
Some checks failed
/ build-web (push) Successful in 4m1s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
feat(user): rename to identity
feat(identity): add getIota function with caching
2026-08-30 19:26:42 +02:00

550 lines
17 KiB
TypeScript

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<ReturnType<typeof createBrowserClient>>;
function createBrowserClient(
options: Omit<Parameters<typeof MTPClient.create>[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<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutMessage: string,
signal: AbortSignal,
): Promise<T> {
return new Promise<T>((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<ProtocolMessage<"ClientStateSync">> {
const stateSync = new Promise<ProtocolMessage<"ClientStateSync">>(
(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<number>(
ConnectionState.Disconnected,
);
const [identified, setIdentified] = useState(false);
const [identifying, setIdentifying] = useState(false);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const clientRef = useRef<BrowserMtpClient | null>(null);
const { addInterceptor, attachSubscriptions, interceptorsRef, subscribe } =
useMessageHandlers();
const connected = readyState === ConnectionState.Connected;
const [mtpUrl, setMtpUrl] = useState<string | null>(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<typeof setTimeout> | null = null;
let reconnectResetTimer: ReturnType<typeof setTimeout> | 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<typeof sonnerToast.error>[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 (
<MTPContext.Provider
value={{
send: sendQueued,
sendSealedRelay,
subscribe,
addInterceptor,
readyState,
identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady,
loadingDescription,
}}
>
{props.children}
</MTPContext.Provider>
);
}