[Fix] Connections

This commit is contained in:
Alex Emmet 2026-08-30 18:26:32 +02:00
commit 74fb46990e
No known key found for this signature in database
14 changed files with 950 additions and 507 deletions

View file

@ -15,7 +15,7 @@ import { Button } from "@methanium/ui";
import { Plus, Laugh, FileVideo, SendHorizonal } from "lucide-react";
import { useChat, useReplyMessage } from "../context";
import { useMTP } from "@tensamin/mtp";
import { requireRelaySuccess, useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
import { cn, useIsMobile } from "@methanium/ui";
import { encryptChatText } from "@tensamin/crypto/chatSecret";
@ -39,7 +39,7 @@ export default function InputComponent({
}) {
const [invertEnterBehavior, setInvertEnterBehavior] = useState(false);
const { send } = useMTP();
const { send, sendSealedRelay } = useMTP();
const {
addLiveMessage,
chatSecret,
@ -174,19 +174,46 @@ export default function InputComponent({
log(3, "chat", "purple", "Content encrypted, sending message...");
send("MessageSend", {
Content: encryptedContent,
ReceiverId: userId,
SendTime: time,
...(replyTo && { ReplyId: replyTo }),
}).catch((e) => {
try {
const [ownIota, peerIota] = await Promise.all([
send("GetIotaData", { UserId: ownId }),
send("GetIotaData", { UserId: userId }),
]);
if (ownIota.type !== "GetIotaData" || peerIota.type !== "GetIotaData") {
throw new Error("Could not resolve an Iota for this message");
}
const response = await sendSealedRelay(
"MessageSend",
{
Content: encryptedContent,
ReceiverId: userId,
SendTime: time,
...(replyTo && { ReplyId: replyTo }),
},
{
nextHop: { kind: "iota", id: ownIota.data.IotaId },
finalRecipientId: userId,
metadataRecipients: [
{ value: ownIota.data.PublicKey, encoding: "base64" },
{ value: peerIota.data.PublicKey, encoding: "base64" },
],
contentRecipients: [
{ value: ownIota.data.PublicKey, encoding: "base64" },
{ value: peerIota.data.PublicKey, encoding: "base64" },
],
},
);
requireRelaySuccess(response);
reference.setMessageState("sent");
} catch (e) {
log(0, "Chat", "red", "Failed to send message", e, {
ReceiverId: userId,
SendTime: time,
});
reference.setFailed(true);
toast("error", "Failed to send message");
});
return;
}
if (replyTo) {
setReplyTo(undefined);

View file

@ -23,7 +23,7 @@ import {
wrapChatSecret,
} from "@tensamin/crypto/chatSecret";
import { useStorage } from "@tensamin/storage/context";
import { useMTP } from "@tensamin/mtp";
import { requireRelaySuccess, useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
import { useSession } from "@tensamin/storage/session";
import { useUser } from "@tensamin/user/context";
@ -226,7 +226,7 @@ export async function fetchReplyMessage({
export default function Provider({ children }: { children: ReactNode }) {
const { load } = useStorage();
const { send, subscribe } = useMTP();
const { send, sendSealedRelay, subscribe } = useMTP();
const { get: getUser } = useUser();
const { moveUserIdToTop } = useSession();
@ -469,9 +469,17 @@ export default function Provider({ children }: { children: ReactNode }) {
version: CHAT_SECRET_VERSION,
});
assertProtocolSuccess(
const ownIota = await send("GetIotaData", { UserId: ownUserId });
if (ownIota.type !== "GetIotaData") {
throw new Error(`Own Iota lookup failed: ${ownIota.type}`);
}
const peerIota = await send("GetIotaData", { UserId: userIdValue });
if (peerIota.type !== "GetIotaData") {
throw new Error(`Peer Iota lookup failed: ${peerIota.type}`);
}
const response = await sendSealedRelay(
"SetChatSecret",
await send("SetChatSecret", {
{
ChatId: chatId,
SecretId: secretId,
VersionNumber: CHAT_SECRET_VERSION,
@ -489,8 +497,21 @@ export default function Provider({ children }: { children: ReactNode }) {
KemCiphertext: protocolBytes(peerWrapped.kemCiphertext),
},
],
}),
},
{
nextHop: { kind: "iota", id: ownIota.data.IotaId },
finalRecipientId: userIdValue,
metadataRecipients: [
{ value: ownIota.data.PublicKey, encoding: "base64" },
{ value: peerIota.data.PublicKey, encoding: "base64" },
],
contentRecipients: [
{ value: ownIota.data.PublicKey, encoding: "base64" },
{ value: peerIota.data.PublicKey, encoding: "base64" },
],
},
);
requireRelaySuccess(response);
if (active) {
setCurrentChatSecretState({ userId: userIdValue, value: rawSecret });
@ -508,7 +529,7 @@ export default function Provider({ children }: { children: ReactNode }) {
return () => {
active = false;
};
}, [getUser, load, send, userIdValue]);
}, [getUser, load, send, sendSealedRelay, userIdValue]);
const getChatSecret = useCallback(
async (userId: number): Promise<Uint8Array | null> => {
@ -901,6 +922,9 @@ export default function Provider({ children }: { children: ReactNode }) {
setFailed: (failed: boolean) => {
editMessage(message.SendTime, { failed });
},
setMessageState: (MessageState: RawMessage["MessageState"]) => {
editMessage(message.SendTime, { MessageState });
},
};
},
[editMessage, userIdValue, moveUserIdToTop, ownId],
@ -1021,6 +1045,7 @@ type contextType = {
liveMessages: () => LiveMessage[];
addLiveMessage: (message: RawMessage) => {
setFailed: (failed: boolean) => void;
setMessageState: (messageState: RawMessage["MessageState"]) => void;
};
editMessage: (sendTime: number, edit: MessageEdit) => void;
deleteMessage: (sendTime: number) => void;

View file

@ -1,6 +1,11 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { toast as sonnerToast } from "@methanium/ui";
import { base64ToBytes, ConnectionState, MTPClient } from "mtp";
import {
base64ToBytes,
ConnectionState,
MTPClient,
MTPProtocolError,
} from "mtp";
import createAsyncQueue from "@tensamin/shared/asyncQueue";
import {
mtp as mtpSchemas,
@ -17,6 +22,8 @@ import {
type MTPContextType,
type ProtocolMessage,
removeMissingContacts,
sealedRelayResultFromFrame,
type SealedRelaySend,
useMessageHandlers,
} from "./mtpContext";
import {
@ -201,6 +208,23 @@ export function BrowserProvider(props: {
},
[],
);
const sendSealedRelay: SealedRelaySend = useMemo(
() => async (type, data, options) => {
const client = clientRef.current;
if (!client) throw new Error("mtp is not connected");
try {
return sealedRelayResultFromFrame(
await client.requestSealedRelay(type, data, options),
);
} catch (error) {
if (error instanceof MTPProtocolError) {
return sealedRelayResultFromFrame(error.frame);
}
throw error;
}
},
[],
);
const resolveConnectionRef = useRef(() => {});
useEffect(() => {
@ -338,6 +362,7 @@ export function BrowserProvider(props: {
hostPublicKey: { value: omikronPublicKey, encoding: "base64" },
descriptor: "client",
pings: true,
securityProfile: { protectedSignatureSuite: "dual" },
logger: (event) => {
if (event.type === "state") {
if (generation !== connectionGeneration) return;
@ -512,6 +537,7 @@ export function BrowserProvider(props: {
<MTPContext.Provider
value={{
send: sendQueued,
sendSealedRelay,
subscribe,
addInterceptor,
readyState,

View file

@ -1,7 +1,12 @@
export { Provider, useMTP } from "./context";
export { RelayRejectedError, requireRelaySuccess } from "./mtpContext";
export type {
BoundSendFn,
MTPExchange,
MTPInterceptor,
ProtocolMessage,
RelayTarget,
SealedRelayOptions,
SealedRelayResult,
SealedRelaySend,
} from "./mtpContext";

View file

@ -1,5 +1,8 @@
import { createContext, useCallback, useRef } from "react";
import type {
MTPDataValueInput,
MTPEncodedBytesInput,
MTPFrame,
MTPRequestFunction,
MTPResponseFrame,
MTPSubscriptionFunction,
@ -19,6 +22,56 @@ export type ProtocolMessage<
export type BoundSendFn = MTPRequestFunction<typeof mtpSchemas>;
export type RelayTarget =
| { kind: "user"; id: number }
| { kind: "iota"; id: number };
export type SealedRelayOptions = {
nextHop: RelayTarget;
finalRecipientId: number;
metadataRecipients: MTPEncodedBytesInput[];
contentRecipients: MTPEncodedBytesInput[];
};
export type SealedRelayResult = {
type: string;
data: Record<string, unknown>;
};
export function sealedRelayResultFromFrame(frame: MTPFrame): SealedRelayResult {
return {
type: frame.type,
data:
typeof frame.data === "object" &&
frame.data !== null &&
!Array.isArray(frame.data)
? (frame.data as Record<string, unknown>)
: {},
};
}
export class RelayRejectedError extends Error {
public readonly responseType: string;
constructor(responseType: string) {
super(`Relay rejected with ${responseType}`);
this.responseType = responseType;
this.name = "RelayRejectedError";
}
}
export function requireRelaySuccess(response: SealedRelayResult): void {
if (response.type !== "Success") {
throw new RelayRejectedError(response.type);
}
}
export type SealedRelaySend = (
type: string,
data: MTPDataValueInput,
options: SealedRelayOptions,
) => Promise<SealedRelayResult>;
export type MTPExchange = {
type: keyof typeof mtpSchemas & string;
data: unknown;
@ -29,6 +82,7 @@ export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
export type MTPContextType = {
send: BoundSendFn;
sendSealedRelay: SealedRelaySend;
subscribe: MTPSubscriptionFunction<typeof mtpSchemas>;
addInterceptor: (interceptor: MTPInterceptor) => () => void;
readyState: number;

View file

@ -26,6 +26,8 @@ import {
MTPContext,
type ProtocolMessage,
removeMissingContacts,
type SealedRelayResult,
type SealedRelaySend,
useMessageHandlers,
} from "./mtpContext";
@ -233,12 +235,26 @@ export function TauriProvider(props: {
},
[connection, interceptorsRef],
);
const sendSealedRelay = useCallback<SealedRelaySend>(
async (type, data, options) => {
return invoke<SealedRelayResult>("mtp_send_sealed_relay", {
typeName: type,
data,
nextHop: options.nextHop,
finalRecipientId: options.finalRecipientId,
metadataRecipients: options.metadataRecipients,
contentRecipients: options.contentRecipients,
});
},
[],
);
const connected = snapshot.readyState === ConnectionState.Connected;
return (
<MTPContext.Provider
value={{
send,
sendSealedRelay,
subscribe,
addInterceptor,
readyState: snapshot.readyState,

View file

@ -148,7 +148,7 @@ const userFields = {
About: z.string().max(255).optional(),
Avatar: z.string().optional(),
Display: z.string().min(1).max(15),
IotaId: z.number(),
IotaId: z.number().optional(),
OmikronConnections: z.array(z.number()),
OmikronId: z.number().optional(),
PublicKey: z.base64(),
@ -240,6 +240,27 @@ export const mtp = {
}),
response: userSchema,
},
GetIotaData: {
request: z
.object({
IotaId: z.number().int().positive().optional(),
UserId: z.number().int().positive().optional(),
Username: z.string().min(1).max(15).optional(),
})
.refine(
({ IotaId, UserId, Username }) =>
[IotaId, UserId, Username].filter((value) => value !== undefined)
.length === 1,
"GetIotaData requires exactly one selector",
),
response: z.object({
IotaId: z.number().int().positive(),
PublicKey: z.base64(),
OmikronConnections: z.array(z.number().int().positive()).optional(),
UserId: z.number().int().positive().optional(),
Username: z.string().optional(),
}),
},
GetStates: {
request: z.object({
SessionId: z.number().int().positive(),