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

@ -12,8 +12,7 @@
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"test": "vitest run",
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tensamin/mtp": "workspace:*",

View file

@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import {
replaceConversation,
selectConversationWindows,
trimMessages,
} from "./helpers";
import type { CachedMessage, ConversationWindow } from "./schemas";
const message = (SendTime: number): CachedMessage => ({
SenderId: 1,
SendTime,
Content: "Y2lwaGVydGV4dA==",
MessageState: "received",
});
const window = (UserId: number, LastMessageAt: number): ConversationWindow => ({
UserId,
LastMessageAt,
Messages: [],
});
describe("conversation cache helpers", () => {
it("selects the five most recent windows", () => {
const selected = selectConversationWindows(
[
window(1, 1),
window(2, 6),
window(3, 3),
window(4, 4),
window(5, 5),
window(6, 2),
],
5,
);
expect(selected.map(({ UserId }) => UserId)).toEqual([2, 5, 4, 3, 6]);
});
it("replaces only the matching conversation", () => {
expect(
replaceConversation([window(1, 1), window(2, 2)], window(1, 9)),
).toEqual([window(1, 9), window(2, 2)]);
});
it("retains the newest messages in chronological order", () => {
expect(
trimMessages([message(2), message(3), message(1)], 2).map(
(item) => item.SendTime,
),
).toEqual([2, 3]);
});
});

View file

@ -4,8 +4,6 @@ import {
CardHeader,
Drawer,
DrawerContent,
DrawerDescription,
DrawerTitle,
DrawerTrigger,
Popover,
PopoverContent,

View file

@ -12,7 +12,7 @@
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"test": "vitest run",
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"react": "^19.2.8",

View file

@ -1,65 +0,0 @@
import { describe, expect, test, vi } from "vitest";
vi.mock("mtp", () => ({
crypto: {},
}));
import { createCryptoActions } from "./context";
/**
* Creates a rejected API getter used to verify initialization guards.
* @returns Null API reference.
*/
function getUninitializedApi(): null {
return null;
}
describe("createCryptoActions", () => {
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
test("throws when API is not initialized", async () => {
const actions = createCryptoActions(getUninitializedApi);
let failed = false;
try {
await actions.encrypt("ab", new TextEncoder().encode("plain"));
} catch (error) {
failed = (error as Error).message.includes("API not initialized");
}
expect(failed).toBe(true);
});
test("delegates encrypt/decrypt/getSharedSecret to API reference", async () => {
const api = {
encrypt: async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> =>
textEncoder.encode(`${secret}:${textDecoder.decode(input)}`),
decrypt: async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> =>
textEncoder.encode(`${secret}|${textDecoder.decode(input)}`),
encryptText: async (secret: string, plaintext: string): Promise<string> =>
`${secret}:${plaintext}`,
decryptText: async (
secret: string,
ciphertext: string,
): Promise<string> => `${secret}|${ciphertext}`,
};
const actions = createCryptoActions(() => api);
expect(
textDecoder.decode(await actions.encrypt("s", textEncoder.encode("p"))),
).toBe("s:p");
expect(
textDecoder.decode(await actions.decrypt("s", textEncoder.encode("c"))),
).toBe("s|c");
expect(await actions.encryptText("s", "p")).toBe("s:p");
expect(await actions.decryptText("s", "c")).toBe("s|c");
});
});

View file

@ -141,20 +141,30 @@ export default function Provider(props: { children: React.ReactNode }) {
const hasPermissions = await requestNotificationPermission();
if (hasPermissions) {
const notification = new Notification(user.Display, {
const options: NotificationOptions = {
body: content,
icon: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
badge: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
icon: user.Avatar || "/icons/icon-192.png",
badge: "/icons/notification-badge.png",
tag: `message-${user.UserId}`,
silent: true,
});
};
if ("serviceWorker" in navigator) {
const registration =
await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.showNotification(user.Display, {
...options,
data: { url: `/chat?id=${user.UserId}` },
});
return;
}
}
const notification = new Notification(user.Display, options);
notification.onclick = () => {
window.focus();
navigate({
to: `/chat?id=${user.UserId}`,
});
notification.close();
};
} else {

View file

@ -21,17 +21,37 @@ export {
type OnboardingStepControls,
} from "@methanium/ui";
interface GateState {
docs: z.infer<typeof legalDocsSchema>;
acceptedPP: boolean;
acceptedTOS: boolean;
changedPP: boolean;
changedTOS: boolean;
includeLegal: boolean;
includeOnboarding: boolean;
includeTauriPermissions: boolean;
}
async function fetchLegalDocumentHash(document: string) {
const response = await fetch(
`https://legal.methanium.net/tensamin/${document}/raw`,
);
if (!response.ok) {
throw new Error(`Legal document request failed: ${response.status}`);
}
if (!response.headers.get("content-type")?.startsWith("text/plain")) {
throw new Error("Legal document request returned an invalid content type");
}
const hash = await crypto.subtle.digest(
"SHA-256",
await response.arrayBuffer(),
);
return Array.from(new Uint8Array(hash), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}
export default function OnboardingGate({ children }: { children: ReactNode }) {
const { load, save } = useStorage();
const [state, setState] = useState<GateState>();
@ -51,25 +71,9 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
void (async () => {
try {
const response = await fetch("https://legal.tensamin.net/api/current");
if (!response.ok) {
throw new Error(`Legal documents request failed: ${response.status}`);
}
const current: unknown = await response.json();
if (!active) return;
const parsed = legalDocsSchema.safeParse(current);
if (!parsed.success) {
setError("Failed to load legal documents");
setErrorDescription(
"The legal documents data received from the server is invalid. Please try again later.",
);
log(0, "Legal", "red", "Invalid legal documents data", parsed.error);
return;
}
const [
ppHash,
tosHash,
localDocs,
acceptedPP,
acceptedTOS,
@ -77,6 +81,8 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
onboardingStarted,
tauriPermissionsDone,
] = await Promise.all([
fetchLegalDocumentHash("privacy-policy"),
fetchLegalDocumentHash("terms-of-service"),
load("legal_docs"),
load("accepted_privacy_policy"),
load("accepted_terms_of_service"),
@ -87,10 +93,16 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
if (!active) return;
const docs = legalDocsSchema.parse({
pp: { hash: ppHash },
tos: { hash: tosHash },
});
const changedPP = acceptedPP && localDocs.pp.hash !== docs.pp.hash;
const changedTOS = acceptedTOS && localDocs.tos.hash !== docs.tos.hash;
const currentAcceptedPP =
acceptedPP && localDocs.pp.hash === parsed.data.pp.hash;
acceptedPP && localDocs.pp.hash === docs.pp.hash;
const currentAcceptedTOS =
acceptedTOS && localDocs.tos.hash === parsed.data.tos.hash;
acceptedTOS && localDocs.tos.hash === docs.tos.hash;
const existingUser = acceptedPP && acceptedTOS;
const includeOnboarding =
!onboardingDone && (!existingUser || onboardingStarted);
@ -104,9 +116,11 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
}
setState({
docs: parsed.data,
docs,
acceptedPP: currentAcceptedPP,
acceptedTOS: currentAcceptedTOS,
changedPP,
changedTOS,
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
includeOnboarding,
includeTauriPermissions:
@ -168,16 +182,28 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
const steps: OnboardingStep[] = [];
if (state.includeLegal) {
const changedDocuments = [
state.changedPP && "Privacy Policy",
state.changedTOS && "Terms of Service",
].filter(Boolean);
steps.push({
id: "legal",
title: "Privacy Policy & ToS",
description: `${state.docs.pp.version} / ${state.docs.tos.version}`,
title:
changedDocuments.length > 0
? "Legal documents changed"
: "Privacy Policy & ToS",
description:
changedDocuments.length > 0
? changedDocuments.join(" & ")
: "Review and accept our legal documents",
defaultCanContinue: false,
content: (
<LegalPage
docs={state.docs}
initiallyAcceptedPP={state.acceptedPP}
initiallyAcceptedTOS={state.acceptedTOS}
changedPP={state.changedPP}
changedTOS={state.changedTOS}
onAccept={acceptLegal}
/>
),

View file

@ -1,21 +1,17 @@
import { useCallback, useState } from "react";
import { Checkbox, Label, Link } from "@methanium/ui";
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
import type { z } from "zod";
import { useOnboardingStep } from "@methanium/ui";
import { Checkbox, Label, Link, useOnboardingStep } from "@methanium/ui";
export default function LegalPage({
docs,
initiallyAcceptedPP,
initiallyAcceptedTOS,
changedPP,
changedTOS,
onAccept,
}: {
docs: z.infer<typeof legalDocsSchema>;
initiallyAcceptedPP: boolean;
initiallyAcceptedTOS: boolean;
changedPP: boolean;
changedTOS: boolean;
onAccept: () => Promise<void>;
}) {
const [acceptedPP, setAcceptedPP] = useState(initiallyAcceptedPP);
@ -34,34 +30,61 @@ export default function LegalPage({
return (
<div className="mx-auto flex min-h-[calc(100dvh-17rem)] w-full max-w-5xl flex-col gap-10 p-2 py-20 md:min-h-[calc(100dvh-20.5rem)] md:p-24">
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-start gap-2">
<BigCheckbox
id="acceptPP"
checked={acceptedPP}
onChange={setAcceptedPP}
label="I agree to the Privacy Policy"
/>
<BigCheckbox
id="acceptTOS"
checked={acceptedTOS}
onChange={setAcceptedTOS}
label="I agree to the Terms of Service"
/>
<div className="w-full border-t-2" />
<Link
label="Privacy Policy"
link={`https://legal.tensamin.net/pp/${docs.pp.version}`}
/>
<Link
label="Terms of Service"
link={`https://legal.tensamin.net/tos/${docs.tos.version}`}
/>
<div className="flex w-full max-w-xl flex-col items-start gap-8">
{!initiallyAcceptedPP && (
<LegalDocumentAcceptance
id="acceptPP"
name="Privacy Policy"
link="https://legal.methanium.net/tensamin/privacy-policy/"
changed={changedPP}
checked={acceptedPP}
onChange={setAcceptedPP}
/>
)}
{!initiallyAcceptedTOS && (
<LegalDocumentAcceptance
id="acceptTOS"
name="Terms of Service"
link="https://legal.methanium.net/tensamin/terms-of-service/"
changed={changedTOS}
checked={acceptedTOS}
onChange={setAcceptedTOS}
/>
)}
</div>
</div>
</div>
);
}
function LegalDocumentAcceptance({
id,
name,
link,
changed,
checked,
onChange,
}: {
id: string;
name: string;
link: string;
changed: boolean;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex w-full flex-col items-start gap-3">
<Link label={`Read the ${name}`} link={link} />
<BigCheckbox
id={id}
checked={checked}
onChange={onChange}
label={`I agree to the ${changed ? `updated ${name}` : name}`}
/>
</div>
);
}
function BigCheckbox({
id,
label,

View file

@ -580,20 +580,11 @@ export const storageDefaults: Storage = {
analytics_done: false,
...settingsStorageDefaults,
legal_docs: {
eula: {
version: "0.0",
hash: "000000000000",
unix: 0,
},
tos: {
version: "0.0",
hash: "000000000000",
unix: 0,
hash: "0000000000000000000000000000000000000000000000000000000000000000",
},
pp: {
version: "0.0",
hash: "000000000000",
unix: 0,
hash: "0000000000000000000000000000000000000000000000000000000000000000",
},
},
cached_contacts: [],

View file

@ -1,13 +1,10 @@
import { z } from "zod";
const legalDocSchema = z.object({
version: z.string().regex(/^\d+\.\d+$/),
hash: z.string().regex(/^[a-f0-9]{12}$/),
unix: z.number().int().positive(),
hash: z.string().regex(/^[a-f0-9]{64}$/),
});
export const legalDocsSchema = z.object({
eula: legalDocSchema,
tos: legalDocSchema,
pp: legalDocSchema,
});

View file

@ -6,7 +6,9 @@
"exports": {
"./session": "./src/session.tsx",
"./context": "./src/context.tsx",
"./secure": "./src/secure.ts"
"./secure": "./src/secure.ts",
"./browserSecure": "./src/browserSecure.ts",
"./credentials": "./src/credentials.ts"
},
"scripts": {
"format": "pnpm exec prettier --write .",

View file

@ -0,0 +1,40 @@
import { getDatabaseEntry } from "@tensamin/shared/indexedDb";
type SecureEnvelope = {
__tensaminSecure: 1;
version: 1;
iv: string;
data: string;
};
function base64ToBytes(value: string) {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function isSecureEnvelope(value: unknown): value is SecureEnvelope {
if (!value || typeof value !== "object") return false;
const envelope = value as Partial<SecureEnvelope>;
return (
envelope.__tensaminSecure === 1 &&
envelope.version === 1 &&
typeof envelope.iv === "string" &&
typeof envelope.data === "string"
);
}
export async function loadSecureBrowserValue<T>(key: string) {
const stored = await getDatabaseEntry<unknown>("storage", key);
if (stored === undefined || !isSecureEnvelope(stored)) {
return stored as T | undefined;
}
const masterKey = await getDatabaseEntry<CryptoKey>("keys", "master-v1");
if (!masterKey) throw new Error("Secure storage key is unavailable.");
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToBytes(stored.iv) },
masterKey,
base64ToBytes(stored.data),
);
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
}

View file

@ -30,7 +30,7 @@ import {
export type SaveOptions = { secure?: boolean };
interface StorageContextValue {
export interface StorageContextValue {
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
save<K extends keyof StorageSchema>(
key: K,

View file

@ -0,0 +1,68 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
import type { StorageContextValue } from "./context";
export function parseTuFileContent(rawFileContent: string): {
userId: number;
privateKey: string;
domain: string | null;
} {
const content = rawFileContent.trim();
const separator = content.indexOf("::");
if (separator <= 0 || separator !== content.lastIndexOf("::")) {
throw new Error("Invalid file");
}
const identity = content.slice(0, separator);
const privateKey = content.slice(separator + 2).trim();
const [userIdValue, domain, ...extraDomainParts] = identity.split("@");
const userId = Number(userIdValue);
if (
!Number.isSafeInteger(userId) ||
userId <= 0 ||
!privateKey ||
extraDomainParts.length > 0 ||
(identity.includes("@") && !domain)
) {
throw new Error("Invalid file");
}
return { userId, privateKey, domain: domain ?? null };
}
export async function persistMtpCredentials({
storage,
userId,
keyring,
domain,
}: {
storage: Pick<StorageContextValue, "load" | "save">;
userId: number;
keyring: string;
domain?: string | null;
}) {
const omegaUrl = domain
? `https://${domain}/`
: await storage.load("omega_url");
if (domain) await storage.save("omega_url", omegaUrl);
if (isTauri()) {
const [forcedOmikronUrl, forcedOmikronPublicKey] = await Promise.all([
storage.load("forced_omikron_url"),
storage.load("forced_omikron_public_key"),
]);
await invoke("mtp_store_credentials", {
config: {
userId,
keyring,
omegaUrl,
forcedOmikronUrl,
forcedOmikronPublicKey,
},
});
}
await storage.save("mtp_keyring", keyring, { secure: true });
await storage.save("session_id", Date.now());
await storage.save("user_id", userId);
}

View file

@ -4,10 +4,7 @@ import { getDatabaseEntry, setDatabaseEntry } from "@tensamin/shared/indexedDb";
export type SecureStorageStatus = {
backend:
| "electron-keyring"
| "application-storage"
| "webcrypto"
| "indexeddb";
"electron-keyring" | "application-storage" | "webcrypto" | "indexeddb";
secure: boolean;
reason?: string;
};
@ -71,7 +68,12 @@ async function getKey() {
if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") {
return null;
}
if (window.tensaminDesktop?.secureStorage) return loadElectronKey();
if (
typeof window !== "undefined" &&
window.tensaminDesktop?.secureStorage
) {
return loadElectronKey();
}
return loadBrowserKey();
})().catch(() => null);
return keyPromise;
@ -120,7 +122,10 @@ export async function decodeSecureValue(value: unknown): Promise<unknown> {
}
export async function getSecureStorageStatus(): Promise<SecureStorageStatus> {
const desktop = window.tensaminDesktop?.secureStorage;
const desktop =
typeof window === "undefined"
? undefined
: window.tensaminDesktop?.secureStorage;
if (desktop?.getStatus) {
const status = await desktop.getStatus();
if (status.available) return { backend: "electron-keyring", secure: true };