TTP -> MTP, A lot of other stuff #21

Merged
alois merged 81 commits from dev into main 2026-07-21 11:57:33 +03:00
8 changed files with 97 additions and 38 deletions
Showing only changes of commit ee5955fc75 - Show all commits

(wip): migrate ttp to mtp

(feat): migrate tauth to mtp
(qol): update todo
(qol): add direnv
Alois 2026-07-03 10:56:42 +02:00

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

3
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules
releases
.fallow/
.fallow
.direnv

View file

@ -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-8ae8377/mtp-0.1.0.tgz",
"mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c3fbeb2/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-8ae8377/mtp-0.1.0.tgz", {}, "sha512-W0l7Jc+1XI8BdrmRP9rlY/di6kZ5a/srv4mQvzwyJd+Tfg7BWZ9IUYUXIPze6ayx9dLY1f9Y71OgbhjstMzYjA=="],
"mtp": ["mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c3fbeb2/mtp-0.1.0.tgz", {}, "sha512-j/RGQY9/3NXzZptXbY44Kv7YnGr38CQQOehRAzPPFvnEVj+kxCwzVLgSImT765+vfgl2L+bkAE9aI2PWwOBTow=="],
"nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],

View file

@ -206,7 +206,7 @@
];
};
commonDeps = [pkgs.wasm-pack pkgs.lld];
commonDeps = [rustToolchain pkgs.wasm-pack pkgs.lld];
in rec {
default = electron;
@ -256,7 +256,6 @@
buildInputs = with pkgs;
[
jdk17
rustToolchain
gradle
bun
nodejs

View file

@ -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-8ae8377/mtp-0.1.0.tgz"
"mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c3fbeb2/mtp-0.1.0.tgz"
},
"dependencies": {
"@tensamin/ui": "*",

View file

@ -10,7 +10,7 @@ import {
} from "react";
import { isTauri } from "@tauri-apps/api/core";
import { onResume } from "tauri-plugin-app-events-api";
import { MTPClient } from "mtp";
import { ConnectionState, MTPClient } from "mtp";
import type { z } from "zod";
import {
@ -31,12 +31,6 @@ import {
RETRY_INTERVAL,
} from "./values";
const READY_STATE = {
CLOSED: 0,
CONNECTING: 1,
OPEN: 2,
} as const;
const PUSH_TYPES = [
"message_live",
"message_state",
@ -153,6 +147,7 @@ function mapDataKeys(value: unknown, mapKey: (key: string) => string): unknown {
);
}
// Zod schema validation
function validateResponse<T extends keyof Schemas & string>(
type: T,
message: { id?: number; type: string; data: unknown },
@ -189,8 +184,9 @@ export function Provider(props: {
}) {
const { load } = useStorage();
const [readyState, setReadyState] = useState<number>(READY_STATE.CLOSED);
const [connected, setConnected] = useState<boolean>(false);
const [readyState, setReadyState] = useState<number>(
ConnectionState.Disconnected,
);
const [identified, setIdentified] = useState<boolean>(false);
const [identifying, setIdentifying] = useState<boolean>(false);
@ -208,11 +204,15 @@ export function Provider(props: {
null,
);
const connected = readyState === ConnectionState.Connected;
// MTP url
const [mtpUrl, setMtpUrl] = useState<string | null>(null);
useEffect(() => {
load("mtp_url").then(setMtpUrl);
}, [load]);
// Validation override functions
const send: BoundSendFn = useMemo(
() => async (type, data, options) => {
const client = clientRef.current;
@ -259,6 +259,7 @@ export function Provider(props: {
};
}, []);
// No Iota check
useEffect(() => {
if (!connected) return;
@ -272,6 +273,7 @@ export function Provider(props: {
});
}, [connected, subscribe]);
// Custom Pings
useEffect(() => {
if (!connected || !identified) {
return;
@ -297,11 +299,9 @@ export function Provider(props: {
};
}, [connected, identified, send]);
// Reconnect stuff
useEffect(() => {
if (!mtpUrl) {
return;
}
const url = mtpUrl;
if (!mtpUrl) return;
let attempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@ -358,17 +358,28 @@ export function Provider(props: {
}
try {
setReadyState(READY_STATE.CONNECTING);
setConnected(false);
setIdentified(false);
setIdentifying(false);
await MTPClient.init();
const client = await MTPClient.create({
url,
url: mtpUrl ?? "",
descriptor: "client",
pings: true,
logger: (event) => {
log(2, "mtp", event.type === "state" ? "cyan" : "blue", event.type === "state" ? event.data : event.type, event);
if (event.type === "state") {
setReadyState(
clientRef.current?.state ?? ConnectionState.Disconnected,
);
}
log(
2,
"mtp",
event.type === "state" ? "cyan" : "blue",
event.type === "state" ? event.data : event.type,
event,
);
},
});
@ -378,6 +389,7 @@ export function Provider(props: {
}
clientRef.current = client;
setReadyState(client.state);
await client.connect();
if (disposed) {
@ -401,8 +413,7 @@ export function Provider(props: {
clearReconnectTimer();
scheduleReconnectReset();
setReadyState(READY_STATE.OPEN);
setConnected(true);
setReadyState(client.state);
setIdentifying(true);
setError("");
setErrorDescription("");
@ -425,8 +436,7 @@ export function Provider(props: {
clientRef.current?.disconnect();
clientRef.current = null;
clearReconnectResetTimer();
setReadyState(READY_STATE.CLOSED);
setConnected(false);
setReadyState(ConnectionState.Disconnected);
setIdentified(false);
setIdentifying(false);
log(
@ -475,16 +485,16 @@ export function Provider(props: {
clientRef.current?.disconnect();
clientRef.current = null;
setReadyState(READY_STATE.CLOSED);
setConnected(false);
setReadyState(ConnectionState.Disconnected);
setIdentified(false);
setIdentifying(false);
};
}, [mtpUrl, props.blockConnection]);
// Loading bar
const progress = useMemo(() => {
if (!mtpUrl) return 10;
if (readyState === READY_STATE.CONNECTING) return 30;
if (readyState === ConnectionState.Connecting) return 30;
if (!connected) return 45;
if (identifying) return 75;
if (!identified) return 90;
@ -493,7 +503,7 @@ export function Provider(props: {
const loadingTitle = useMemo(() => {
if (!mtpUrl) return "Looking up configuration";
if (readyState === READY_STATE.CONNECTING || !connected)
if (readyState === ConnectionState.Connecting || !connected)
return "Connecting to Tensamin";
if (identifying || !identified) return "Identifying secure session";
return "Loading";
@ -501,13 +511,14 @@ export function Provider(props: {
const loadingDescription = useMemo(() => {
if (!mtpUrl) return "Loading connection details";
if (readyState === READY_STATE.CONNECTING || !connected) {
if (readyState === ConnectionState.Connecting || !connected) {
return "Establishing transport channel";
}
if (identifying || !identified) return "Waiting for authenticated session";
return undefined;
}, [connected, identified, identifying, readyState, mtpUrl]);
// Return
if (error !== "" && errorDescription !== "") {
return <ErrorScreen error={error} description={errorDescription} />;
}

View file

@ -11,7 +11,7 @@ import {
DialogHeader,
DialogTitle,
} from "@tensamin/ui";
import { useLocation } from "@tanstack/react-router";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
@ -25,28 +25,40 @@ export default function Wrapper({ children }: { children: ReactNode }) {
const { send } = useMTP();
const { getSharedSecret } = useCrypto();
const { searchStr } = useLocation();
const navigate = useNavigate();
const [dialogOpen, setDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [identifier, setIdentifier] = useState<string | null>(null);
const [redirect, setRedirect] = useState<string | null>(null);
const [challenge, setChallenge] = useState<string | null>(null);
const [appPublicKey, setAppPublicKey] = useState<string | null>(null);
const [allowChildern, setAllowChildern] = useState(false);
const { deeplinks } = useDeeplinks();
const authorizeApp = async () => {
if (!identifier || !redirect || !challenge) return;
if (!identifier || !redirect || !challenge || !appPublicKey) return;
try {
// Get Data
const user = await get(await load("user_id"));
const {
data: { content: appPublicKey },
data: { content: appPublicKeyHash },
} = await send("load_txt_record", {
path: "tauth." + identifier,
});
const verifiedAppPublicKeyHash = await sha256Hex(appPublicKey);
if (normalizeHash(appPublicKeyHash) !== verifiedAppPublicKeyHash) {
log(1, "tauth", "red", "App public key hash mismatch", undefined, {
appPublicKey,
appPublicKeyHash,
verifiedAppPublicKeyHash,
});
throw new Error("App public key hash mismatch");
}
// Get Shared Secret
const sharedSecret = await getSharedSecret(
await load("private_key"),
@ -75,7 +87,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
finalUrl.searchParams.set("challenge", solvedChallenge);
finalUrl.searchParams.set("originalChallenge", challenge);
const session = Date.now();
const session = new Date().getTime();
finalUrl.searchParams.set("sessionId", String(session));
// Save Session
@ -92,11 +104,14 @@ export default function Wrapper({ children }: { children: ReactNode }) {
setIdentifier(null);
setRedirect(null);
setChallenge(null);
setAppPublicKey(null);
toast("success", "App authorized successfully");
return;
} else {
window.location.href = finalUrl.toString();
navigate({
to: finalUrl.toString(),
});
return;
}
} catch (err) {
@ -108,9 +123,27 @@ export default function Wrapper({ children }: { children: ReactNode }) {
setIdentifier(null);
setRedirect(null);
setChallenge(null);
setAppPublicKey(null);
}
};
async function sha256Hex(value: string): Promise<string> {
const hash = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(value),
);
return [...new Uint8Array(hash)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function normalizeHash(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/^sha256[:=]/, "");
}
function hexToBase64(hex: string): string {
const bytes = hex.match(/.{2}/g)?.map((byte) => parseInt(byte, 16)) ?? [];
const binaryString = String.fromCharCode(...bytes);
@ -122,6 +155,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
const identifier = params.get("identifier");
const redirect = params.get("redirect");
const challenge = params.get("challenge");
const appPublicKey = params.get("public_key");
if (!identifier || !redirect) {
setAllowChildern(true);
return;
@ -135,10 +169,18 @@ export default function Wrapper({ children }: { children: ReactNode }) {
return;
}
if (!appPublicKey) {
toast("error", "Missing app public key");
log(1, "tauth", "red", "Missing app public key");
setAllowChildern(true);
return;
}
setAllowChildern(true);
setIdentifier(identifier);
setRedirect(redirect);
setChallenge(hexToBase64(challenge || ""));
setAppPublicKey(appPublicKey);
setDialogOpen(true);
});
}, [searchStr, load]);
@ -149,11 +191,13 @@ export default function Wrapper({ children }: { children: ReactNode }) {
const url = new URL(link);
const identifier = url.searchParams.get("identifier");
const redirect = url.searchParams.get("redirect");
const appPublicKey = url.searchParams.get("public_key");
if (identifier && redirect) {
if (identifier && redirect && appPublicKey) {
setIdentifier(identifier);
setRedirect(redirect);
setChallenge(hexToBase64(url.searchParams.get("challenge") || ""));
setAppPublicKey(appPublicKey);
setDialogOpen(true);
}
}
@ -170,6 +214,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
setIdentifier(null);
setRedirect(null);
setChallenge(null);
setAppPublicKey(null);
}
}}
>
@ -205,6 +250,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
setIdentifier(null);
setRedirect(null);
setChallenge(null);
setAppPublicKey(null);
}}
>
Deny

View file

@ -1 +1,2 @@
- Move legal to extra onboarding package
- Add a bunch of tests