feat(pwa): add base
All checks were successful
/ build-web (push) Successful in 5m35s
/ build-desktop (linux) (push) Successful in 9m41s
/ build-mobile (push) Successful in 20m12s
/ release (push) Successful in 1m51s
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped

This commit is contained in:
Alois 2026-08-18 18:44:45 +02:00
commit 7b36218ffa
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
40 changed files with 4014 additions and 922 deletions

View file

@ -52,6 +52,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
return (
<div
data-tauri-drag-region
data-pwa-navbar
className={`${forMobile && "border-b"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
>
<div className="flex items-center justify-center gap-2">

View file

@ -13,8 +13,11 @@ import {
useState,
} from "react";
import { z } from "zod";
import { invoke, isTauri } from "@tauri-apps/api/core";
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
import { subscribeTuFileLaunch } from "@tensamin/pwa/runtime";
import {
parseTuFileContent,
persistMtpCredentials,
} from "@tensamin/storage/credentials";
import { useNavigate } from "@tanstack/react-router";
const fetchedUser = z.object({
@ -35,50 +38,6 @@ const formSchema = z.object({
mtp_keyring: z.string().min(1).max(92),
});
/**
* Parses a .tu file payload into credentials.
* @param rawFileContent UTF-8 file content from an uploaded .tu file.
* @returns Parsed user id and private key credentials.
*/
function parseTuFileContent(rawFileContent: string): {
userId: number;
privateKey: string;
domain: string | null;
} {
if (rawFileContent.trim().length === 0) {
throw new Error("File is empty");
} else if (!rawFileContent.includes("::")) {
throw new Error("Invalid file");
} else if (rawFileContent.split("::").length !== 2) {
throw new Error("Invalid file");
}
const left = rawFileContent.split("::")[0];
const right = rawFileContent.split("::")[1];
if (left.length === 0) {
throw new Error("Invalid file");
} else if (right.length === 0) {
throw new Error("Invalid file");
} else if (isNaN(Number(left)) && !left.includes("@")) {
throw new Error("Invalid file");
}
const [userIdString, privateKeyValue] = rawFileContent.split("::");
const privateKey = privateKeyValue.trim();
const userId = isNaN(Number(userIdString))
? Number(userIdString.split("@")[0])
: Number(userIdString);
const domain = userIdString.includes("@") ? userIdString.split("@")[1] : null;
if (!userId || !privateKey) {
throw new Error("Invalid file");
}
return { userId, privateKey, domain };
}
export default function Form() {
const isMobile = useIsMobile();
const uploadRef = useRef<HTMLInputElement | null>(null);
@ -92,27 +51,12 @@ export default function Form() {
if (loginPendingRef.current) return false;
loginPendingRef.current = true;
try {
if (domain) await save("omega_url", `https://${domain}/`);
if (isTauri()) {
const [omegaUrl, forcedOmikronUrl, forcedOmikronPublicKey] =
await Promise.all([
domain ? `https://${domain}/` : load("omega_url"),
load("forced_omikron_url"),
load("forced_omikron_public_key"),
]);
await invoke("mtp_store_credentials", {
config: {
userId,
keyring: privateKey,
omegaUrl,
forcedOmikronUrl,
forcedOmikronPublicKey,
},
});
}
await save("mtp_keyring", privateKey, { secure: true });
await save("session_id", Date.now());
await save("user_id", userId);
await persistMtpCredentials({
storage: { load, save },
userId,
keyring: privateKey,
domain,
});
await navigate({ to: "/", replace: true });
return true;
} finally {
@ -143,6 +87,11 @@ export default function Form() {
[persistLogin],
);
useEffect(
() => subscribeTuFileLaunch(processDroppedFile),
[processDroppedFile],
);
// Handle .tu files
const handleFileInputChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
@ -281,32 +230,10 @@ export default function Form() {
return (
<div className="relative flex md:flex-row flex-col gap-15">
{isTauri() && isMobile ? (
<>
<QrCodeScanner
onData={async (data) => {
if (!data.startsWith("tensamin://tu::")) {
toast("error", "Invalid QR code");
return;
}
const decoded = data.replace("tensamin://tu::", "");
try {
const { userId, privateKey, domain } =
parseTuFileContent(decoded);
await persistLogin(userId, privateKey, domain);
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to parse QR code data");
}
}}
/>
<Button onClick={() => uploadRef.current?.click()}>
Select .tu file
</Button>
</>
{isMobile ? (
<Button onClick={() => uploadRef.current?.click()}>
Select .tu file
</Button>
) : (
<div
onClick={() => uploadRef.current?.click()}

View file

@ -29,6 +29,7 @@ import { Provider as MTPProvider } from "@tensamin/mtp";
import UserProvider from "@tensamin/user/context";
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import NotificationsProvider from "@tensamin/notifications/context";
import PwaRuntime from "@tensamin/pwa/runtime";
import TAuthWrapper from "@tensamin/tauth/context";
@ -253,10 +254,14 @@ function RootShell() {
parentThemeStorageKey={null}
designStorageKey={null}
>
<div className="w-screen h-dvh overflow-hidden">
<div
data-pwa-root
className="box-border w-screen h-dvh overflow-hidden"
>
<Toaster position={isMobile ? "top-center" : "bottom-right"} />
<TooltipProvider>
<Storage>
<PwaRuntime />
<HotkeysProvider>
<ThemeStorageBridge />
<LoginWrapper>

View file

@ -19,6 +19,7 @@ export default function Layout({ children }: { children: ReactNode }) {
<Sidebar />
<CallPopout />
<div
data-app-layout
// Background of ui that is overlapping with the system ui
className={cn(
"w-full h-full min-h-0 flex flex-col overflow-hidden",