Compare commits

..
Author SHA1 Message Date
7ad15bbbe6 chore(deps): update dependency gradle to v9
Some checks failed
renovate/stability-days Updates have met minimum release age requirement
Dependency builds / Build desktop (pull_request) Failing after 5s
Dependency builds / Build web (pull_request) Failing after 5s
Dependency builds / Test native MTP (pull_request) Failing after 2s
Dependency builds / Build mobile (pull_request) Failing after 4s
2026-08-27 21:00:57 +03:00
22 changed files with 1870 additions and 1217 deletions

View file

@ -1,2 +0,0 @@
[env]
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }

View file

@ -0,0 +1,2 @@
[env]
MTP_TYPE_MAPS = { value = "../../mtp-type-maps/type-maps.yaml", relative = true }

View file

@ -341,7 +341,7 @@ async fn supervise(generation: u64) {
let notification_config = config.clone();
let notification_worker = tokio::spawn(async move {
while let Some(frame) = notification_rx.recv().await {
if !manager.is_current(generation) {
if !manager().is_current(generation) {
break;
}
if let Err(error) = notify_message(
@ -497,7 +497,7 @@ fn prepare_initial_state_ack(
.ok_or("ClientStateSync payload is not an object")?;
let session_id = required_integer(data, "SessionId")?;
let version = required_integer(data, "VersionNumber")?;
let ack = CommunicationValue::new(communication_type("ClientStateAck")?)
let ack = CommunicationValue::new(CommunicationType::ClientStateAck)
.with_id(request_ids.next()?)
.add_typed_default(DataType::SessionId, number_to_data(session_id))
.add_typed_default(DataType::VersionNumber, number_to_data(version));
@ -645,7 +645,7 @@ async fn await_initial_state(
return Err("No Iota is currently connected".into());
}
if frame.get_type_name() == Some("ClientStateSync") {
if frame.is_type(CommunicationType::ClientStateSync) {
return Ok((frame, buffered));
}
@ -740,7 +740,7 @@ async fn notify_message(
let message = frame
.get_data(DataType::Message)
.ok_or("MessageLive omitted Message")?;
let content = container_value_by_name(message, "Content")
let content = container_value(message, DataType::AppContent)
.and_then(DataValue::as_str)
.ok_or("MessageLive omitted Content")?;
let keyring_bytes = decode_browser_base64(&config.keyring)?;
@ -888,24 +888,42 @@ fn container_value(value: &DataValue, field: DataType) -> Option<&DataValue> {
value.get_field(id)
}
fn container_value_by_name<'a>(value: &'a DataValue, field: &str) -> Option<&'a DataValue> {
container_value(value, DataType::from_name(field)?)
fn wire_field_name(name: &str) -> &str {
match name {
"Content" => "AppContent",
"CreatedAt" => "AppCreatedAt",
"MessageId" => "AppMessageId",
_ => name,
}
}
fn communication_type(name: &str) -> Result<CommunicationType, String> {
CommunicationType::from_name(name).ok_or_else(|| format!("unknown communication type: {name}"))
fn application_field_name(name: &str) -> &str {
match name {
"AppContent" => "Content",
"AppCreatedAt" => "CreatedAt",
"AppMessageId" => "MessageId",
_ => name,
}
}
fn json_to_frame(type_name: &str, data: Value, id: u32) -> Result<CommunicationValue, String> {
let comm_type = communication_type(type_name)?;
let comm_type = CommunicationType::from_name(type_name)
.ok_or_else(|| format!("unknown communication type: {type_name}"))?;
let mut frame = CommunicationValue::new(comm_type).with_id(id);
let Value::Object(fields) = data else {
return Err("MTP request data must be an object".into());
};
let mut translated_fields = std::collections::HashSet::<String>::with_capacity(fields.len());
for (name, value) in fields {
let wire_name = wire_field_name(&name).to_owned();
if !translated_fields.insert(wire_name.clone()) {
return Err(format!(
"duplicate MTP field after translation: {wire_name}"
));
}
let data_type =
DataType::from_name(&name).ok_or_else(|| format!("unknown data type: {name}"))?;
frame = frame.add_typed_default(data_type, json_to_data(&name, value)?);
DataType::from_name(&wire_name).ok_or_else(|| format!("unknown data type: {name}"))?;
frame = frame.add_typed_default(data_type, json_to_data(&wire_name, value)?);
}
Ok(frame)
}
@ -943,13 +961,21 @@ fn json_to_data(field: &str, value: Value) -> Result<DataValue, String> {
),
Value::Object(fields) => {
let mut entries = Vec::with_capacity(fields.len());
let mut translated_fields =
std::collections::HashSet::<String>::with_capacity(fields.len());
for (name, value) in fields {
let data_type = DataType::from_name(&name)
let wire_name = wire_field_name(&name).to_owned();
if !translated_fields.insert(wire_name.clone()) {
return Err(format!(
"duplicate MTP field after translation: {wire_name}"
));
}
let data_type = DataType::from_name(&wire_name)
.ok_or_else(|| format!("unknown nested data type: {name}"))?;
let id = data_type
.try_to_id(&TypeMap::latest())
.ok_or_else(|| format!("unmapped data type: {name}"))?;
entries.push((id, json_to_data(&name, value)?));
entries.push((id, json_to_data(&wire_name, value)?));
}
DataValue::Container(entries)
}
@ -994,7 +1020,13 @@ fn frame_data_to_json(frame: &CommunicationValue) -> Result<Value, String> {
let name = map
.data_type_name(id.0)
.ok_or_else(|| format!("unknown data type id: {}", id.0))?;
result.insert(name.to_owned(), data_to_json(value, &map)?);
let application_name = application_field_name(name);
if result.contains_key(application_name) {
return Err(format!(
"duplicate MTP field after translation: {application_name}"
));
}
result.insert(application_name.to_owned(), data_to_json(value, &map)?);
}
Ok(Value::Object(result))
}
@ -1025,7 +1057,13 @@ fn data_to_json(value: &DataValue, map: &TypeMap) -> Result<Value, String> {
let name = map
.data_type_name(id.0)
.ok_or_else(|| format!("unknown nested data type id: {}", id.0))?;
object.insert(name.to_owned(), data_to_json(value, map)?);
let application_name = application_field_name(name);
if object.contains_key(application_name) {
return Err(format!(
"duplicate MTP field after translation: {application_name}"
));
}
object.insert(application_name.to_owned(), data_to_json(value, map)?);
}
Value::Object(object)
}
@ -1046,7 +1084,7 @@ mod tests {
use serde_json::json;
use super::{
container_value_by_name, decode_browser_base64, decode_sdk_bytes, frame_to_json,
container_value, decode_browser_base64, decode_sdk_bytes, frame_to_json,
jittered_retry_delay, json_to_frame, prepare_initial_state_ack, RequestIdAllocator,
};
@ -1079,7 +1117,7 @@ mod tests {
}
#[test]
fn json_content_uses_content_wire_type() {
fn json_content_uses_app_content_wire_type() {
let frame = json_to_frame(
"MessageEdit",
json!({
@ -1093,14 +1131,14 @@ mod tests {
assert_eq!(
frame
.get_data(DataType::Content)
.get_data(DataType::AppContent)
.and_then(DataValue::as_str),
Some("ciphertext")
);
}
#[test]
fn nested_json_content_uses_content_wire_type() {
fn nested_json_content_uses_app_content_wire_type() {
let frame = json_to_frame(
"MessageEdit",
json!({
@ -1112,24 +1150,35 @@ mod tests {
let message = frame.get_data(DataType::Message).unwrap();
assert_eq!(
container_value_by_name(message, "Content").and_then(DataValue::as_str),
container_value(message, DataType::AppContent).and_then(DataValue::as_str),
Some("ciphertext")
);
}
#[test]
fn content_is_exposed_to_frontend() {
fn app_content_is_exposed_as_content_to_frontend() {
let frame = CommunicationValue::new(CommunicationType::MessageEditLive)
.with_id(1)
.add_typed_default(DataType::Content, DataValue::Str("ciphertext".into()));
.add_typed_default(DataType::AppContent, DataValue::Str("ciphertext".into()));
let json = frame_to_json(&frame).unwrap();
assert_eq!(json["data"]["Content"], "ciphertext");
assert!(json["data"].get("AppContent").is_none());
}
#[test]
fn translated_field_collisions_are_rejected() {
assert!(json_to_frame(
"MessageEdit",
json!({ "Content": "a", "AppContent": "b" }),
1,
)
.is_err());
}
fn valid_initial_state() -> CommunicationValue {
CommunicationValue::new(communication_type("ClientStateSync").unwrap())
CommunicationValue::new(CommunicationType::ClientStateSync)
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))
@ -1146,7 +1195,7 @@ mod tests {
let (state, ack) = prepare_initial_state_ack(&valid_initial_state(), &ids).unwrap();
assert_eq!(state["SyncMode"], "full");
assert_eq!(ack.get_type_name(), Some("ClientStateAck"));
assert!(ack.is_type(CommunicationType::ClientStateAck));
assert_eq!(ack.id(), Some(1));
}
@ -1167,7 +1216,7 @@ mod tests {
#[test]
fn malformed_nested_initial_state_does_not_prepare_ack() {
let ids = RequestIdAllocator::new();
let malformed = CommunicationValue::new(communication_type("ClientStateSync").unwrap())
let malformed = CommunicationValue::new(CommunicationType::ClientStateSync)
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))

View file

@ -21,7 +21,8 @@ export function removeMissingContactSnapshots<T extends { UserId: number }>(
}
export default function CacheSync() {
const { addInterceptor, contextReady, freshContacts, subscribe } = useMTP();
const { addInterceptor, contextReady, freshContacts, subscribePush } =
useMTP();
const { load } = useStorage();
const [accountId, setAccountId] = useState(0);
const queueRef = useRef(Promise.resolve());
@ -320,19 +321,10 @@ export default function CacheSync() {
useEffect(() => {
if (!accountId || !contextReady) return;
const handleMessage = (message: ProtocolMessage) => {
return subscribePush((message) => {
void enqueue(() => synchronizePush(message));
};
const unsubscribers = [
subscribe("GetStates", handleMessage),
subscribe("MessageLive", handleMessage),
subscribe("MessageEditLive", handleMessage),
subscribe("MessageDeleteLive", handleMessage),
subscribe("MessageState", handleMessage),
subscribe("MessageReactionLive", handleMessage),
];
return () => unsubscribers.forEach((unsubscribe) => unsubscribe());
}, [accountId, contextReady, enqueue, subscribe, synchronizePush]);
});
}, [accountId, contextReady, enqueue, subscribePush, synchronizePush]);
return null;
}

View file

@ -1223,7 +1223,7 @@ export const useCall = create<{
export function useInitializeCall() {
const navigate = useNavigate();
const location = useLocation();
const { send, subscribe } = useMTP();
const { send, subscribePush } = useMTP();
const { load } = useStorage();
const { insertCall } = useSession();
const { get } = useUser();
@ -1313,9 +1313,14 @@ export function useInitializeCall() {
// listen to call invites
useEffect(() => {
return subscribe("CallInvite", async ({ data }) => {
const { CallId, CallSecret, SenderId } = data;
if (!CallId || !CallSecret || !SenderId) return;
return subscribePush(async (message) => {
if (message.type !== "CallInvite") return;
const { CallId, CallSecret, SenderId } = message.data as {
CallId: string;
CallSecret: ProtocolCallSecret;
SenderId: number;
};
if (SenderId === Number(await load("user_id"))) {
return;
@ -1332,7 +1337,7 @@ export function useInitializeCall() {
SenderId,
);
});
}, [load, subscribe, showCallingScreen]);
}, [load, subscribePush, showCallingScreen]);
// get callId from url
useEffect(() => {

View file

@ -226,7 +226,7 @@ export async function fetchReplyMessage({
export default function Provider({ children }: { children: ReactNode }) {
const { load } = useStorage();
const { send, subscribe } = useMTP();
const { send, subscribePush } = useMTP();
const { get: getUser } = useUser();
const { moveUserIdToTop } = useSession();
@ -912,47 +912,124 @@ export default function Provider({ children }: { children: ReactNode }) {
// Get live updates for message states
useEffect(() => {
const unsubscribeEdit = subscribe("MessageEditLive", ({ data }) => {
if (!currentChatSecret) return;
if (data.ChatPartnerId !== userIdValue) {
log(
3,
"chat",
"yellow",
"Cancel message edit update due to user ID mismatch",
{
expected: userIdValue,
received: data.ChatPartnerId,
},
return subscribePush((message) => {
if (message.type === "MessageEditLive") {
if (!currentChatSecret) return;
const rawData = message.data as {
ChatPartnerId: unknown;
SendTime: unknown;
Content: string;
};
const chatPartnerId = Number(rawData.ChatPartnerId);
const sendTime = Number(rawData.SendTime);
if (!Number.isFinite(chatPartnerId) || !Number.isFinite(sendTime)) {
log(
3,
"chat",
"yellow",
"Cancel message edit update due to invalid data",
);
return;
}
if (chatPartnerId !== userIdValue) {
log(
3,
"chat",
"yellow",
"Cancel message edit update due to user ID mismatch",
{
expected: userIdValue,
received: chatPartnerId,
},
);
return;
}
void decryptChatText(currentChatSecret, rawData.Content)
.then((content) => {
editMessage(sendTime, { Content: content, Edited: true });
})
.catch((err) => {
log(1, "chat", "red", "Failed to decrypt message edit", err, {
SendTime: sendTime,
});
});
return;
}
if (message.type === "MessageReactionLive") {
const rawData = message.data as {
ChatPartnerId: unknown;
SendTime: unknown;
Reaction: string;
SenderId: unknown;
Accepted: boolean;
};
const chatPartnerId = Number(rawData.ChatPartnerId);
const sendTime = Number(rawData.SendTime);
const senderId = Number(rawData.SenderId);
if (
chatPartnerId !== userIdValue ||
!Number.isFinite(sendTime) ||
!Number.isFinite(senderId)
) {
return;
}
applyLiveReaction(
sendTime,
rawData.Reaction,
senderId,
rawData.Accepted,
);
return;
}
void decryptChatText(currentChatSecret, data.Content)
.then((content) => {
editMessage(data.SendTime, { Content: content, Edited: true });
})
.catch((err) => {
log(1, "chat", "red", "Failed to decrypt message edit", err, {
SendTime: data.SendTime,
});
});
});
const unsubscribeReaction = subscribe("MessageReactionLive", ({ data }) => {
if (data.ChatPartnerId !== userIdValue) return;
applyLiveReaction(
data.SendTime,
data.Reaction,
data.SenderId,
data.Accepted,
);
});
const unsubscribeDelete = subscribe("MessageDeleteLive", ({ data }) => {
if (data.ChatPartnerId !== userIdValue) return;
removeMessage(data.SendTime);
});
const unsubscribeState = subscribe("MessageState", ({ data }) => {
if (data.ChatPartnerId !== userIdValue) {
if (message.type === "MessageDeleteLive") {
const data = message.data as {
ChatPartnerId: number;
SendTime: number;
};
if (data.ChatPartnerId !== userIdValue) return;
removeMessage(data.SendTime);
return;
}
if (message.type !== "MessageState") return;
const rawData = message.data as {
ChatPartnerId: unknown;
SendTime: unknown;
MessageState: RawMessage["MessageState"];
};
const nextState = {
ChatPartnerId: Number(rawData.ChatPartnerId),
SendTime: Number(rawData.SendTime),
MessageState: rawData.MessageState,
};
if (
!Number.isFinite(nextState.ChatPartnerId) ||
!Number.isFinite(nextState.SendTime)
) {
log(
3,
"chat",
"yellow",
"Cancel message state update due to invalid data",
);
return;
}
if (nextState.ChatPartnerId !== userIdValue) {
log(
3,
"chat",
@ -960,27 +1037,22 @@ export default function Provider({ children }: { children: ReactNode }) {
"Cancel message state update due to user ID mismatch",
{
expected: userIdValue,
received: data.ChatPartnerId,
received: nextState.ChatPartnerId,
},
);
return;
}
editMessage(data.SendTime, {
MessageState: data.MessageState,
editMessage(nextState.SendTime, {
MessageState: nextState.MessageState,
});
});
return () => {
unsubscribeEdit();
unsubscribeReaction();
unsubscribeDelete();
unsubscribeState();
};
}, [
currentChatSecret,
applyLiveReaction,
editMessage,
removeMessage,
subscribe,
subscribePush,
userIdValue,
]);

View file

@ -159,7 +159,7 @@ function CopyableCode({
* @param input Parameter input.
* @returns InlineNode[].
*/
function parseInlineNodes(input: string): InlineNode[] {
export function parseInlineNodes(input: string): InlineNode[] {
const nodes: InlineNode[] = [];
let cursor = 0;
@ -205,7 +205,7 @@ function parseInlineNodes(input: string): InlineNode[] {
return nodes;
}
function parseEmojiText(input: string): InlineNode[] {
export function parseEmojiText(input: string): InlineNode[] {
const nodes: InlineNode[] = [];
let cursor = 0;

View file

@ -9,16 +9,19 @@
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src --ext .ts,.tsx",
"test": "vitest run --passWithNoTests",
"test": "vitest run",
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@methanium/ui": "*",
"@tauri-apps/api": "^2.11.1",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"mtp": "*",
"react": "^19.2.8"
"react": "^19.2.8",
"react-dom": "^19.2.8",
"zod": "^4.4.3"
},
"devDependencies": {
"eslint": "^10.8.0"

View file

@ -1,529 +0,0 @@
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,
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 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,
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,
subscribe,
addInterceptor,
readyState,
identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady,
loadingDescription,
}}
>
{props.children}
</MTPContext.Provider>
);
}

View file

@ -0,0 +1,220 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { RequestIdAllocator } from "./requestIds";
vi.mock("@methanium/ui", () => ({
toast: {
dismiss: vi.fn(),
error: vi.fn(),
loading: vi.fn(),
},
}));
vi.mock("@tensamin/shared/log", () => ({ log: vi.fn() }));
vi.mock("@tensamin/storage/context", () => ({
useStorage: () => ({ load: vi.fn() }),
}));
const { completeInitialSynchronization, isPushType, validateResponse } =
await import("./context");
const validState = {
SessionId: 7,
VersionNumber: 2,
CacheSchemaVersion: 0,
SyncMode: "full",
Contacts: [],
Communities: [],
Calls: [],
Messages: [],
DeletedMessageIds: [],
DeletedContactIds: [],
};
function mockInitialSyncClient(
state: { type: string; data: unknown } = {
type: "ClientStateSync",
data: validState,
},
acknowledgement: unknown = { type: "ClientStateAck", data: {} },
) {
const handlers = new Map<string, (message: never) => void>();
const request = vi.fn().mockResolvedValue(acknowledgement);
const client = {
auth: vi.fn(async () => {
handlers.get(state.type)?.(state as never);
}),
subscribe: vi.fn((type: string, handler: (message: never) => void) => {
handlers.set(type, handler);
return () => handlers.delete(type);
}),
request,
disconnect: vi.fn(),
} as unknown as Parameters<typeof completeInitialSynchronization>[0];
return { client, handlers, request };
}
afterEach(() => {
vi.useRealTimers();
});
describe("MTP protocol dispatch", () => {
it("preserves protocol errors for the request layer", () => {
const error = validateResponse("GetStates", {
id: 12,
type: "ErrorInternal",
data: { ErrorType: "temporary" },
});
expect(error.type).toBe("ErrorInternal");
expect(error.id).toBe(12);
});
it("recognizes initial and live presence pushes", () => {
expect(isPushType("GetStates")).toBe(true);
expect(isPushType("ClientChanged")).toBe(true);
expect(isPushType("UnknownMessage")).toBe(false);
});
});
describe("browser initial synchronization", () => {
it("validates state, sends a nonzero acknowledgement, then resolves", async () => {
const { client, request } = mockInitialSyncClient();
const state = await completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
);
expect(state.data).toEqual(validState);
expect(request).toHaveBeenCalledWith(
"ClientStateAck",
{ SessionId: 7, VersionNumber: 2 },
{ id: 1 },
);
});
it("does not acknowledge malformed state", async () => {
const { client, request } = mockInitialSyncClient({
type: "ClientStateSync",
data: { ...validState, SyncMode: "invalid" },
});
await expect(
completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
),
).rejects.toThrow("Response validation failed");
expect(request).not.toHaveBeenCalled();
});
it("rejects ErrorNoIota during initial synchronization", async () => {
const { client, request } = mockInitialSyncClient({
type: "ErrorNoIota",
data: {},
});
await expect(
completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
),
).rejects.toThrow("No Iota is currently connected");
expect(request).not.toHaveBeenCalled();
});
it("rejects an acknowledgement protocol error", async () => {
const { client } = mockInitialSyncClient(undefined, {
type: "ErrorInvalidData",
data: {},
});
await expect(
completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
),
).rejects.toThrow("State acknowledgement failed: ErrorInvalidData");
});
it("times out a missing acknowledgement", async () => {
vi.useFakeTimers();
const { client } = mockInitialSyncClient();
vi.mocked(client.request).mockReturnValue(new Promise(() => {}));
const result = completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
100,
10,
);
const assertion = expect(result).rejects.toThrow(
"State acknowledgement timed out",
);
await vi.advanceTimersByTimeAsync(10);
await assertion;
});
it("stops immediately when the connection attempt is cancelled", async () => {
const { client } = mockInitialSyncClient();
vi.mocked(client.auth).mockImplementation(() => new Promise(() => {}));
const controller = new AbortController();
const result = completeInitialSynchronization(
client,
new RequestIdAllocator(),
controller.signal,
);
controller.abort(new Error("MTP connection lost"));
await expect(result).rejects.toThrow("MTP connection lost");
});
it("times out authentication while waiting for initial state", async () => {
vi.useFakeTimers();
const { client, handlers } = mockInitialSyncClient();
vi.mocked(client.auth).mockImplementation(() => {
handlers.get("ClientStateSync")?.({
type: "ClientStateSync",
data: validState,
} as never);
return new Promise(() => {});
});
const result = completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
100,
10,
);
const assertion = expect(result).rejects.toThrow(
"MTP authentication timed out",
);
await vi.advanceTimersByTimeAsync(100);
await assertion;
});
it("uses a fresh request ID namespace for each connection", async () => {
const first = mockInitialSyncClient();
const second = mockInitialSyncClient();
await completeInitialSynchronization(
first.client,
new RequestIdAllocator(),
new AbortController().signal,
);
await completeInitialSynchronization(
second.client,
new RequestIdAllocator(),
new AbortController().signal,
);
expect(first.request.mock.calls[0]?.[2]).toEqual({ id: 1 });
expect(second.request.mock.calls[0]?.[2]).toEqual({ id: 1 });
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,9 @@
export { Provider, useMTP } from "./context";
export { RequestIdAllocator } from "./requestIds";
export type {
BoundSendFn,
MTPExchange,
MTPInterceptor,
PushHandler,
ProtocolMessage,
} from "./mtpContext";
} from "./context";

View file

@ -1,159 +0,0 @@
import { createContext, useCallback, useRef } from "react";
import type {
MTPRequestFunction,
MTPResponseFrame,
MTPSubscriptionFunction,
} from "mtp";
import {
mtp as mtpSchemas,
type Calls,
type Communities,
type Contacts,
} from "@tensamin/shared/data";
import { log } from "@tensamin/shared/log";
export type ProtocolMessage<
Type extends keyof typeof mtpSchemas & string = keyof typeof mtpSchemas &
string,
> = MTPResponseFrame<typeof mtpSchemas, Type>;
export type BoundSendFn = MTPRequestFunction<typeof mtpSchemas>;
export type MTPExchange = {
type: keyof typeof mtpSchemas & string;
data: unknown;
response: ProtocolMessage;
};
export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
export type MTPContextType = {
send: BoundSendFn;
subscribe: MTPSubscriptionFunction<typeof mtpSchemas>;
addInterceptor: (interceptor: MTPInterceptor) => () => void;
readyState: number;
identified: boolean;
freshContacts: Contacts;
freshCommunities: Communities;
freshCalls: Calls;
contextReady: boolean;
loadingDescription: string;
};
export const MTPContext = createContext<MTPContextType | undefined>(undefined);
export function removeMissingContacts(
contacts: Contacts,
message: ProtocolMessage<"GetStates">,
): Contacts {
const missing = new Set(message.data.MissingUserIds ?? []);
return contacts.filter((contact) => !missing.has(contact.UserId));
}
export function useMessageHandlers() {
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const subscriptionHandlersRef = useRef(
new Map<string, Set<(message: ProtocolMessage) => void | Promise<void>>>(),
);
const transportRef = useRef<{
subscribe: MTPSubscriptionFunction<typeof mtpSchemas>;
} | null>(null);
const transportGenerationRef = useRef(0);
const transportUnsubscribersRef = useRef(new Map<string, () => void>());
const lastInitialStateRef = useRef<ProtocolMessage<"GetStates"> | null>(null);
const attachType = useCallback(
<Type extends keyof typeof mtpSchemas & string>(type: Type) => {
const transport = transportRef.current;
if (!transport || transportUnsubscribersRef.current.has(type)) return;
const generation = transportGenerationRef.current;
const unsubscribe = transport.subscribe(type, (message) => {
if (
transportRef.current !== transport ||
transportGenerationRef.current !== generation
)
return;
if (type === "GetStates") {
lastInitialStateRef.current = message as ProtocolMessage<"GetStates">;
}
for (const handler of [
...(subscriptionHandlersRef.current.get(type) ?? []),
]) {
void Promise.resolve(handler(message as ProtocolMessage)).catch(
(error) => {
log(1, "mtp", "red", "Subscription handler failed", error, {
type,
});
},
);
}
});
transportUnsubscribersRef.current.set(type, unsubscribe);
},
[],
);
const attachSubscriptions = useCallback(
(transport: { subscribe: MTPSubscriptionFunction<typeof mtpSchemas> }) => {
for (const unsubscribe of transportUnsubscribersRef.current.values()) {
unsubscribe();
}
transportUnsubscribersRef.current.clear();
transportRef.current = transport;
const generation = ++transportGenerationRef.current;
for (const type of subscriptionHandlersRef.current.keys()) {
attachType(type as keyof typeof mtpSchemas & string);
}
return () => {
if (
transportRef.current !== transport ||
transportGenerationRef.current !== generation
)
return;
transportRef.current = null;
transportGenerationRef.current += 1;
for (const unsubscribe of transportUnsubscribersRef.current.values()) {
unsubscribe();
}
transportUnsubscribersRef.current.clear();
};
},
[attachType],
);
const subscribe = useCallback<MTPSubscriptionFunction<typeof mtpSchemas>>(
(type, handler) => {
const handlers = subscriptionHandlersRef.current.get(type) ?? new Set();
const untypedHandler = handler as (
message: ProtocolMessage,
) => void | Promise<void>;
handlers.add(untypedHandler);
subscriptionHandlersRef.current.set(type, handlers);
attachType(type);
const initialState = lastInitialStateRef.current;
if (type === "GetStates" && initialState) {
void Promise.resolve(untypedHandler(initialState)).catch(
() => undefined,
);
}
return () => {
handlers.delete(untypedHandler);
if (handlers.size !== 0) return;
subscriptionHandlersRef.current.delete(type);
transportUnsubscribersRef.current.get(type)?.();
transportUnsubscribersRef.current.delete(type);
};
},
[attachType],
);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
return {
addInterceptor,
attachSubscriptions,
interceptorsRef,
subscribe,
};
}

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { fromWireData, toWireData } from "./protocolFields";
describe("MTP protocol field translation", () => {
it("maps application message fields to MTP wire fields", () => {
expect(
toWireData({
Messages: [{ Content: "abc", MessageId: 4 }],
}),
).toEqual({
Messages: [{ AppContent: "abc", AppMessageId: 4 }],
});
});
it("maps MTP wire fields back to application fields", () => {
expect(
fromWireData({
AppCreatedAt: 123,
Message: { AppContent: "abc", AppMessageId: 4 },
}),
).toEqual({
CreatedAt: 123,
Message: { Content: "abc", MessageId: 4 },
});
});
it("preserves byte arrays and unrelated fields", () => {
const bytes = new Uint8Array([1, 2, 3]);
const translated = toWireData({ Payload: bytes, Other: "value" }) as {
Payload: Uint8Array;
Other: string;
};
expect(translated.Payload).toBe(bytes);
expect(translated.Other).toBe("value");
});
it("rejects field mapping collisions", () => {
expect(() => toWireData({ Content: "a", AppContent: "b" })).toThrow(
"MTP field translation collision",
);
});
});

View file

@ -0,0 +1,58 @@
const APPLICATION_TO_WIRE_FIELDS = {
Content: "AppContent",
CreatedAt: "AppCreatedAt",
MessageId: "AppMessageId",
} as const;
const WIRE_TO_APPLICATION_FIELDS = {
AppContent: "Content",
AppCreatedAt: "CreatedAt",
AppMessageId: "MessageId",
} as const;
function mapProtocolFields(
value: unknown,
fieldMap: Readonly<Record<string, string>>,
): unknown {
if (
value === null ||
typeof value !== "object" ||
value instanceof Uint8Array
) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => mapProtocolFields(item, fieldMap));
}
const source = value as Record<string, unknown>;
const target: Record<string, unknown> = {};
for (const [key, child] of Object.entries(source)) {
const mappedKey = fieldMap[key] ?? key;
if (mappedKey in target) {
throw new Error(`MTP field translation collision for ${mappedKey}`);
}
target[mappedKey] = mapProtocolFields(child, fieldMap);
}
return target;
}
export function toWireData(value: unknown): unknown {
return mapProtocolFields(value, APPLICATION_TO_WIRE_FIELDS);
}
export function fromWireData(value: unknown): unknown {
return mapProtocolFields(value, WIRE_TO_APPLICATION_FIELDS);
}
export function fromWireMessage<T extends { data: unknown }>(message: T): T {
return {
...message,
data: fromWireData(message.data),
};
}

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { RequestIdAllocator } from "./requestIds";
describe("MTP request ID allocation", () => {
it("allocates nonzero request IDs monotonically", () => {
const ids = new RequestIdAllocator();
expect(ids.allocate()).toBe(1);
expect(ids.allocate()).toBe(2);
expect(new RequestIdAllocator().allocate()).toBe(1);
});
it("does not wrap exhausted request IDs", () => {
const ids = new RequestIdAllocator(0x1_0000_0000);
expect(() => ids.allocate()).toThrow("MTP request ID space exhausted");
});
it("rejects invalid allocator states", () => {
expect(() => new RequestIdAllocator(0)).toThrow(
"invalid MTP request ID allocator state",
);
});
});

View file

@ -0,0 +1,24 @@
const MAX_MTP_REQUEST_ID = 0xffff_ffff;
export class RequestIdAllocator {
#next: number;
constructor(next = 1) {
if (
!Number.isSafeInteger(next) ||
next <= 0 ||
next > MAX_MTP_REQUEST_ID + 1
) {
throw new RangeError("invalid MTP request ID allocator state");
}
this.#next = next;
}
allocate(): number {
if (this.#next > MAX_MTP_REQUEST_ID) {
throw new Error("MTP request ID space exhausted for this connection");
}
return this.#next++;
}
}

View file

@ -1,258 +0,0 @@
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import {
ConnectionState,
MTPProxyConnection,
type MTPFrame,
type MTPProxyAdapter,
} from "mtp";
import {
mtp as mtpSchemas,
type Calls,
type Communities,
type Contacts,
} from "@tensamin/shared/data";
import { log } from "@tensamin/shared/log";
import {
type BoundSendFn,
MTPContext,
type ProtocolMessage,
removeMissingContacts,
useMessageHandlers,
} from "./mtpContext";
type NativeSnapshot = {
generation: number;
readyState: number;
identified: boolean;
state?: unknown;
error?: string;
};
function createTauriAdapter() {
const subscriptions = new Map<string, Set<(message: MTPFrame) => void>>();
const adapter: MTPProxyAdapter = {
request: (type, data) =>
invoke<MTPFrame>("mtp_request", { typeName: type, data }),
subscribe(type, handler) {
const handlers = subscriptions.get(type) ?? new Set();
handlers.add(handler);
subscriptions.set(type, handlers);
return () => {
handlers.delete(handler);
if (handlers.size === 0) subscriptions.delete(type);
};
},
};
return {
adapter,
dispatch(message: MTPFrame) {
for (const handler of subscriptions.get(message.type) ?? [])
handler(message);
},
};
}
export function TauriProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const [snapshot, setSnapshot] = useState<NativeSnapshot>({
generation: 0,
readyState: ConnectionState.Disconnected,
identified: false,
});
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const generationRef = useRef(0);
const { addInterceptor, attachSubscriptions, interceptorsRef, subscribe } =
useMessageHandlers();
const [{ bridge, connection }] = useState(() => {
const bridge = createTauriAdapter();
return {
bridge,
connection: new MTPProxyConnection(bridge.adapter, {
schemas: mtpSchemas,
throwProtocolErrors: true,
onValidationError: (error) => {
log(1, "mtp", "red", "Failed to validate native MTP message", error);
},
}),
};
});
const applySnapshot = useCallback(async (next: NativeSnapshot) => {
if (next.generation < generationRef.current) return;
generationRef.current = next.generation;
if (next.error)
log(0, "android", "orange", "MTP connection failed", next.error);
if (!next.identified) {
setSnapshot(next);
return;
}
if (next.state === undefined) {
setSnapshot({
...next,
identified: false,
error: "Native MTP connection omitted initial state",
});
return;
}
try {
const state = await mtpSchemas.ClientStateSync.response.parseAsync(
next.state,
);
setFreshContacts(state.Contacts);
setFreshCommunities(state.Communities);
setFreshCalls(state.Calls);
setSnapshot(next);
} catch (error) {
log(0, "mtp", "red", "Invalid native MTP state", error);
setSnapshot({
...next,
identified: false,
error: "Invalid ClientStateSync payload",
});
}
}, []);
const dispatchMessage = useCallback(
(message: MTPFrame) => {
bridge.dispatch(message);
},
[bridge],
);
useEffect(() => {
return attachSubscriptions(connection);
}, [attachSubscriptions, connection]);
useEffect(
() =>
subscribe("GetStates", (message) => {
setFreshContacts((contacts) =>
removeMissingContacts(contacts, message),
);
}),
[subscribe],
);
useEffect(() => {
if (props.blockConnection) return;
let disposed = false;
let unlisten: UnlistenFn | undefined;
void (async () => {
try {
const nextUnlisten = await listen<
| { kind: "state"; snapshot: NativeSnapshot }
| { kind: "message"; generation: number; message: MTPFrame }
| { kind: "log"; level: number; message: string; details?: unknown }
>("mtp://event", ({ payload }) => {
if (disposed) return;
if (payload.kind === "state") {
void applySnapshot(payload.snapshot);
} else if (payload.kind === "message") {
if (payload.generation === generationRef.current) {
dispatchMessage(payload.message);
}
} else {
log(
payload.level,
"android",
"orange",
payload.message,
payload.details,
);
}
});
if (disposed) nextUnlisten();
else unlisten = nextUnlisten;
} catch (error) {
log(0, "mtp", "red", "Failed to subscribe to native MTP events", error);
}
try {
const current = await invoke<NativeSnapshot>("mtp_status");
if (!disposed) await applySnapshot(current);
} catch (error) {
log(0, "mtp", "red", "Failed to load native MTP status", error);
}
})();
return () => {
disposed = true;
unlisten?.();
};
}, [applySnapshot, dispatchMessage, props.blockConnection]);
useEffect(() => {
if (props.blockConnection) return;
const updateVisibility = () => {
void invoke("mtp_set_ui_visible", {
visible: document.visibilityState === "visible" && document.hasFocus(),
});
};
updateVisibility();
document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("focus", updateVisibility);
window.addEventListener("blur", updateVisibility);
return () => {
document.removeEventListener("visibilitychange", updateVisibility);
window.removeEventListener("focus", updateVisibility);
window.removeEventListener("blur", updateVisibility);
void invoke("mtp_set_ui_visible", { visible: false });
};
}, [props.blockConnection]);
const send = useCallback<BoundSendFn>(
async (type, data, options) => {
const response = await connection.request(type, data, options);
if (response.type === "GetStates") {
setFreshContacts((contacts) =>
removeMissingContacts(
contacts,
response as ProtocolMessage<"GetStates">,
),
);
}
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;
},
[connection, interceptorsRef],
);
const connected = snapshot.readyState === ConnectionState.Connected;
return (
<MTPContext.Provider
value={{
send,
subscribe,
addInterceptor,
readyState: snapshot.readyState,
identified: snapshot.identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady: connected && snapshot.identified,
loadingDescription: connected
? "Waiting for authenticated session"
: "Establishing native transport channel",
}}
>
{props.children}
</MTPContext.Provider>
);
}

View file

@ -16,6 +16,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
import { decryptChatText } from "@tensamin/crypto/chatSecret";
import { log } from "@tensamin/shared/log";
import { playSound } from "@tensamin/shared/sounds";
import { type RawMessage } from "@tensamin/chat/values";
export const context = createContext<contextType | undefined>(undefined);
@ -29,7 +30,7 @@ async function requestNotificationPermission() {
}
export default function Provider(props: { children: React.ReactNode }) {
const { subscribe, send } = useMTP();
const { subscribePush, send } = useMTP();
const { load } = useStorage();
const { get } = useUser();
const { addLiveMessage, chatSecret, getChatSecret, userId } = useChat();
@ -38,140 +39,148 @@ export default function Provider(props: { children: React.ReactNode }) {
const location = useLocation();
useEffect(() => {
return subscribe("MessageLive", async ({ data }) => {
if (!data.SenderId) return;
return subscribePush(async (message) => {
if (message.type === "MessageLive") {
const data = message.data as {
Message?: RawMessage;
SenderId?: number;
};
const isCurrentChat =
location.pathname === "/chat" && userId === data.SenderId;
const appFocused =
document.hasFocus() && document.visibilityState === "visible";
const shouldAlert = !isCurrentChat || !appFocused;
if (!data.SenderId) return;
const messageSecret =
isCurrentChat && chatSecret
? chatSecret
: await getChatSecret(data.SenderId);
const isCurrentChat =
location.pathname === "/chat" && userId === data.SenderId;
const appFocused =
document.hasFocus() && document.visibilityState === "visible";
const shouldAlert = !isCurrentChat || !appFocused;
if (!data.Message || !messageSecret) return;
if (shouldAlert) playSound("message");
const messageSecret =
isCurrentChat && chatSecret
? chatSecret
: await getChatSecret(data.SenderId);
void decryptChatText(messageSecret, data.Message.Content)
.catch((err) => {
log(1, "chat", "red", "Failed to decrypt live message", err, {
SendTime: data.Message?.SendTime,
});
return null;
})
.then(async (content) => {
if (!data.Message || !content || !data.SenderId) return;
if (!data.Message || !messageSecret) return;
if (shouldAlert) playSound("message");
if (isCurrentChat) {
addLiveMessage({
...data.Message,
Content: content ?? "Failed to decrypt message",
decryptionFailed: content === null,
void decryptChatText(messageSecret, data.Message.Content)
.catch((err) => {
log(1, "chat", "red", "Failed to decrypt live message", err, {
SendTime: data.Message?.SendTime,
});
}
return null;
})
.then(async (content) => {
if (!data.Message || !content || !data.SenderId) return;
if (!shouldAlert) return;
if (!isCurrentChat) {
// todo: add notification symbol to conversation cards (incl. message start)
moveUserIdToTop(data.SenderId);
if (await load("settings.receive_confirmations")) {
void send("MessageState", {
MessageState: "received",
if (isCurrentChat) {
addLiveMessage({
...data.Message,
Content: content ?? "Failed to decrypt message",
decryptionFailed: content === null,
});
}
}
const user = await get(data.SenderId, [
"UserId",
"Display",
"Avatar",
]);
if (!shouldAlert) return;
if (isTauri()) {
if (!appFocused) return;
const permissionGranted =
(await isTauriNotificationPermissionGranted()) ||
(await requestTauriNotificationPermission()) === "granted";
if (!isCurrentChat) {
// todo: add notification symbol to conversation cards (incl. message start)
moveUserIdToTop(data.SenderId);
if (permissionGranted) {
let handledNatively = false;
try {
handledNatively = await invoke<boolean>(
"mtp_post_message_notification",
{
senderId: user.UserId,
sender: user.Display,
body: content,
avatar: user.Avatar,
},
);
} catch (error) {
log(
1,
"notifications",
"red",
"Failed to create native message notification",
error,
);
}
if (!handledNatively) {
sendTauriNotification({ title: user.Display, body: content });
if (await load("settings.receive_confirmations")) {
void send("MessageState", {
MessageState: "received",
});
}
}
} else {
const hasPermissions = await requestNotificationPermission();
if (hasPermissions) {
const options: NotificationOptions = {
body: content,
icon: user.Avatar || "/icons/icon-192.png",
badge: "/icons/notification-badge.png",
tag: `message-${user.UserId}`,
silent: true,
};
if ("serviceWorker" in navigator) {
const registration =
await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.showNotification(user.Display, {
...options,
data: { url: `/chat?id=${user.UserId}` },
});
return;
const user = await get(data.SenderId, [
"UserId",
"Display",
"Avatar",
]);
if (isTauri()) {
if (!appFocused) return;
const permissionGranted =
(await isTauriNotificationPermissionGranted()) ||
(await requestTauriNotificationPermission()) === "granted";
if (permissionGranted) {
let handledNatively = false;
try {
handledNatively = await invoke<boolean>(
"mtp_post_message_notification",
{
senderId: user.UserId,
sender: user.Display,
body: content,
avatar: user.Avatar,
},
);
} catch (error) {
log(
1,
"notifications",
"red",
"Failed to create native message notification",
error,
);
}
if (!handledNatively) {
sendTauriNotification({ title: user.Display, body: content });
}
}
const notification = new Notification(user.Display, options);
notification.onclick = () => {
window.focus();
navigate({
to: `/chat?id=${user.UserId}`,
});
notification.close();
};
} else {
sonnerToast(user.Display, {
classNames: {
content: "pl-4",
},
description: content,
icon: (
<Avatar>
<AvatarImage src={user.Avatar} />
<AvatarFallback>
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
),
});
const hasPermissions = await requestNotificationPermission();
if (hasPermissions) {
const options: NotificationOptions = {
body: content,
icon: user.Avatar || "/icons/icon-192.png",
badge: "/icons/notification-badge.png",
tag: `message-${user.UserId}`,
silent: true,
};
if ("serviceWorker" in navigator) {
const registration =
await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.showNotification(user.Display, {
...options,
data: { url: `/chat?id=${user.UserId}` },
});
return;
}
}
const notification = new Notification(user.Display, options);
notification.onclick = () => {
window.focus();
navigate({
to: `/chat?id=${user.UserId}`,
});
notification.close();
};
} else {
sonnerToast(user.Display, {
classNames: {
content: "pl-4",
},
description: content,
icon: (
<Avatar>
<AvatarImage src={user.Avatar} />
<AvatarFallback>
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
),
});
}
}
}
});
});
return;
}
});
}, [
addLiveMessage,
@ -182,7 +191,7 @@ export default function Provider(props: { children: React.ReactNode }) {
navigate,
send,
moveUserIdToTop,
subscribe,
subscribePush,
getChatSecret,
userId,
]);

View file

@ -9,12 +9,13 @@ import {
useState,
useSyncExternalStore,
} from "react";
import { useMTP } from "@tensamin/mtp";
import { useMTP, type ProtocolMessage } from "@tensamin/mtp";
import {
clientUserStateSchema,
mtp as schemas,
publicUserStateSchema,
userStateEntrySchema,
} from "@tensamin/shared/data";
import type z from "zod";
import { createCache } from "@tensamin/cache";
@ -88,7 +89,7 @@ export default function UserProvider(props: { children: ReactNode }) {
);
const revisionsRef = useRef(new Map<number, Map<UserField, number>>());
const { send, subscribe: subscribeMTP } = useMTP();
const { send, subscribePush } = useMTP();
const { load } = useStorage();
const { contacts } = useSession();
const [accountId, setAccountId] = useState<number | null>(null);
@ -173,6 +174,39 @@ export default function UserProvider(props: { children: ReactNode }) {
[publishUser],
);
const handleStatePush = useCallback(
async (message: ProtocolMessage) => {
if (!accountId) return;
const data = message.data as Record<string, unknown>;
if (message.type === "GetStates") {
if (Array.isArray(data.MissingUserIds)) {
for (const userId of data.MissingUserIds) {
if (typeof userId === "number") removePresence(userId);
}
}
if (!Array.isArray(data.UserStates)) return;
for (const entry of data.UserStates) {
const parsed = userStateEntrySchema.safeParse(entry);
if (!parsed.success) continue;
if (parsed.data.UserId === accountId) continue;
initialStatesRef.current.set(
parsed.data.UserId,
parsed.data.UserState,
);
if (applyUserState(parsed.data.UserId, parsed.data.UserState)) {
initialStatesRef.current.delete(parsed.data.UserId);
}
}
return;
}
if (message.type !== "ClientChanged") return;
const parsed = schemas.ClientChanged.response.safeParse(data);
if (!parsed.success) return;
applyUserState(parsed.data.UserId, parsed.data.UserState, true);
},
[accountId, applyUserState, removePresence],
);
useEffect(() => {
void load("user_id").then((accountId) => {
accountIdRef.current = accountId;
@ -189,24 +223,8 @@ export default function UserProvider(props: { children: ReactNode }) {
useEffect(() => {
if (!accountId) return;
const unsubscribeStates = subscribeMTP("GetStates", ({ data }) => {
for (const userId of data.MissingUserIds ?? []) removePresence(userId);
for (const entry of data.UserStates) {
if (!entry || entry.UserId === accountId) continue;
initialStatesRef.current.set(entry.UserId, entry.UserState);
if (applyUserState(entry.UserId, entry.UserState)) {
initialStatesRef.current.delete(entry.UserId);
}
}
});
const unsubscribeChanged = subscribeMTP("ClientChanged", ({ data }) => {
applyUserState(data.UserId, data.UserState, true);
});
return () => {
unsubscribeStates();
unsubscribeChanged();
};
}, [accountId, applyUserState, removePresence, subscribeMTP]);
return subscribePush(handleStatePush);
}, [accountId, handleStatePush, subscribePush]);
const loadUser = useCallback(
async (userId: number): Promise<User> => {

41
pnpm-lock.yaml generated
View file

@ -6,7 +6,7 @@ settings:
overrides:
'@methanium/ui': https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz
mtp: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
mtp: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
importers:
@ -16,8 +16,8 @@ importers:
specifier: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz
version: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(emojibase@17.0.0)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@7.2.0)(typescript@6.0.3)
mtp:
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
sonner:
specifier: ^2.0.8
version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@ -98,8 +98,8 @@ importers:
specifier: workspace:*
version: link:../../packages/storage
mtp:
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
react:
specifier: ^19.2.8
version: 19.2.8
@ -290,8 +290,8 @@ importers:
specifier: ^17.9.0
version: 17.9.0
mtp:
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
typescript:
specifier: ~6.0.3
version: 6.0.3
@ -356,8 +356,8 @@ importers:
specifier: ^1.29.0
version: 1.30.0(react@19.2.8)
mtp:
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
react:
specifier: ^19.2.8
version: 19.2.8
@ -431,8 +431,8 @@ importers:
packages/crypto:
dependencies:
mtp:
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
react:
specifier: ^19.2.8
version: 19.2.8
@ -506,6 +506,9 @@ importers:
'@tauri-apps/api':
specifier: ^2.11.1
version: 2.11.1
'@tensamin/crypto':
specifier: workspace:*
version: link:../crypto
'@tensamin/shared':
specifier: workspace:*
version: link:../shared
@ -513,11 +516,17 @@ importers:
specifier: workspace:*
version: link:../storage
mtp:
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
react:
specifier: ^19.2.8
version: 19.2.8
react-dom:
specifier: ^19.2.8
version: 19.2.8(react@19.2.8)
zod:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
eslint:
specifier: ^10.8.0
@ -5076,8 +5085,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz:
resolution: {integrity: sha512-MzqeWSaS2lVoiK0coNfY8EPgGNk7QYFS3eRqVYeYBCT0NNm2lQ7T8lLjBuIuMZ6Zh2KAVWhwI2xQIkMdZ7QThA==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz}
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz:
resolution: {integrity: sha512-rI+xskgAp93o9EdBO/b7HnA0PZTQQPTYuF4Snon/hTvayN+x9O40oad4n/ZUF0ahS6grw00MPKPe89Mfe3BHnw==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz}
version: 0.3.0
nanoid@3.3.18:
@ -11328,7 +11337,7 @@ snapshots:
ms@2.1.3: {}
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz:
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz:
dependencies:
yaml: 2.9.0

View file

@ -7,4 +7,4 @@ allowBuilds:
esbuild: true
overrides:
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz"