diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx
index 4d1e9ae..bbc399e 100644
--- a/apps/web/src/index.tsx
+++ b/apps/web/src/index.tsx
@@ -238,19 +238,9 @@ function RootShell() {
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -264,13 +254,23 @@ function RootShell() {
function AppShell() {
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 44697ad..a224653 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
-import { defineConfig, type Plugin } from "vite";
+import { defineConfig, normalizePath, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { mtp } from "mtp/vite";
@@ -51,6 +51,38 @@ function deepFilterAssetHeaders(rootDir: string): Plugin {
};
}
+function restartOnMtpSourceChange(srcDir: string): Plugin {
+ const watchedDir = normalizePath(realpathSync(srcDir));
+
+ return {
+ name: "restart-on-mtp-source-change",
+ apply: "serve",
+ configureServer(server) {
+ server.watcher.add(watchedDir);
+ let restartTimer: ReturnType | null = null;
+
+ const restart = (file: string) => {
+ if (!normalizePath(file).startsWith(`${watchedDir}/`)) {
+ return;
+ }
+
+ if (restartTimer) {
+ clearTimeout(restartTimer);
+ }
+
+ restartTimer = setTimeout(() => {
+ restartTimer = null;
+ void server.restart();
+ }, 50);
+ };
+
+ server.watcher.on("add", restart);
+ server.watcher.on("change", restart);
+ server.watcher.on("unlink", restart);
+ },
+ };
+}
+
export default defineConfig({
base: "./",
clearScreen: false,
@@ -105,7 +137,6 @@ export default defineConfig({
},
envPrefix: ["VITE_", "TAURI_ENV_*"],
optimizeDeps: {
- // Annoying Vite 8 stuff
include: [
"react-redux",
"use-sync-external-store/shim/with-selector",
@@ -114,7 +145,28 @@ export default defineConfig({
"eventemitter3",
"react-is",
],
- exclude: ["mtp", "mtp/raw", "mtp/type-map", "@tensamin/shared"],
+ exclude: [
+ "mtp",
+ "mtp/raw",
+ "mtp/type-map",
+ "@tensamin/call",
+ "@tensamin/call/utils",
+ "@tensamin/chat",
+ "@tensamin/crypto",
+ "@tensamin/crypto/context",
+ "@tensamin/crypto/worker",
+ "@tensamin/markdown",
+ "@tensamin/mtp",
+ "@tensamin/notifications",
+ "@tensamin/shared",
+ "@tensamin/shared/data",
+ "@tensamin/shared/log",
+ "@tensamin/storage",
+ "@tensamin/storage/context",
+ "@tensamin/tauri",
+ "@tensamin/tauth",
+ "@tensamin/user",
+ ],
},
build: {
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
@@ -122,6 +174,7 @@ export default defineConfig({
},
plugins: [
deepFilterAssetHeaders(resolve(appDir, "public")),
+ restartOnMtpSourceChange(resolve(appDir, "../../packages/mtp/src")),
mtp({ typeMaps: resolve(appDir, "../../type-maps.yaml") }),
react(),
tailwindcss(),
diff --git a/bun.lock b/bun.lock
index 466251e..0af5854 100644
--- a/bun.lock
+++ b/bun.lock
@@ -347,7 +347,7 @@
},
"overrides": {
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/latest/tensamin-ui.tgz",
- "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c4a52c6/mtp-0.1.0.tgz",
+ "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-8ae8377/mtp-0.1.0.tgz",
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
@@ -1540,7 +1540,7 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
- "mtp": ["mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c4a52c6/mtp-0.1.0.tgz", {}, "sha512-KIhmkIlALo0PdMnx/QzhM87jNCuC90DaQ7uEWpMjbkI3xvGWmkiX75zqt0UZcO03JYWfBAsCO4+51egoOUq6TA=="],
+ "mtp": ["mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-8ae8377/mtp-0.1.0.tgz", {}, "sha512-W0l7Jc+1XI8BdrmRP9rlY/di6kZ5a/srv4mQvzwyJd+Tfg7BWZ9IUYUXIPze6ayx9dLY1f9Y71OgbhjstMzYjA=="],
"nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
diff --git a/package.json b/package.json
index 82550b9..c673b1a 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,7 @@
},
"overrides": {
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/latest/tensamin-ui.tgz",
- "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c4a52c6/mtp-0.1.0.tgz"
+ "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-8ae8377/mtp-0.1.0.tgz"
},
"dependencies": {
"@tensamin/ui": "*",
diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx
index c931a04..251071a 100644
--- a/packages/call/src/store.tsx
+++ b/packages/call/src/store.tsx
@@ -52,8 +52,7 @@ type IncomingCallInvite = {
senderId: number;
};
type CurrentCallData =
- | (z.infer & { exists: boolean })
- | null;
+ (z.infer & { exists: boolean }) | null;
type NavigateFn = (options: {
to: string;
diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx
index c81aa13..b6da558 100644
--- a/packages/mtp/src/context.tsx
+++ b/packages/mtp/src/context.tsx
@@ -13,8 +13,6 @@ import { onResume } from "tauri-plugin-app-events-api";
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,
@@ -33,19 +31,21 @@ import {
RETRY_INTERVAL,
} from "./values";
-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 PUSH_TYPES = [
+ "message_live",
+ "message_state",
+ "call_invite",
+ "error_no_iota",
+] as const;
const WIRE_TYPES = {
- identification: "AppIdentification",
- challenge_response: "AppChallengeResponse",
+ temp_cool_type: "TempCoolType",
get_user_data: "GetUserData",
change_user_data: "ChangeUserData",
ping: "AppPing",
@@ -67,18 +67,9 @@ const APP_TYPES = Object.fromEntries(
Object.entries(WIRE_TYPES).map(([appType, wireType]) => [wireType, appType]),
) as Record;
-const FATAL_IDENTIFICATION_ERROR_TYPES = new Set([
- "error",
- "error_invalid_user_id",
- "error_no_user_id",
- "error_invalid_challenge",
- "error_invalid_secret",
- "error_invalid_private_key",
- "error_invalid_public_key",
- "error_not_authenticated",
-]);
-
-export type ProtocolMessage = {
+export type ProtocolMessage<
+ T extends keyof Schemas & string = keyof Schemas & string,
+> = {
id?: number;
type: T | string;
data: z.infer;
@@ -114,29 +105,16 @@ 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 };
+ const protocolError = error as {
+ id?: unknown;
+ type?: unknown;
+ data?: unknown;
+ };
return {
id: protocolError.id,
type: protocolError.type,
@@ -193,7 +171,9 @@ function validateResponse(
const parsed = schema.safeParse(data);
if (!parsed.success) {
- throw new Error(`Response validation failed for ${type}: ${parsed.error.message}`);
+ throw new Error(
+ `Response validation failed for ${type}: ${parsed.error.message}`,
+ );
}
return {
@@ -203,9 +183,11 @@ function validateResponse(
} as ProtocolMessage;
}
-export function Provider(props: { children: ReactNode; blockConnection?: boolean }) {
+export function Provider(props: {
+ children: ReactNode;
+ blockConnection?: boolean;
+}) {
const { load } = useStorage();
- const { decrypt, getSharedSecret } = useCrypto();
const [readyState, setReadyState] = useState(READY_STATE.CLOSED);
const [connected, setConnected] = useState(false);
@@ -222,9 +204,9 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean
const [freshContacts, setFreshContacts] = useState([]);
const [freshCalls, setFreshCalls] = useState([]);
- const clientRef = useRef> | null>(null);
- const identificationStartedRef = useRef(false);
- const identificationCancelRef = useRef(false);
+ const clientRef = useRef> | null>(
+ null,
+ );
const [mtpUrl, setMtpUrl] = useState(null);
useEffect(() => {
@@ -281,7 +263,6 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean
if (!connected) return;
return subscribe("error_no_iota", () => {
- identificationCancelRef.current = true;
setIdentified(false);
setIdentifying(false);
setError("We couldn't reach your Iota");
@@ -381,13 +362,14 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean
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),
+ logger: (event) => {
+ log(2, "mtp", event.type === "state" ? "cyan" : "blue", event.type === "state" ? event.data : event.type, event);
+ },
});
if (disposed) {
@@ -403,13 +385,40 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean
return;
}
+ const authPayload = new Promise>(
+ (resolve, reject) => {
+ const unsubscribe = client.subscribe("TempCoolType", (message) => {
+ try {
+ unsubscribe();
+ resolve(validateResponse("temp_cool_type", message));
+ } catch (authPayloadError) {
+ unsubscribe();
+ reject(authPayloadError);
+ }
+ });
+ },
+ );
+
clearReconnectTimer();
scheduleReconnectReset();
- identificationCancelRef.current = false;
setReadyState(READY_STATE.OPEN);
setConnected(true);
+ setIdentifying(true);
setError("");
setErrorDescription("");
+
+ await client.auth();
+ const finalResponse = await authPayload;
+
+ if (disposed || clientRef.current !== client) {
+ return;
+ }
+
+ setFreshContacts(finalResponse.data.contacts);
+ setFreshCommunities(finalResponse.data.communities ?? []);
+ setFreshCalls(finalResponse.data.calls);
+ setIdentifying(false);
+ setIdentified(true);
} catch (connectError) {
if (disposed) return;
@@ -420,7 +429,13 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean
setConnected(false);
setIdentified(false);
setIdentifying(false);
- log(0, "mtp", "red", "Connection attempt failed", connectError);
+ log(
+ 0,
+ "mtp",
+ "red",
+ "Connection/authentication attempt failed",
+ getProtocolErrorDetails(connectError) ?? connectError,
+ );
scheduleReconnect(connectError);
}
}
@@ -464,116 +479,9 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean
setConnected(false);
setIdentified(false);
setIdentifying(false);
- identificationStartedRef.current = false;
};
}, [mtpUrl, props.blockConnection]);
- useEffect(() => {
- if (!connected) {
- identificationStartedRef.current = false;
- return;
- }
-
- if (identificationCancelRef.current || identificationStartedRef.current) {
- return;
- }
-
- identificationStartedRef.current = true;
- let cancelled = false;
- setIdentifying(true);
- setIdentified(false);
-
- const identify = async () => {
- try {
- const sessionId = await load("session_id");
- const userId = await load("user_id");
- const privateKey = await load("private_key");
-
- if (
- !Number.isSafeInteger(userId) ||
- userId <= 0 ||
- privateKey.trim() === "" ||
- !Number.isSafeInteger(sessionId) ||
- sessionId <= 0
- ) {
- throw new Error("Invalid credentials");
- }
-
- const challengeEnvelope = await send("identification", {
- version: APP_VERSION,
- session_id: sessionId,
- user_id: userId,
- });
-
- const sharedSecret = await getSharedSecret(
- privateKey,
- "",
- challengeEnvelope.data.public_key,
- );
-
- const decryptedChallenge = await decryptText(
- sharedSecret,
- challengeEnvelope.data.challenge,
- );
-
- const finalResponse = await send("challenge_response", {
- challenge: decryptedChallenge,
- }).catch((challengeError) => {
- if (!identificationCancelRef.current) {
- setError("Identification Failed");
- setErrorDescription(
- "Unable to complete secure identification. Please verify your credentials and try again.",
- );
- }
- throw challengeError;
- });
-
- setFreshContacts(finalResponse.data.contacts);
- setFreshCommunities(finalResponse.data.communities ?? []);
- setFreshCalls(finalResponse.data.calls);
-
- if (cancelled || identificationCancelRef.current) {
- return;
- }
-
- setError("");
- setErrorDescription("");
- setIdentified(true);
- } catch (identificationError) {
- if (cancelled || identificationCancelRef.current) {
- return;
- }
-
- const isFatal = isFatalIdentificationError(identificationError);
- log(
- isFatal ? 0 : 1,
- "mtp",
- isFatal ? "red" : "yellow",
- "Identification handshake failed",
- getProtocolErrorDetails(identificationError) ?? identificationError,
- );
-
- setIdentified(false);
- setError("Identification Failed");
- setErrorDescription(
- isFatal
- ? "Unable to complete secure identification. Please verify your credentials and try again."
- : "Unable to complete secure identification because the transport request failed.",
- );
- } finally {
- if (!cancelled && !identificationCancelRef.current) {
- setIdentifying(false);
- }
- }
- };
-
- void identify();
-
- return () => {
- cancelled = true;
- };
- }, [connected, decrypt, getSharedSecret, load, send]);
-
const progress = useMemo(() => {
if (!mtpUrl) return 10;
if (readyState === READY_STATE.CONNECTING) return 30;
@@ -585,7 +493,8 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean
const loadingTitle = useMemo(() => {
if (!mtpUrl) return "Looking up configuration";
- if (readyState === READY_STATE.CONNECTING || !connected) return "Connecting to Tensamin";
+ if (readyState === READY_STATE.CONNECTING || !connected)
+ return "Connecting to Tensamin";
if (identifying || !identified) return "Identifying secure session";
return "Loading";
}, [connected, identified, identifying, readyState, mtpUrl]);
@@ -595,7 +504,7 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean
if (readyState === READY_STATE.CONNECTING || !connected) {
return "Establishing transport channel";
}
- if (identifying || !identified) return "Verifying challenge-response handshake";
+ if (identifying || !identified) return "Waiting for authenticated session";
return undefined;
}, [connected, identified, identifying, readyState, mtpUrl]);
diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts
index 23c8d31..b284c14 100644
--- a/packages/shared/src/data.ts
+++ b/packages/shared/src/data.ts
@@ -39,13 +39,33 @@ export const failedUser = {
username: "unknown",
} as z.infer;
-export type Contacts = z.infer<
- typeof mtp.challenge_response.response.shape.contacts
->;
-export type Communities = z.infer<
- typeof mtp.challenge_response.response.shape.communities
->;
-export type Calls = z.infer;
+const authPayload = z.object({
+ communities: z.array(z.object({})).optional(),
+ contacts: z.array(
+ z.object({
+ last_message_at: z.number(),
+ user_id: z.number(),
+ last_message: z
+ .object({
+ content: z.base64(),
+ sender_id: z.number(),
+ })
+ .optional(),
+ messages: z.array(message),
+ }),
+ ),
+ calls: z.array(
+ z.object({
+ call_id: z.string(),
+ call_secret: z.base64().optional(),
+ call_members: z.array(z.number()),
+ }),
+ ),
+});
+
+export type Contacts = z.infer;
+export type Communities = z.infer;
+export type Calls = z.infer;
type Base16Palette = Record<
| "base00"
@@ -94,44 +114,9 @@ const user = z.object({
username: z.string().min(1).max(15),
});
export const mtp = {
- identification: {
- request: z.object({
- version: z.string(),
- session_id: z.number(),
- user_id: z.number(),
- }),
- response: z.object({
- challenge: z.string(),
- public_key: z.base64(),
- }),
- },
- challenge_response: {
- request: z.object({
- challenge: z.base64(),
- }),
- response: z.object({
- communities: z.array(z.object({})).optional(),
- contacts: z.array(
- z.object({
- last_message_at: z.number(),
- user_id: z.number(),
- last_message: z
- .object({
- content: z.base64(),
- sender_id: z.number(),
- })
- .optional(),
- messages: z.array(message),
- }),
- ),
- calls: z.array(
- z.object({
- call_id: z.string(),
- call_secret: z.base64().optional(),
- call_members: z.array(z.number()),
- }),
- ),
- }),
+ temp_cool_type: {
+ request: z.object({}).optional(),
+ response: authPayload,
},
get_user_data: {
request: z.object({
diff --git a/type-maps.yaml b/type-maps.yaml
index 15f30cc..b542504 100644
--- a/type-maps.yaml
+++ b/type-maps.yaml
@@ -3,8 +3,7 @@ protocol_version: "0.0"
type_maps:
"0.0":
CommunicationTypes:
- AppIdentification: 32
- AppChallengeResponse: 33
+ TempCoolType: 32
GetUserData: 34
ChangeUserData: 35
MessageLive: 36