temp(mtp): temp commit stuff that needs to get a rework

This commit is contained in:
Alois 2026-08-27 19:35:17 +02:00
commit aa34bd962b
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
4 changed files with 124 additions and 219 deletions

View file

@ -14,13 +14,10 @@
"dependencies": { "dependencies": {
"@methanium/ui": "*", "@methanium/ui": "*",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*", "@tensamin/storage": "workspace:*",
"mtp": "*", "mtp": "*",
"react": "^19.2.8", "react": "^19.2.8"
"react-dom": "^19.2.8",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^10.8.0" "eslint": "^10.8.0"

View file

@ -1,21 +0,0 @@
import { describe, expect, it } from "vitest";
import { isPushType, validateResponse } from "./context";
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);
});
});

View file

@ -10,9 +10,18 @@ import {
} from "react"; } from "react";
import { invoke, isTauri } from "@tauri-apps/api/core"; import { invoke, isTauri } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { MTPClient } from "mtp"; import {
import { type z } from "zod"; base64ToBytes,
import { ConnectionState } from "mtp"; ConnectionState,
MTPClient,
MTPProxyConnection,
type MTPFrame,
type MTPKeyMaterialInput,
type MTPProxyAdapter,
type MTPRequestOptions,
type MTPRequestFunction,
type MTPResponseFrame,
} from "mtp";
import createAsyncQueue from "@tensamin/shared/asyncQueue"; import createAsyncQueue from "@tensamin/shared/asyncQueue";
import { toast as sonnerToast } from "@methanium/ui"; import { toast as sonnerToast } from "@methanium/ui";
@ -24,35 +33,15 @@ import {
type MTP as Schemas, type MTP as Schemas,
} from "@tensamin/shared/data"; } from "@tensamin/shared/data";
import { log } from "@tensamin/shared/log"; import { log } from "@tensamin/shared/log";
import { ProtocolError } from "@tensamin/shared/errors";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values"; import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values";
function base64ToUint8Array(b64: string) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
out[i] = bin.charCodeAt(i);
}
return out;
}
export type ProtocolMessage< export type ProtocolMessage<
T extends keyof Schemas & string = keyof Schemas & string, T extends keyof Schemas & string = keyof Schemas & string,
> = { > = MTPResponseFrame<Schemas, T>;
id?: number;
type: T | string;
data: z.infer<Schemas[T]["response"]>;
};
export type BoundSendFn = <T extends keyof Schemas & string>( export type BoundSendFn = MTPRequestFunction<Schemas>;
type: T,
data?: z.infer<Schemas[T]["request"]>,
options?: { id?: number },
) => Promise<ProtocolMessage<T>>;
export type PushHandler = (message: ProtocolMessage) => void | Promise<void>; export type PushHandler = (message: ProtocolMessage) => void | Promise<void>;
@ -68,6 +57,15 @@ const PUSH_TYPES = [
"ErrorNoIota", "ErrorNoIota",
] as const; ] as const;
function encodedPublicKey(value: string): MTPKeyMaterialInput {
const trimmed = value.trim();
const hex = trimmed.replace(/^0x/i, "");
return {
value: /^[0-9a-f]+$/i.test(hex) ? hex : trimmed,
encoding: /^[0-9a-f]+$/i.test(hex) ? "hex" : "base64",
};
}
export function isPushType(type: string): boolean { export function isPushType(type: string): boolean {
return (PUSH_TYPES as readonly string[]).includes(type); return (PUSH_TYPES as readonly string[]).includes(type);
} }
@ -114,53 +112,6 @@ type ContextType = {
const MTPContext = createContext<ContextType | undefined>(undefined); const MTPContext = createContext<ContextType | undefined>(undefined);
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,
};
}
// Zod schema validation
export function validateResponse<T extends keyof Schemas & string>(
type: T,
message: { id?: number; type: string; data: unknown },
): ProtocolMessage<T> {
if (message.type.startsWith("Error")) {
return message as ProtocolMessage<T>;
}
const schema =
schemas[message.type as keyof Schemas & string]?.response ??
schemas[type]?.response;
if (!schema) {
return message as ProtocolMessage<T>;
}
const parsed = schema.safeParse(message.data);
if (!parsed.success) {
throw new Error(
`Response validation failed for ${type}: ${parsed.error.message}`,
);
}
return {
id: message.id,
type: message.type,
data: parsed.data,
} as ProtocolMessage<T>;
}
function useMessageHandlers() { function useMessageHandlers() {
const interceptorsRef = useRef(new Set<MTPInterceptor>()); const interceptorsRef = useRef(new Set<MTPInterceptor>());
const pushHandlersRef = useRef(new Set<PushHandler>()); const pushHandlersRef = useRef(new Set<PushHandler>());
@ -202,9 +153,7 @@ function BrowserProvider(props: {
const [freshContacts, setFreshContacts] = useState<Contacts>([]); const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]); const [freshCalls, setFreshCalls] = useState<Calls>([]);
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>( const clientRef = useRef<MTPClient<Schemas> | null>(null);
null,
);
const { const {
addInterceptor, addInterceptor,
interceptorsRef, interceptorsRef,
@ -221,7 +170,6 @@ function BrowserProvider(props: {
load("omega_url").then(setMtpUrl); load("omega_url").then(setMtpUrl);
}, [load]); }, [load]);
// Validation override functions
const send: BoundSendFn = useMemo( const send: BoundSendFn = useMemo(
() => async (type, data, options) => { () => async (type, data, options) => {
const client = clientRef.current; const client = clientRef.current;
@ -230,24 +178,8 @@ function BrowserProvider(props: {
throw new Error("mtp is not connected"); throw new Error("mtp is not connected");
} }
const message = await client.request( const response = await client.request(type, data, options);
type,
(data ?? {}) as Record<string, unknown>,
options,
);
const response = validateResponse(type, message);
setFreshContacts((contacts) => removeMissingContacts(contacts, response)); setFreshContacts((contacts) => removeMissingContacts(contacts, response));
if (response.type.startsWith("Error")) {
const errorData = response.data as Record<string, unknown>;
throw new ProtocolError({
type: response.type,
requestId: response.id,
errorType:
typeof errorData.ErrorType === "string"
? errorData.ErrorType
: undefined,
});
}
return response; return response;
}, },
[], [],
@ -259,9 +191,7 @@ function BrowserProvider(props: {
return () => {}; return () => {};
} }
return client.subscribe(type, (message) => { return client.subscribe(type, handler);
handler(validateResponse(type, message));
});
}, []); }, []);
// Reconnect stuff // Reconnect stuff
@ -324,7 +254,7 @@ function BrowserProvider(props: {
if (disposed || props.blockConnection) return; if (disposed || props.blockConnection) return;
const generation = ++connectionGeneration; const generation = ++connectionGeneration;
let client: Awaited<ReturnType<typeof MTPClient.create>> | null = null; let client: MTPClient<Schemas> | null = null;
let failed = false; let failed = false;
const cleanup = () => { const cleanup = () => {
client?.disconnect(); client?.disconnect();
@ -398,15 +328,20 @@ function BrowserProvider(props: {
log(2, "mtp", "green", "Connecting to: " + url); log(2, "mtp", "green", "Connecting to: " + url);
client = await MTPClient.create({ client = await MTPClient.create<Schemas>({
url, url,
credentials: { credentials: {
clientId: userId, clientId: userId,
keyring: base64ToUint8Array(keyring), keyring: base64ToBytes(keyring),
}, },
hostPublicKey: omikronPublicKey, hostPublicKey: encodedPublicKey(omikronPublicKey),
descriptor: "client", descriptor: "client",
pings: true, pings: true,
schemas,
throwProtocolErrors: true,
onValidationError: (error) => {
log(1, "mtp", "red", "Failed to validate MTP message", error);
},
logger: (event) => { logger: (event) => {
if (event.type === "state") { if (event.type === "state") {
if (generation !== connectionGeneration) return; if (generation !== connectionGeneration) return;
@ -458,16 +393,7 @@ function BrowserProvider(props: {
clientRef.current = activeClient; clientRef.current = activeClient;
for (const type of PUSH_TYPES) { for (const type of PUSH_TYPES) {
activeClient.subscribe(type, (message) => { activeClient.subscribe(type, (message) => {
let validated: ProtocolMessage; const validated = message as ProtocolMessage;
try {
validated = validateResponse(type, message);
} catch (error) {
log(1, "mtp", "red", "Failed to validate push message", error, {
type,
data: message.data,
});
return;
}
setFreshContacts((contacts) => setFreshContacts((contacts) =>
removeMissingContacts(contacts, validated), removeMissingContacts(contacts, validated),
@ -515,11 +441,7 @@ function BrowserProvider(props: {
"ClientStateSync", "ClientStateSync",
(message) => { (message) => {
cleanupStateSync(); cleanupStateSync();
try { resolve(message);
resolve(validateResponse("ClientStateSync", message));
} catch (error) {
reject(error);
}
}, },
); );
unsubscribeNoIota = activeClient.subscribe("ErrorNoIota", () => { unsubscribeNoIota = activeClient.subscribe("ErrorNoIota", () => {
@ -539,15 +461,10 @@ function BrowserProvider(props: {
); );
} }
const acknowledgement = await activeClient.request("ClientStateAck", { await activeClient.request("ClientStateAck", {
SessionId: finalResponse.data.SessionId, SessionId: finalResponse.data.SessionId,
VersionNumber: finalResponse.data.VersionNumber, VersionNumber: finalResponse.data.VersionNumber,
}); });
if (acknowledgement.type.startsWith("Error")) {
throw new Error(
`State acknowledgement failed: ${acknowledgement.type}`,
);
}
if (disposed || clientRef.current !== activeClient) return; if (disposed || clientRef.current !== activeClient) return;
@ -573,7 +490,7 @@ function BrowserProvider(props: {
"mtp", "mtp",
"red", "red",
`Connection/authentication attempt failed: ${connectErrorMessage}`, `Connection/authentication attempt failed: ${connectErrorMessage}`,
getProtocolErrorDetails(connectError) ?? connectError, connectError,
); );
scheduleReconnect(connectError); scheduleReconnect(connectError);
@ -694,6 +611,39 @@ type NativeSnapshot = {
error?: string; error?: string;
}; };
class TauriMTPAdapter implements MTPProxyAdapter {
readonly #subscriptions = new Map<string, Set<(message: MTPFrame) => void>>();
async request(
type: string,
data: Record<string, unknown>,
options?: MTPRequestOptions,
) {
return await invoke<MTPFrame>("mtp_request", {
typeName: type,
data,
id: options?.id,
});
}
subscribe(type: string, handler: (message: MTPFrame) => void) {
const handlers =
this.#subscriptions.get(type) ?? new Set<(message: MTPFrame) => void>();
handlers.add(handler);
this.#subscriptions.set(type, handlers);
return () => {
handlers.delete(handler);
if (handlers.size === 0) this.#subscriptions.delete(type);
};
}
dispatch(message: MTPFrame) {
for (const handler of this.#subscriptions.get(message.type) ?? []) {
handler(message);
}
}
}
function TauriProvider(props: { function TauriProvider(props: {
children: ReactNode; children: ReactNode;
blockConnection?: boolean; blockConnection?: boolean;
@ -714,8 +664,16 @@ function TauriProvider(props: {
pushHandlersRef, pushHandlersRef,
subscribePush, subscribePush,
} = useMessageHandlers(); } = useMessageHandlers();
const subscriptionsRef = useRef( const [adapter] = useState(() => new TauriMTPAdapter());
new Map<string, Set<(message: ProtocolMessage) => void>>(), const [connection] = useState(
() =>
new MTPProxyConnection<Schemas>(adapter, {
schemas,
throwProtocolErrors: true,
onValidationError: (error) => {
log(1, "mtp", "red", "Failed to validate native MTP message", error);
},
}),
); );
const applySnapshot = useCallback((next: NativeSnapshot) => { const applySnapshot = useCallback((next: NativeSnapshot) => {
@ -739,38 +697,34 @@ function TauriProvider(props: {
const dispatchMessage = useCallback( const dispatchMessage = useCallback(
(raw: unknown) => { (raw: unknown) => {
if (!raw || typeof raw !== "object" || !("type" in raw)) return; if (!raw || typeof raw !== "object" || !("type" in raw)) return;
const message = raw as { id?: number; type: string; data: unknown }; adapter.dispatch(raw as MTPFrame);
let validated: ProtocolMessage; },
try { [adapter],
validated = validateResponse(
message.type as keyof Schemas & string,
message,
); );
} catch (error) {
log(1, "mtp", "red", "Failed to validate native MTP message", error); useEffect(() => {
return; const unsubscribers = PUSH_TYPES.map((type) =>
} connection.subscribe(type, (message) => {
for (const handler of subscriptionsRef.current.get(validated.type) ?? const validated = message as ProtocolMessage;
[]) {
handler(validated);
}
if (!isPushType(validated.type)) return;
setFreshContacts((contacts) => setFreshContacts((contacts) =>
removeMissingContacts(contacts, validated), removeMissingContacts(contacts, validated),
); );
for (const handler of [...pushHandlersRef.current]) { for (const handler of [...pushHandlersRef.current]) {
void Promise.resolve(handler(validated)).catch((error) => { void Promise.resolve(handler(validated)).catch((error) => {
log(1, "mtp", "red", "Native MTP push handler failed", error, { log(1, "mtp", "red", "Native MTP push handler failed", error, {
type: validated.type, type,
}); });
}); });
} }
if (validated.type === "GetStates") { if (validated.type === "GetStates") {
lastInitialStateRef.current = validated; lastInitialStateRef.current = validated;
} }
}, }),
[lastInitialStateRef, pushHandlersRef],
); );
return () => {
for (const unsubscribe of unsubscribers) unsubscribe();
};
}, [connection, lastInitialStateRef, pushHandlersRef]);
useEffect(() => { useEffect(() => {
if (props.blockConnection) return; if (props.blockConnection) return;
@ -847,26 +801,10 @@ function TauriProvider(props: {
const send = useCallback<BoundSendFn>( const send = useCallback<BoundSendFn>(
async (type, data, options) => { async (type, data, options) => {
const response = await invoke<ProtocolMessage>("mtp_request", { const validated = await connection.request(type, data, options);
typeName: type,
data: data ?? {},
id: options?.id,
});
const validated = validateResponse(type, response);
setFreshContacts((contacts) => setFreshContacts((contacts) =>
removeMissingContacts(contacts, validated), removeMissingContacts(contacts, validated),
); );
if (validated.type.startsWith("Error")) {
const errorData = validated.data as Record<string, unknown>;
throw new ProtocolError({
type: validated.type,
requestId: validated.id,
errorType:
typeof errorData.ErrorType === "string"
? errorData.ErrorType
: undefined,
});
}
for (const interceptor of interceptorsRef.current) { for (const interceptor of interceptorsRef.current) {
void Promise.resolve( void Promise.resolve(
interceptor({ type, data, response: validated as ProtocolMessage }), interceptor({ type, data, response: validated as ProtocolMessage }),
@ -876,20 +814,13 @@ function TauriProvider(props: {
} }
return validated; return validated;
}, },
[interceptorsRef], [connection, interceptorsRef],
); );
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => { const subscribe = useCallback<ContextType["subscribe"]>(
const handlers = (type, handler) => connection.subscribe(type, handler),
subscriptionsRef.current.get(type) ?? [connection],
new Set<(message: ProtocolMessage) => void>(); );
handlers.add(handler as (message: ProtocolMessage) => void);
subscriptionsRef.current.set(type, handlers);
return () => {
handlers.delete(handler as (message: ProtocolMessage) => void);
if (handlers.size === 0) subscriptionsRef.current.delete(type);
};
}, []);
const connected = snapshot.readyState === ConnectionState.Connected; const connected = snapshot.readyState === ConnectionState.Connected;
const contextReady = connected && snapshot.identified; const contextReady = connected && snapshot.identified;
@ -916,7 +847,7 @@ function TauriProvider(props: {
); );
} }
export function Provider(props: { function BrowserProviderLoader(props: {
children: ReactNode; children: ReactNode;
blockConnection?: boolean; blockConnection?: boolean;
}) { }) {
@ -941,10 +872,17 @@ export function Provider(props: {
if (wasmError) throw wasmError; if (wasmError) throw wasmError;
if (!wasmReady) return null; if (!wasmReady) return null;
return <BrowserProvider {...props} />;
}
export function Provider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
return isTauri() ? ( return isTauri() ? (
<TauriProvider {...props} /> <TauriProvider {...props} />
) : ( ) : (
<BrowserProvider {...props} /> <BrowserProviderLoader {...props} />
); );
} }

9
pnpm-lock.yaml generated
View file

@ -458,9 +458,6 @@ importers:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.11.1 specifier: ^2.11.1
version: 2.11.1 version: 2.11.1
'@tensamin/crypto':
specifier: workspace:*
version: link:../crypto
'@tensamin/shared': '@tensamin/shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
@ -473,12 +470,6 @@ importers:
react: react:
specifier: ^19.2.8 specifier: ^19.2.8
version: 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: devDependencies:
eslint: eslint:
specifier: ^10.8.0 specifier: ^10.8.0