(wip): migrate ttp to mtp

(feat): migrate tauth to mtp
(qol): update todo
(qol): add direnv
This commit is contained in:
Alois 2026-07-03 10:56:42 +02:00
commit ee5955fc75
8 changed files with 97 additions and 38 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

3
.gitignore vendored
View file

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

View file

@ -347,7 +347,7 @@
}, },
"overrides": { "overrides": {
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/latest/tensamin-ui.tgz", "@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": { "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=="], "@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=="], "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=="], "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 { in rec {
default = electron; default = electron;
@ -256,7 +256,6 @@
buildInputs = with pkgs; buildInputs = with pkgs;
[ [
jdk17 jdk17
rustToolchain
gradle gradle
bun bun
nodejs nodejs

View file

@ -44,7 +44,7 @@
}, },
"overrides": { "overrides": {
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/latest/tensamin-ui.tgz", "@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": { "dependencies": {
"@tensamin/ui": "*", "@tensamin/ui": "*",

View file

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

View file

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

View file

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