(wip): migrate ttp to mtp
Some checks failed
/ build-web (push) Failing after 3m4s
/ build-desktop (linux) (push) Failing after 3m15s
/ build-mobile (push) Failing after 9m7s
/ release (push) Has been skipped

This commit is contained in:
Alois 2026-07-02 21:05:50 +02:00
commit 20018f09a9
163 changed files with 8342 additions and 2609 deletions

View file

@ -22,7 +22,7 @@
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/ttp": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/ui": "*",
"@tensamin/user": "workspace:*",
"deepfilternet3-noise-filter": "^1.2.1",

View file

@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { create } from "zustand";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useTTP } from "@tensamin/ttp";
import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
import { ttp } from "@tensamin/shared/data";
import { mtp } from "@tensamin/shared/data";
import { useCrypto } from "@tensamin/crypto/context";
import { useStorage } from "@tensamin/storage/context";
import { useSession } from "@tensamin/storage/session";
@ -52,7 +52,7 @@ type IncomingCallInvite = {
senderId: number;
};
type CurrentCallData =
| (z.infer<typeof ttp.call_data.response> & { exists: boolean })
| (z.infer<typeof mtp.call_data.response> & { exists: boolean })
| null;
type NavigateFn = (options: {
@ -1088,7 +1088,7 @@ export const useCall = create<CallStore>(() => ({
export function useInitializeCall() {
const navigate = useNavigate();
const location = useLocation();
const { send, subscribePush } = useTTP();
const { send, subscribePush } = useMTP();
const { getSharedSecret, decryptText, encryptText } = useCrypto();
const { load } = useStorage();
const { insertCall } = useSession();
@ -1504,7 +1504,7 @@ export function useInitializeCall() {
send("call_data", { call_id: callId })
.then((data) => {
setCurrentCallData({
...(data.data as z.infer<typeof ttp.call_data.response>),
...(data.data as z.infer<typeof mtp.call_data.response>),
exists: true,
});
})

View file

@ -23,7 +23,7 @@
"@tensamin/markdown": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/ttp": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/ui": "*",
"@tensamin/user": "workspace:*",
"lucide-react": "^1.14.0",

View file

@ -1,5 +1,4 @@
import type { BoundSendFn } from "@tensamin/ttp";
import type { TTP } from "@tensamin/shared/data";
import type { BoundSendFn } from "@tensamin/mtp";
import type { RawMessages } from "../values";
/**
@ -11,7 +10,7 @@ import type { RawMessages } from "../values";
* @returns Promise<RawMessages>.
*/
export async function getMessages(
send: BoundSendFn<TTP>,
send: BoundSendFn,
amount: number,
offset: number,
user_id: number,

View file

@ -12,7 +12,7 @@ import { Button } from "@tensamin/ui";
import { Plus, Laugh, FileVideo } from "lucide-react";
import { useChat } from "../context";
import { useTTP } from "@tensamin/ttp";
import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
import { cn, useIsMobile } from "@tensamin/ui";
import { encryptText } from "@tensamin/crypto/worker";
@ -28,7 +28,7 @@ export default function InputComponent({
}) {
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
const { send } = useTTP();
const { send } = useMTP();
const { addLiveMessage, sharedSecret, userId, inputBoxRef } = useChat();
const { load, save } = useStorage();
const { moveUserIdToTop } = useSession();

View file

@ -28,7 +28,7 @@ import {
import MessageContextMenu from "./messageContextMenu";
import Media from "./media";
import { useStorage } from "@tensamin/storage/context";
import { useTTP } from "@tensamin/ttp";
import { useMTP } from "@tensamin/mtp";
function MessageComponent({
grouped,
@ -75,7 +75,7 @@ function MessageComponent({
// Message states
const { load } = useStorage();
const { send } = useTTP();
const { send } = useMTP();
const messageStateReadUpdate = useCallback(async () => {
const readConfirmations = await load("settings.read_confirmations");

View file

@ -15,7 +15,7 @@ import type { LiveMessage, RawMessage, RawMessages } from "./values";
import { useCrypto } from "@tensamin/crypto/context";
import { useUser } from "@tensamin/user/context";
import { useStorage } from "@tensamin/storage/context";
import { useTTP } from "@tensamin/ttp";
import { useMTP } from "@tensamin/mtp";
import { log } from "@tensamin/shared/log";
import { useSession } from "@tensamin/storage/session";
@ -59,7 +59,7 @@ export default function Provider(props: { children: ReactNode }) {
const { getSharedSecret, decryptText } = useCrypto();
const { get } = useUser();
const { load } = useStorage();
const { send, subscribePush } = useTTP();
const { send, subscribePush } = useMTP();
const { moveUserIdToTop } = useSession();
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);

View file

@ -1,7 +1,7 @@
import { z } from "zod";
import { ttp } from "@tensamin/shared/data";
import { mtp } from "@tensamin/shared/data";
export type RawMessages = z.infer<typeof ttp.messages_get.response>["messages"];
export type RawMessages = z.infer<typeof mtp.messages_get.response>["messages"];
export type RawMessage = RawMessages[number];

View file

@ -15,6 +15,7 @@
},
"dependencies": {
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1",
"comlink": "^4.4.2",
"react": "^19.2.0",
"react-dom": "^19.2.0"

View file

@ -1,5 +1,5 @@
{
"name": "@tensamin/ttp",
"name": "@tensamin/mtp",
"private": true,
"version": "0.0.0",
"type": "module",
@ -12,16 +12,16 @@
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tensamin/ttp-core": "*",
"@tanstack/react-router": "^1.0.0",
"@tensamin/crypto": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/ui": "*",
"@tauri-apps/api": "^2",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/ui": "*",
"mtp": "*",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tauri-plugin-app-events-api": "^0.2.0"
"tauri-plugin-app-events-api": "^0.2.0",
"zod": "^4.4.2"
},
"devDependencies": {
"eslint": "^10.0.3"

View file

@ -1,42 +1,71 @@
import {
useState,
createContext,
type ReactNode,
useRef,
useEffect,
useContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} 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 { MTPClient } from "mtp";
import type { z } from "zod";
import { decryptText } from "@tensamin/crypto/worker";
import { useCrypto } from "@tensamin/crypto/context";
import {
type Calls,
type Communities,
type Contacts,
mtp as schemas,
type MTP as Schemas,
} from "@tensamin/shared/data";
import { log } from "@tensamin/shared/log";
import { useStorage } from "@tensamin/storage/context";
import { ErrorScreen, LoadingScreen as Loading } from "@tensamin/ui";
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 APP_VERSION = "0.0.10";
const READY_STATE = {
CLOSED: 0,
CONNECTING: 1,
OPEN: 2,
} as const;
const PUSH_TYPES = ["message_live", "message_state", "call_invite", "error_no_iota"] as const;
const WIRE_TYPES = {
identification: "AppIdentification",
challenge_response: "AppChallengeResponse",
get_user_data: "GetUserData",
change_user_data: "ChangeUserData",
ping: "AppPing",
message_live: "MessageLive",
messages_get: "MessagesGet",
message_send: "MessageSend",
add_conversation: "AddConversation",
message_state: "MessageState",
load_txt_record: "LoadTxtRecord",
authenticate_app: "AuthenticateApp",
create_app: "CreateApp",
call_token: "CallToken",
call_data: "CallData",
call_invite: "CallInvite",
error_no_iota: "ErrorNoIota",
} as const satisfies Record<keyof Schemas & string, string>;
const APP_TYPES = Object.fromEntries(
Object.entries(WIRE_TYPES).map(([appType, wireType]) => [wireType, appType]),
) as Record<string, keyof Schemas & string>;
const FATAL_IDENTIFICATION_ERROR_TYPES = new Set([
"error",
@ -49,105 +78,26 @@ const FATAL_IDENTIFICATION_ERROR_TYPES = new Set([
"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");
}
export type ProtocolMessage<T extends keyof Schemas & string = keyof Schemas & string> = {
id?: number;
type: T | string;
data: z.infer<Schemas[T]["response"]>;
};
if (error instanceof Error) {
if (error.message.includes("STOP_SENDING")) {
return true;
}
export type BoundSendFn = <T extends keyof Schemas & string>(
type: T,
data?: z.infer<Schemas[T]["request"]>,
options?: { id?: number },
) => Promise<ProtocolMessage<T>>;
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);
}
export type PushHandler = (message: ProtocolMessage) => void;
type ContextType = {
send: BoundSendFn<Schemas>;
send: BoundSendFn;
subscribe: <T extends keyof Schemas & string>(
type: T,
handler: (message: ProtocolMessage<T>) => void,
) => () => void;
subscribePush: (handler: PushHandler) => () => void;
readyState: number;
ownPing: number;
@ -158,17 +108,102 @@ type ContextType = {
freshCalls: Calls;
};
const TTPContext = createContext<ContextType | undefined>(undefined);
const MTPContext = 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;
}) {
function isTauriMobile() {
return isTauri() && /Android|iPhone|iPad|iPod/.test(navigator.userAgent);
}
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;
}
}
return error instanceof Error && (
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")
);
}
function getProtocolErrorDetails(error: unknown) {
if (typeof error !== "object" || error === null || !("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 toPascalCase(value: string) {
return value
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
function toSnakeCase(value: string) {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
.toLowerCase();
}
function mapDataKeys(value: unknown, mapKey: (key: string) => string): unknown {
if (Array.isArray(value)) {
return value.map((item) => mapDataKeys(item, mapKey));
}
if (typeof value !== "object" || value === null) {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
mapKey(key),
mapDataKeys(entry, mapKey),
]),
);
}
function validateResponse<T extends keyof Schemas & string>(
type: T,
message: { id?: number; type: string; data: unknown },
): ProtocolMessage<T> {
const appType = APP_TYPES[message.type] ?? message.type;
const data = mapDataKeys(message.data, toSnakeCase);
if (appType.startsWith("error")) {
return { ...message, type: appType, data } as ProtocolMessage<T>;
}
const schema = schemas[type]?.response;
if (!schema) {
return message as ProtocolMessage<T>;
}
const parsed = schema.safeParse(data);
if (!parsed.success) {
throw new Error(`Response validation failed for ${type}: ${parsed.error.message}`);
}
return {
id: message.id,
type: appType,
data: parsed.data,
} as ProtocolMessage<T>;
}
export function Provider(props: { children: ReactNode; blockConnection?: boolean }) {
const { load } = useStorage();
const { decrypt, getSharedSecret } = useCrypto();
@ -187,73 +222,65 @@ export function Provider(props: {
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const clientRef = useRef<ReturnType<
typeof createTransportClient<Schemas>
> | null>(null);
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | 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>("");
const [mtpUrl, setMtpUrl] = useState<string | null>(null);
useEffect(() => {
load("ttp_url").then((url) => {
setTtpUrl(url);
});
load("ttp_server_cert").then((cert) => {
setTtpServerCert(cert.trim());
});
load("mtp_url").then(setMtpUrl);
}, [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;
const send: BoundSendFn = useMemo(
() => async (type, data, options) => {
const client = clientRef.current;
if (!client) {
return Promise.reject(new Error("ttp is not connected"));
}
if (!client) {
throw new Error("mtp is not connected");
}
return client.send(type as keyof Schemas & string, data as never, {
...options,
});
}) as BoundSendFn<Schemas>,
const message = await client.request(
WIRE_TYPES[type],
mapDataKeys(data ?? {}, toPascalCase) as Record<string, unknown>,
options,
);
return validateResponse(type, message);
},
[],
);
/**
* 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 subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
const client = clientRef.current;
if (!client) {
return () => {};
}
return client.subscribePush(handler);
return client.subscribe(WIRE_TYPES[type], (message) => {
handler(validateResponse(type, message));
});
}, []);
const subscribePush = useCallback((handler: PushHandler) => {
const client = clientRef.current;
if (!client) {
return () => {};
}
const unsubscribers = PUSH_TYPES.map((type) =>
client.subscribe(WIRE_TYPES[type], (message) => {
handler(validateResponse(type as keyof Schemas & string, message));
}),
);
return () => {
unsubscribers.forEach((unsubscribe) => unsubscribe());
};
}, []);
// Check for error_no_iota
useEffect(() => {
if (!connected) return;
return subscribePush((message) => {
if (message.type !== "error_no_iota") return;
return subscribe("error_no_iota", () => {
identificationCancelRef.current = true;
setIdentified(false);
setIdentifying(false);
@ -262,7 +289,7 @@ export function Provider(props: {
"You could try to restart your Iota, check for updates or check your network connection.",
);
});
}, [connected, subscribePush]);
}, [connected, subscribe]);
useEffect(() => {
if (!connected || !identified) {
@ -272,20 +299,15 @@ export function Provider(props: {
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 data = await send("ping", { last_ping: originalNow });
setOwnPing(Date.now() - originalNow);
const remotePing = data.data.ping_iota;
if (typeof remotePing === "number") {
setIotaPing(remotePing);
}
} catch (intervalError) {
log(1, "ttp", "yellow", "Ping failed", intervalError);
log(1, "mtp", "yellow", "Ping failed", intervalError);
}
}, PING_INTERVAL);
@ -295,9 +317,10 @@ export function Provider(props: {
}, [connected, identified, send]);
useEffect(() => {
if (!ttpUrl) {
if (!mtpUrl) {
return;
}
const url = mtpUrl;
let attempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@ -306,37 +329,19 @@ export function Provider(props: {
let disposed = false;
let resumeListenerRegistered = false;
/**
* Clears any scheduled reconnect timeout and resets scheduling flags.
* @returns Void.
*/
const clearReconnectTimer = () => {
if (!reconnectTimer) {
return;
}
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;
}
if (!reconnectResetTimer) return;
clearTimeout(reconnectResetTimer);
reconnectResetTimer = null;
};
/**
* Starts the stability timer that resets reconnect attempts after uptime.
* @returns Void.
*/
const scheduleReconnectReset = () => {
clearReconnectResetTimer();
reconnectResetTimer = setTimeout(() => {
@ -345,22 +350,15 @@ export function Provider(props: {
}, 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 (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);
log(0, "mtp", "red", "Reconnection attempts exhausted", reason);
return;
}
@ -373,87 +371,65 @@ export function Provider(props: {
}, 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) {
if (disposed || props.blockConnection) {
return;
}
try {
await transportClient?.connect(ttpUrl);
} catch (connectError) {
setReadyState(READY_STATE.CONNECTING);
setConnected(false);
setIdentified(false);
setIdentifying(false);
identificationStartedRef.current = false;
await MTPClient.init();
const client = await MTPClient.create({
url,
pings: true,
logger: (event) => log(event.hint === "error" ? 0 : 2, "mtp", "blue", event),
});
if (disposed) {
client.disconnect();
return;
}
log(0, "ttp", "red", "Connection attempt failed", connectError);
clientRef.current = client;
await client.connect();
if (disposed) {
client.disconnect();
return;
}
clearReconnectTimer();
scheduleReconnectReset();
identificationCancelRef.current = false;
setReadyState(READY_STATE.OPEN);
setConnected(true);
setError("");
setErrorDescription("");
} catch (connectError) {
if (disposed) return;
clientRef.current?.disconnect();
clientRef.current = null;
clearReconnectResetTimer();
setReadyState(READY_STATE.CLOSED);
setConnected(false);
setIdentified(false);
setIdentifying(false);
log(0, "mtp", "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;
}
if (disposed) return;
clientRef.current?.disconnect();
clientRef.current = null;
clearReconnectTimer();
clearReconnectResetTimer();
attempts = 0;
@ -482,18 +458,15 @@ export function Provider(props: {
onResume();
}
if (clientRef.current === transportClient) {
clientRef.current = null;
}
void transportClient?.close("context-dispose");
clientRef.current?.disconnect();
clientRef.current = null;
setReadyState(READY_STATE.CLOSED);
setConnected(false);
setIdentified(false);
setIdentifying(false);
identificationStartedRef.current = false;
};
}, [props.blockConnection, ttpServerCert, ttpUrl]);
}, [mtpUrl, props.blockConnection]);
useEffect(() => {
if (!connected) {
@ -501,12 +474,7 @@ export function Provider(props: {
return;
}
if (identificationCancelRef.current) {
identificationStartedRef.current = false;
return;
}
if (identificationStartedRef.current) {
if (identificationCancelRef.current || identificationStartedRef.current) {
return;
}
@ -515,10 +483,6 @@ export function Provider(props: {
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");
@ -535,58 +499,39 @@ export function Provider(props: {
throw new Error("Invalid credentials");
}
// Challenge Request
const challengeEnvelope = await send("identification", {
version,
version: APP_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) => {
}).catch((challengeError) => {
if (!identificationCancelRef.current) {
setError("Identification Failed");
setErrorDescription(
"Unable to complete secure identification. Please verify your credentials and try again.",
);
}
throw error;
throw challengeError;
});
// Data handling
setFreshContacts(finalResponse.data.contacts);
setFreshCommunities(finalResponse.data.communities);
setFreshCommunities(finalResponse.data.communities ?? []);
setFreshCalls(finalResponse.data.calls);
if (cancelled || identificationCancelRef.current) {
return;
}
@ -595,37 +540,20 @@ export function Provider(props: {
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);
if (cancelled || identificationCancelRef.current) {
return;
}
const isFatal = isFatalIdentificationError(identificationError);
const protocolErrorDetails =
getProtocolErrorDetails(identificationError);
log(
isFatal ? 0 : 1,
"ttp",
"mtp",
isFatal ? "red" : "yellow",
"Identification handshake failed",
protocolErrorDetails ?? identificationError,
getProtocolErrorDetails(identificationError) ?? identificationError,
);
setIdentified(false);
setError("Identification Failed");
setErrorDescription(
isFatal
@ -647,51 +575,35 @@ export function Provider(props: {
}, [connected, decrypt, getSharedSecret, load, send]);
const progress = useMemo(() => {
if (!ttpUrl) return 10;
if (!mtpUrl) 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]);
}, [connected, identified, identifying, readyState, mtpUrl]);
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";
}
if (!mtpUrl) 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]);
}, [connected, identified, identifying, readyState, mtpUrl]);
const loadingDescription = useMemo(() => {
if (!ttpUrl) {
return "Loading connection details";
}
if (!mtpUrl) return "Loading connection details";
if (readyState === READY_STATE.CONNECTING || !connected) {
return "Establishing transport channel";
}
if (identifying || !identified) {
return "Verifying challenge-response handshake";
}
if (identifying || !identified) return "Verifying challenge-response handshake";
return undefined;
}, [connected, identified, identifying, readyState, ttpUrl]);
}, [connected, identified, identifying, readyState, mtpUrl]);
if (error !== "" && errorDescription !== "") {
return <ErrorScreen error={error} description={errorDescription} />;
}
if (!connected || !identified || !ttpUrl) {
if (!connected || !identified || !mtpUrl) {
return (
<Loading
progress={progress}
@ -703,9 +615,10 @@ export function Provider(props: {
}
return (
<TTPContext.Provider
<MTPContext.Provider
value={{
send,
subscribe,
subscribePush,
readyState,
ownPing,
@ -717,18 +630,14 @@ export function Provider(props: {
}}
>
{props.children}
</TTPContext.Provider>
</MTPContext.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);
export function useMTP(): ContextType {
const context = useContext(MTPContext);
if (!context) {
throw new Error("useTTP must be used within a TTPProvider");
throw new Error("useMTP must be used within an MTPProvider");
}
return context;
}

View file

@ -0,0 +1,2 @@
export { Provider, useMTP } from "./context";
export type { BoundSendFn, PushHandler, ProtocolMessage } from "./context";

View file

@ -1,5 +1,3 @@
export const RESPONSE_TIMEOUT = 15_000;
export const RETRY_COUNT = 10;
export const RETRY_INTERVAL = 3_000;
export const PING_INTERVAL = 3_000;
export const RECONNECT_TRIES = 3;

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["src"]
}

View file

@ -18,7 +18,7 @@
"@tensamin/shared": "workspace:*",
"@tensamin/chat": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/ttp": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/ui": "*",
"@tensamin/user": "workspace:*",
"react": "^19.2.0",

View file

@ -2,7 +2,7 @@ import { useCrypto } from "@tensamin/crypto/context";
import { useStorage } from "@tensamin/storage/context";
import { useUser } from "@tensamin/user/context";
import { useChat } from "@tensamin/chat/context";
import { useTTP } from "@tensamin/ttp";
import { useMTP } from "@tensamin/mtp";
import { createContext, useEffect, useContext } from "react";
import z from "zod";
import { toast as sonnerToast } from "sonner";
@ -24,7 +24,7 @@ async function requestNotificationPermission() {
}
export default function Provider(props: { children: React.ReactNode }) {
const { subscribePush, send } = useTTP();
const { subscribePush, send } = useMTP();
const { load } = useStorage();
const { get } = useUser();
const { decryptText, getSharedSecret } = useCrypto();

View file

@ -37,15 +37,15 @@ export const failedUser = {
sub_level: 0,
user_id: 0,
username: "unknown",
} as z.infer<typeof ttp.get_user_data.response>;
} as z.infer<typeof mtp.get_user_data.response>;
export type Contacts = z.infer<
typeof ttp.challenge_response.response.shape.contacts
typeof mtp.challenge_response.response.shape.contacts
>;
export type Communities = z.infer<
typeof ttp.challenge_response.response.shape.communities
typeof mtp.challenge_response.response.shape.communities
>;
export type Calls = z.infer<typeof ttp.challenge_response.response.shape.calls>;
export type Calls = z.infer<typeof mtp.challenge_response.response.shape.calls>;
type Base16Palette = Record<
| "base00"
@ -67,7 +67,7 @@ type Base16Palette = Record<
string
>;
// TTP
// MTP
const user = z.object({
about: z.string().max(255).optional(),
avatar: z.string().optional(),
@ -93,7 +93,7 @@ const user = z.object({
user_id: z.number(),
username: z.string().min(1).max(15),
});
export const ttp = {
export const mtp = {
identification: {
request: z.object({
version: z.string(),
@ -258,9 +258,13 @@ export const ttp = {
sender_id: z.number().optional(),
}),
},
error_no_iota: {
request: z.object({}).optional(),
response: z.object({}),
},
} satisfies Record<string, { request: z.ZodType; response: z.ZodType }>;
export type TTP = typeof ttp;
export type MTP = typeof mtp;
// Storage
export interface Storage extends SettingsStorageDefaults {
@ -276,8 +280,7 @@ export interface Storage extends SettingsStorageDefaults {
legal_docs: z.infer<typeof legalDocsSchema>;
cached_contacts: Contacts;
cached_communities: Communities;
ttp_url: string;
ttp_server_cert: string;
mtp_url: string;
call_mute_range_start: number;
call_mute_range_end: number;
theme_color: string;
@ -326,8 +329,7 @@ export const storageDefaults: Storage = {
},
cached_contacts: [],
cached_communities: [],
ttp_url: "https://tensamin.net:959",
ttp_server_cert: "",
mtp_url: "https://tensamin.net:959",
call_mute_range_start: -55,
call_mute_range_end: -45,
theme_color: "",
@ -368,7 +370,7 @@ export const storageDefaults: Storage = {
// User Status
export function getStatusColor(
status: z.infer<typeof ttp.get_user_data.response.shape.online_status>,
status: z.infer<typeof mtp.get_user_data.response.shape.online_status>,
) {
switch (status) {
case "user_online":

View file

@ -15,7 +15,7 @@
},
"dependencies": {
"@tensamin/shared": "workspace:*",
"@tensamin/ttp": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/ui": "*",
"react": "^19.2.0",
"react-dom": "^19.2.0"

View file

@ -1,4 +1,4 @@
import { useTTP } from "@tensamin/ttp";
import { useMTP } from "@tensamin/mtp";
import {
createContext,
type ReactNode,
@ -21,7 +21,7 @@ interface SessionContextType {
const SessionContext = createContext<SessionContextType | undefined>(undefined);
export default function SessionProvider({ children }: { children: ReactNode }) {
const { freshContacts, freshCommunities, freshCalls } = useTTP();
const { freshContacts, freshCommunities, freshCalls } = useMTP();
const { load, save } = useStorage();
const [contacts, setContacts] = useState<Contacts>([]);
const [communities, setCommunities] = useState<Communities>([]);

View file

@ -18,7 +18,7 @@
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/tauri": "workspace:*",
"@tensamin/ttp": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/ui": "*",
"@tensamin/user": "workspace:*",
"lucide-react": "^1.8.0",

View file

@ -13,7 +13,7 @@ import {
} from "@tensamin/ui";
import { useLocation } from "@tanstack/react-router";
import { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import { useTTP } from "@tensamin/ttp";
import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
import { Loader2 } from "lucide-react";
import { isTauri } from "@tauri-apps/api/core";
@ -22,7 +22,7 @@ import { decryptText } from "@tensamin/crypto/worker";
export default function Wrapper({ children }: { children: ReactNode }) {
const { get } = useUser();
const { load } = useStorage();
const { send } = useTTP();
const { send } = useMTP();
const { getSharedSecret } = useCrypto();
const { searchStr } = useLocation();
const [dialogOpen, setDialogOpen] = useState(false);

View file

@ -1,3 +0,0 @@
export * from "@tensamin/ttp-core";
export * from "./context";
export * from "./values";

View file

@ -1,3 +0,0 @@
- Replace with MTP
- Remove queue
- Use Date.now() for message id

View file

@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

View file

@ -14,7 +14,7 @@
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tensamin/ttp": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"react": "^19.2.0",

View file

@ -1,7 +1,7 @@
import * as React from "react";
import { useTTP } from "@tensamin/ttp";
import { useMTP } from "@tensamin/mtp";
import { ttp as schemas } from "@tensamin/shared/data";
import { mtp as schemas } from "@tensamin/shared/data";
import type z from "zod";
export type User = z.infer<typeof schemas.get_user_data.response>;
@ -23,7 +23,7 @@ export default function UserProvider(props: { children: React.ReactNode }) {
{},
);
const { send } = useTTP();
const { send } = useMTP();
/**
* Executes get.