Compare commits

..
Author SHA1 Message Date
7ad28b3ebf chore(deps): update dependency gradle to v9
Some checks failed
renovate/stability-days Updates have met minimum release age requirement
Dependency builds / Build web (pull_request) Successful in 6m44s
Dependency builds / Build mobile (pull_request) Failing after 17m42s
Dependency builds / Build desktop (pull_request) Failing after 22m52s
2026-08-18 20:00:26 +03:00
7b36218ffa
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
2026-08-18 18:44:45 +02:00
40 changed files with 4014 additions and 922 deletions

36
apps/pwa/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "@tensamin/pwa",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./vite": "./src/vite.ts",
"./runtime": "./src/runtime.tsx"
},
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.worker.json --noEmit"
},
"dependencies": {
"@methanium/ui": "*",
"@tauri-apps/api": "^2.11.1",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"mtp": "*",
"react": "^19.2.8",
"sonner": "^2.0.7",
"vite-plugin-pwa": "^1.1.0",
"workbox-core": "^7.3.0",
"workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0"
},
"devDependencies": {
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"typescript": "~6.0.3",
"vite": "^8.2.1"
}
}

237
apps/pwa/src/runtime.tsx Normal file
View file

@ -0,0 +1,237 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { setDatabaseEntry } from "@tensamin/shared/indexedDb";
import { isTauri } from "@tauri-apps/api/core";
import "./style.css";
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
const launchedFiles: File[] = [];
const fileListeners = new Set<(file: File) => void>();
function emitLaunchedFile(file: File) {
if (fileListeners.size === 0) launchedFiles.push(file);
else for (const listener of fileListeners) listener(file);
}
export function subscribeTuFileLaunch(listener: (file: File) => void) {
fileListeners.add(listener);
for (const file of launchedFiles.splice(0)) listener(file);
return () => {
fileListeners.delete(listener);
};
}
function isStandalone() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
function applicationServerKey(value: string) {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
}
async function enablePush() {
if (!("Notification" in window))
throw new Error("Notifications are not supported by this browser.");
const permission = await Notification.requestPermission();
if (permission !== "granted")
throw new Error("Notification permission was not granted.");
const publicKey = import.meta.env.VITE_WEB_PUSH_PUBLIC_KEY;
if (
!publicKey ||
!("serviceWorker" in navigator) ||
!("PushManager" in window)
) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription =
(await registration.pushManager.getSubscription()) ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey(publicKey),
}));
await setDatabaseEntry("keys", "push-subscription", subscription.toJSON());
}
export default function PwaRuntime() {
const [installPrompt, setInstallPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
useEffect(() => {
if (
isTauri() ||
!("serviceWorker" in navigator) ||
!["http:", "https:"].includes(window.location.protocol)
) {
return;
}
let reloading = false;
const handleControllerChange = () => {
if (reloading) return;
reloading = true;
// A newly activated service worker must reload the document it controls.
// eslint-disable-next-line tensamin/no-window-location-reload
window.location.reload();
};
navigator.serviceWorker.addEventListener(
"controllerchange",
handleControllerChange,
);
void navigator.serviceWorker
.register(
import.meta.env.DEV ? "/dev-sw.js?dev-sw" : "/serviceWorker.js",
{
type: "module",
},
)
.then((registration) => {
const watchWorker = (worker: ServiceWorker) => {
worker.addEventListener("statechange", () => {
if (worker.state !== "installed") return;
if (!navigator.serviceWorker.controller) {
toast.success("Tensamin is ready for offline startup");
return;
}
toast("A Tensamin update is ready", {
duration: Infinity,
action: {
label: "Update",
onClick: () => worker.postMessage({ type: "SKIP_WAITING" }),
},
});
});
};
if (registration.installing) watchWorker(registration.installing);
registration.addEventListener("updatefound", () => {
if (registration.installing) watchWorker(registration.installing);
});
})
.catch((error: unknown) => {
console.error("Failed to register the Tensamin service worker", error);
});
return () => {
navigator.serviceWorker.removeEventListener(
"controllerchange",
handleControllerChange,
);
};
}, []);
useEffect(() => {
const handleInstallPrompt = (event: Event) => {
event.preventDefault();
setInstallPrompt(event as BeforeInstallPromptEvent);
};
window.addEventListener("beforeinstallprompt", handleInstallPrompt);
return () =>
window.removeEventListener("beforeinstallprompt", handleInstallPrompt);
}, []);
useEffect(() => {
const launchQueue = (
window as Window & {
launchQueue?: {
setConsumer: (
consumer: (params: {
files?: Array<{ getFile: () => Promise<File> }>;
}) => void,
) => void;
};
}
).launchQueue;
launchQueue?.setConsumer((params) => {
for (const handle of params.files ?? []) {
void handle.getFile().then(emitLaunchedFile);
}
});
}, []);
useEffect(() => {
if (!installPrompt) return;
toast("Install Tensamin for a native app experience", {
duration: Infinity,
action: {
label: "Install",
onClick: () => {
void installPrompt.prompt().then(() => installPrompt.userChoice);
setInstallPrompt(null);
},
},
});
}, [installPrompt]);
useEffect(() => {
const isAppleMobile = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (
isTauri() ||
!isAppleMobile ||
isStandalone() ||
localStorage.getItem("pwa-ios-install-hint")
) {
return;
}
localStorage.setItem("pwa-ios-install-hint", "shown");
toast(
"Install Tensamin from Safari's Share menu to enable background notifications.",
{
duration: 12_000,
},
);
}, []);
useEffect(() => {
if (
!isStandalone() ||
!("Notification" in window) ||
Notification.permission !== "default" ||
localStorage.getItem("pwa-push-hint")
) {
return;
}
localStorage.setItem("pwa-push-hint", "shown");
toast("Enable message notifications", {
duration: Infinity,
action: {
label: "Enable",
onClick: () => {
void enablePush()
.then(() => toast.success("Notifications enabled"))
.catch((error: unknown) =>
toast.error(
error instanceof Error
? error.message
: "Could not enable notifications",
),
);
},
},
});
}, []);
useEffect(() => {
if (
isStandalone() &&
"Notification" in window &&
Notification.permission === "granted" &&
import.meta.env.VITE_WEB_PUSH_PUBLIC_KEY
) {
void enablePush().catch((error: unknown) => {
console.error("Failed to refresh the Web Push subscription", error);
});
}
}, []);
return null;
}

View file

@ -0,0 +1,158 @@
/// <reference lib="webworker" />
import { base64ToBytes } from "mtp";
import { clientsClaim } from "workbox-core";
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { createHandlerBoundToURL } from "workbox-precaching";
import { CacheFirst } from "workbox-strategies";
import { decryptChatText, unwrapChatSecret } from "@tensamin/crypto/chatSecret";
import { loadSecureBrowserValue } from "@tensamin/storage/browserSecure";
declare let self: ServiceWorkerGlobalScope;
type PushPayload = {
version: 1;
senderId: number;
sender: string;
avatar?: string;
message: { content: string };
secret: {
chatId: string;
secretId: string;
version: number;
encryptedSecret: string;
kemCiphertext: string;
wrappingScheme: string;
};
};
function isPushPayload(value: unknown): value is PushPayload {
if (!value || typeof value !== "object") return false;
const payload = value as Partial<PushPayload>;
const message = payload.message as
Partial<PushPayload["message"]> | undefined;
const secret = payload.secret as Partial<PushPayload["secret"]> | undefined;
return (
payload.version === 1 &&
typeof payload.senderId === "number" &&
Number.isSafeInteger(payload.senderId) &&
payload.senderId > 0 &&
typeof payload.sender === "string" &&
typeof message?.content === "string" &&
typeof secret?.chatId === "string" &&
typeof secret.secretId === "string" &&
typeof secret.version === "number" &&
typeof secret.encryptedSecret === "string" &&
typeof secret.kemCiphertext === "string" &&
typeof secret.wrappingScheme === "string"
);
}
async function decryptPush(payload: PushPayload) {
const keyring = await loadSecureBrowserValue<string>("mtp_keyring");
if (!keyring) throw new Error("MTP credentials are unavailable.");
const chatSecret = await unwrapChatSecret({
encryptedSecret: base64ToBytes(payload.secret.encryptedSecret),
kemCiphertext: base64ToBytes(payload.secret.kemCiphertext),
keyring,
chatId: payload.secret.chatId,
secretId: payload.secret.secretId,
version: payload.secret.version,
wrappingScheme: payload.secret.wrappingScheme,
});
try {
return await decryptChatText(chatSecret, payload.message.content);
} finally {
chatSecret.fill(0);
}
}
clientsClaim();
cleanupOutdatedCaches();
const precacheManifest = self.__WB_MANIFEST;
precacheAndRoute(precacheManifest);
if (
precacheManifest.some((entry) =>
(typeof entry === "string" ? entry : entry.url).endsWith("index.html"),
)
) {
registerRoute(
new NavigationRoute(createHandlerBoundToURL("index.html"), {
denylist: [/^\/api\//],
}),
);
}
registerRoute(
({ request, url }) =>
url.origin === self.location.origin &&
["font", "image", "style"].includes(request.destination),
new CacheFirst({ cacheName: "tensamin-static-v1" }),
);
self.addEventListener("push", (event) => {
event.waitUntil(
(async () => {
let payload: PushPayload | undefined;
try {
const value = event.data?.json() as unknown;
if (isPushPayload(value)) payload = value;
} catch {
// The generic notification below is safe for malformed payloads.
}
let body = "Open Tensamin to view the encrypted message.";
if (payload) {
try {
body = await decryptPush(payload);
} catch {
// Do not leak credential or decryption failures in the notification.
}
}
await self.registration.showNotification(payload?.sender ?? "Tensamin", {
body,
icon: payload?.avatar || "./icons/icon-192.png",
badge: "./icons/notification-badge.png",
tag: payload ? `message-${payload.senderId}` : "message",
data: { url: payload ? `/chat?id=${payload.senderId}` : "/" },
});
const navigatorWithBadge = self.navigator as WorkerNavigator & {
setAppBadge?: (contents?: number) => Promise<void>;
};
await navigatorWithBadge.setAppBadge?.().catch(() => undefined);
})(),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(
(async () => {
const target = new URL(
String(
(event.notification.data as { url?: string } | undefined)?.url ?? "/",
),
self.location.origin,
);
const windows = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of windows) {
if ("navigate" in client) await client.navigate(target.href);
return client.focus();
}
return self.clients.openWindow(target.href);
})(),
);
});
self.addEventListener("message", (event) => {
if ((event.data as { type?: string } | undefined)?.type === "SKIP_WAITING") {
void self.skipWaiting();
}
});

18
apps/pwa/src/style.css Normal file
View file

@ -0,0 +1,18 @@
@media (display-mode: standalone), (display-mode: fullscreen) {
[data-pwa-root] {
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-left: env(safe-area-inset-left, 0px);
}
}
@media (display-mode: window-controls-overlay) and (min-width: 768px) {
[data-pwa-navbar] {
min-height: env(titlebar-area-height, 3.375rem);
padding-left: max(1px, env(titlebar-area-x, 0px));
padding-right: max(
0px,
calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw))
);
}
}

217
apps/pwa/src/vite.ts Normal file
View file

@ -0,0 +1,217 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { Plugin } from "vite";
import { VitePWA } from "vite-plugin-pwa";
const pwaDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const tauriIcons = resolve(pwaDirectory, "../tauri/src-tauri/icons");
const androidResources = resolve(
pwaDirectory,
"../tauri/src-tauri/gen/android/app/src/main/res",
);
function emitIcons(): Plugin {
const icons = [
{
fileName: "icons/icon-180.png",
source: resolve(tauriIcons, "ios/AppIcon-60x60@3x.png"),
},
{
fileName: "icons/icon-192.png",
source: resolve(androidResources, "mipmap-xxxhdpi/ic_launcher.png"),
},
{
fileName: "icons/icon-96.png",
source: resolve(androidResources, "mipmap-xhdpi/ic_launcher.png"),
},
{
fileName: "icons/icon-512.png",
source: resolve(tauriIcons, "icon.png"),
},
{
fileName: "icons/icon-maskable-512.png",
source: resolve(tauriIcons, "icon.png"),
},
{
fileName: "icons/icon-monochrome-432.png",
source: resolve(
androidResources,
"mipmap-xxxhdpi/ic_launcher_monochrome.png",
),
},
{
fileName: "icons/notification-badge.png",
source: resolve(androidResources, "drawable/ic_notification_small.png"),
},
];
return {
name: "tensamin-pwa-icons",
configureServer(server) {
server.middlewares.use((request, response, next) => {
const pathname = request.url
? new URL(request.url, "http://localhost").pathname.slice(1)
: "";
const icon = icons.find(({ fileName }) => fileName === pathname);
if (!icon) {
next();
return;
}
response.statusCode = 200;
response.setHeader("Content-Type", "image/png");
response.setHeader("Cache-Control", "no-cache");
response.end(readFileSync(icon.source));
});
},
generateBundle() {
for (const icon of icons) {
this.emitFile({
type: "asset",
fileName: icon.fileName,
source: readFileSync(icon.source),
});
}
},
transformIndexHtml: {
order: "post",
handler() {
return [
{
tag: "link",
attrs: {
rel: "apple-touch-icon",
sizes: "180x180",
href: "./icons/icon-180.png",
},
injectTo: "head",
},
{
tag: "meta",
attrs: { name: "apple-mobile-web-app-capable", content: "yes" },
injectTo: "head",
},
{
tag: "meta",
attrs: {
name: "apple-mobile-web-app-status-bar-style",
content: "black-translucent",
},
injectTo: "head",
},
{
tag: "meta",
attrs: {
name: "apple-mobile-web-app-title",
content: "Tensamin",
},
injectTo: "head",
},
{
tag: "meta",
attrs: { name: "theme-color", content: "#006a67" },
injectTo: "head",
},
];
},
},
};
}
export function tensaminPwa(): Plugin[] {
return [
emitIcons(),
...VitePWA({
strategies: "injectManifest",
srcDir: resolve(pwaDirectory, "src"),
filename: "serviceWorker.ts",
injectRegister: null,
registerType: "prompt",
manifestFilename: "manifest.json",
includeAssets: ["favicon.ico", "icons/*.png"],
manifest: {
id: "/",
name: "Tensamin",
short_name: "Tensamin",
description: "Private messaging and calls with Tensamin.",
start_url: "/",
scope: "/",
display: "standalone",
display_override: ["window-controls-overlay", "standalone"],
background_color: "#001f1e",
theme_color: "#006a67",
categories: ["social", "communication"],
orientation: "any",
launch_handler: { client_mode: "focus-existing" },
icons: [
{
src: "icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "icons/icon-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
{
src: "icons/icon-monochrome-432.png",
sizes: "432x432",
type: "image/png",
purpose: "monochrome",
},
],
shortcuts: [
{
name: "Chats",
short_name: "Chats",
url: "/",
icons: [
{
src: "icons/icon-96.png",
sizes: "96x96",
type: "image/png",
},
],
},
{
name: "Settings",
short_name: "Settings",
url: "/settings",
icons: [
{
src: "icons/icon-96.png",
sizes: "96x96",
type: "image/png",
},
],
},
],
file_handlers: [
{
action: "/login",
accept: { "application/x-tensamin-user": [".tu"] },
},
],
},
injectManifest: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2,wasm,mp3,wav}"],
globIgnores: ["assets/v2/**"],
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024,
},
devOptions: {
enabled: true,
type: "module",
},
}),
];
}

33
apps/pwa/todo.md Normal file
View file

@ -0,0 +1,33 @@
# Web Push Backend TODO
The client can subscribe and decrypt version 1 push payloads, but reliable delivery requires backend support.
- Generate and securely store a VAPID key pair. Expose only the public key to the web build as `VITE_WEB_PUSH_PUBLIC_KEY`.
- Add authenticated MTP requests for registering, replacing, and deleting a browser `PushSubscription` per user and installation.
- Persist the endpoint, `p256dh`, `auth`, expiration time, stable installation ID, and last-seen time.
- Remove subscriptions when a push service returns HTTP 404 or 410 and rate-limit registrations per user.
- Send pushes when an encrypted live message cannot be delivered to an active browser client. Define duplicate suppression for clients that receive both MTP and Web Push.
- Keep the JSON payload within push-provider limits and use this version 1 shape:
```json
{
"version": 1,
"senderId": 123,
"sender": "Display name",
"avatar": "https://optional.example/avatar",
"message": { "content": "base64 encrypted message content" },
"secret": {
"chatId": "123:456",
"secretId": "chat:123:456:main",
"version": 1,
"encryptedSecret": "base64 wrapped chat secret",
"kemCiphertext": "base64 KEM ciphertext",
"wrappingScheme": "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1"
}
}
```
- Ensure the wrapped secret is intended for the receiving user's MTP keyring. The server must never receive plaintext message content or plaintext chat secrets.
- Decide how edits, deletions, reactions, calls, read states, and per-chat notification cancellation map to push events.
- Add subscription rotation handling and unregister subscriptions when a user logs out or clears application data.
- Configure production HTTPS, SPA route fallback, `application/manifest+json` for `manifest.json`, and `Cache-Control: no-cache` for the service worker.

23
apps/pwa/tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "vite-plugin-pwa/client", "node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/runtime.tsx", "src/vite.ts"]
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "WebWorker"],
"types": ["vite-plugin-pwa/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/serviceWorker.ts"]
}

View file

@ -7,10 +7,6 @@
"./deeplinkHandler": { "./deeplinkHandler": {
"types": "./src/deeplinkHandler.tsx", "types": "./src/deeplinkHandler.tsx",
"default": "./src/deeplinkHandler.tsx" "default": "./src/deeplinkHandler.tsx"
},
"./qrCodeScanner": {
"types": "./src/qrCodeScanner.tsx",
"default": "./src/qrCodeScanner.tsx"
} }
}, },
"scripts": { "scripts": {
@ -28,7 +24,6 @@
"dependencies": { "dependencies": {
"@methanium/ui": "*", "@methanium/ui": "*",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-barcode-scanner": "~2.4.5",
"@tauri-apps/plugin-deep-link": "~2.4.9", "@tauri-apps/plugin-deep-link": "~2.4.9",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"react": "^19.2.8", "react": "^19.2.8",

View file

@ -5035,20 +5035,6 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-barcode-scanner"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b68f0e3782b61a6e16a67380be40226e2b7233f3e36dadf6e5d03f07d2e7b3"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.19",
]
[[package]] [[package]]
name = "tauri-plugin-deep-link" name = "tauri-plugin-deep-link"
version = "2.4.9" version = "2.4.9"
@ -5309,7 +5295,6 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-barcode-scanner",
"tauri-plugin-deep-link", "tauri-plugin-deep-link",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-notification", "tauri-plugin-notification",

View file

@ -39,9 +39,6 @@ default-features = true
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
jni = "0.22" jni = "0.22"
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-barcode-scanner = "2"
[patch.crates-io.tauri] [patch.crates-io.tauri]
git = "https://github.com/tauri-apps/tauri" git = "https://github.com/tauri-apps/tauri"
rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41" rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41"

View file

@ -5,9 +5,6 @@
"permissions": [ "permissions": [
"core:event:default", "core:event:default",
"deep-link:default", "deep-link:default",
"barcode-scanner:default",
"barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel",
"notification:default", "notification:default",
"log:default" "log:default"
] ]

View file

@ -15,9 +15,6 @@ pub fn run() {
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_opener::init()); .plugin(tauri_plugin_opener::init());
#[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
let app = builder let app = builder
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
accessibility_backend::accessibility_get_initial_scale, accessibility_backend::accessibility_get_initial_scale,

View file

@ -1,38 +0,0 @@
import {
scan,
Format,
requestPermissions,
} from "@tauri-apps/plugin-barcode-scanner";
import { Button } from "@methanium/ui";
import { toast } from "@tensamin/shared/log";
export default function QrCodeScanner({
onData,
}: {
onData: (data: string) => void;
}) {
return (
<Button
onClick={() => {
requestPermissions()
.catch((err) => {
toast("error", err.message);
})
.then(() =>
scan({ windowed: false, formats: [Format.QRCode] })
.catch((err) => {
toast("error", err.message);
})
.then((data) => {
if (data) {
onData(data.content);
}
}),
);
}}
>
Open QR Code Scanner
</Button>
);
}

View file

@ -8,7 +8,7 @@
"lint": "eslint src", "lint": "eslint src",
"dev": "vite", "dev": "vite",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"build": "pnpm run test && tsc -b && vite build", "build": "pnpm --filter @tensamin/pwa build && pnpm run test && tsc -b && vite build",
"preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .." "preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .."
}, },
"dependencies": { "dependencies": {
@ -18,6 +18,7 @@
"@tanstack/react-router": "^1.170.21", "@tanstack/react-router": "^1.170.21",
"@tanstack/react-virtual": "^3.14.9", "@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tensamin/pwa": "workspace:*",
"@tensamin/cache": "workspace:*", "@tensamin/cache": "workspace:*",
"@tensamin/call": "workspace:*", "@tensamin/call": "workspace:*",
"@tensamin/chat": "workspace:*", "@tensamin/chat": "workspace:*",

View file

@ -52,6 +52,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
return ( return (
<div <div
data-tauri-drag-region 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`} 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"> <div className="flex items-center justify-center gap-2">

View file

@ -13,8 +13,11 @@ import {
useState, useState,
} from "react"; } from "react";
import { z } from "zod"; import { z } from "zod";
import { invoke, isTauri } from "@tauri-apps/api/core"; import { subscribeTuFileLaunch } from "@tensamin/pwa/runtime";
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner"; import {
parseTuFileContent,
persistMtpCredentials,
} from "@tensamin/storage/credentials";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
const fetchedUser = z.object({ const fetchedUser = z.object({
@ -35,50 +38,6 @@ const formSchema = z.object({
mtp_keyring: z.string().min(1).max(92), 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() { export default function Form() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const uploadRef = useRef<HTMLInputElement | null>(null); const uploadRef = useRef<HTMLInputElement | null>(null);
@ -92,27 +51,12 @@ export default function Form() {
if (loginPendingRef.current) return false; if (loginPendingRef.current) return false;
loginPendingRef.current = true; loginPendingRef.current = true;
try { try {
if (domain) await save("omega_url", `https://${domain}/`); await persistMtpCredentials({
if (isTauri()) { storage: { load, save },
const [omegaUrl, forcedOmikronUrl, forcedOmikronPublicKey] = userId,
await Promise.all([ keyring: privateKey,
domain ? `https://${domain}/` : load("omega_url"), domain,
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 navigate({ to: "/", replace: true }); await navigate({ to: "/", replace: true });
return true; return true;
} finally { } finally {
@ -143,6 +87,11 @@ export default function Form() {
[persistLogin], [persistLogin],
); );
useEffect(
() => subscribeTuFileLaunch(processDroppedFile),
[processDroppedFile],
);
// Handle .tu files // Handle .tu files
const handleFileInputChange = useCallback( const handleFileInputChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => { async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
@ -281,32 +230,10 @@ export default function Form() {
return ( return (
<div className="relative flex md:flex-row flex-col gap-15"> <div className="relative flex md:flex-row flex-col gap-15">
{isTauri() && isMobile ? ( {isMobile ? (
<> <Button onClick={() => uploadRef.current?.click()}>
<QrCodeScanner Select .tu file
onData={async (data) => { </Button>
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>
</>
) : ( ) : (
<div <div
onClick={() => uploadRef.current?.click()} onClick={() => uploadRef.current?.click()}

View file

@ -29,6 +29,7 @@ import { Provider as MTPProvider } from "@tensamin/mtp";
import UserProvider from "@tensamin/user/context"; import UserProvider from "@tensamin/user/context";
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler"; import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import NotificationsProvider from "@tensamin/notifications/context"; import NotificationsProvider from "@tensamin/notifications/context";
import PwaRuntime from "@tensamin/pwa/runtime";
import TAuthWrapper from "@tensamin/tauth/context"; import TAuthWrapper from "@tensamin/tauth/context";
@ -253,10 +254,14 @@ function RootShell() {
parentThemeStorageKey={null} parentThemeStorageKey={null}
designStorageKey={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"} /> <Toaster position={isMobile ? "top-center" : "bottom-right"} />
<TooltipProvider> <TooltipProvider>
<Storage> <Storage>
<PwaRuntime />
<HotkeysProvider> <HotkeysProvider>
<ThemeStorageBridge /> <ThemeStorageBridge />
<LoginWrapper> <LoginWrapper>

View file

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

View file

@ -8,6 +8,7 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { mtp } from "mtp/vite"; import { mtp } from "mtp/vite";
import { methaniumUi } from "@methanium/ui/vite"; import { methaniumUi } from "@methanium/ui/vite";
import { tensaminPwa } from "@tensamin/pwa/vite";
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
const appDir = dirname(fileURLToPath(import.meta.url)); const appDir = dirname(fileURLToPath(import.meta.url));
@ -125,6 +126,7 @@ export default defineConfig({
sourcemap: !!process.env.TAURI_ENV_DEBUG, sourcemap: !!process.env.TAURI_ENV_DEBUG,
}, },
plugins: [ plugins: [
...tensaminPwa(),
methaniumUi({ defaultThemeId: "tensamin" }), methaniumUi({ defaultThemeId: "tensamin" }),
deepFilterAssetHeaders(resolve(appDir, "public")), deepFilterAssetHeaders(resolve(appDir, "public")),
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }), mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),

View file

@ -1,20 +0,0 @@
SPDXVersion: SPDX-2.1
DataLicense: CC0-1.0
PackageName: tauri
DataFormat: SPDXRef-1
PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy
PackageHomePage: https://tauri.app
PackageLicenseDeclared: Apache-2.0
PackageLicenseDeclared: MIT
PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy
PackageSummary: <text>Tauri is a rust project that enables developers to make secure
and small desktop applications using a web frontend.
</text>
PackageComment: <text>The package includes the following libraries; see
Relationship information.
</text>
Created: 2019-05-20T09:00:00Z
PackageDownloadLocation: git://github.com/tauri-apps/tauri
PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git
PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git
Creator: Person: Daniel Thompson-Yvetot

View file

@ -117,15 +117,6 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/@tauri-apps_cli@2.11.4` - Folder: `licenses/@tauri-apps_cli@2.11.4`
- Source package dir: `apps/tauri/node_modules/@tauri-apps/cli` - Source package dir: `apps/tauri/node_modules/@tauri-apps/cli`
## @tauri-apps/plugin-barcode-scanner@2.4.5
- License: MIT OR Apache-2.0
- Repository: https://github.com/tauri-apps/plugins-workspace
- Description: Scan QR codes, EAN-13 and other kinds of barcodes on Android and iOS
- Included files: LICENSE.spdx
- Folder: `licenses/@tauri-apps_plugin-barcode-scanner@2.4.5`
- Source package dir: `apps/tauri/node_modules/@tauri-apps/plugin-barcode-scanner`
## @tauri-apps/plugin-deep-link@2.4.9 ## @tauri-apps/plugin-deep-link@2.4.9
- License: MIT OR Apache-2.0 - License: MIT OR Apache-2.0

View file

@ -414,37 +414,6 @@
} }
] ]
}, },
{
"type": "library",
"bomRef": "pkg:npm/%40tauri-apps/plugin-barcode-scanner@2.4.5",
"name": "@tauri-apps/plugin-barcode-scanner",
"version": "2.4.5",
"purl": "pkg:npm/%40tauri-apps/plugin-barcode-scanner@2.4.5",
"description": "Scan QR codes, EAN-13 and other kinds of barcodes on Android and iOS",
"licenses": [
{
"license": {
"name": "MIT OR Apache-2.0"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "https://github.com/tauri-apps/plugins-workspace"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@tauri-apps_plugin-barcode-scanner@2.4.5"
},
{
"name": "local:sourcePackageDir",
"value": "apps/tauri/node_modules/@tauri-apps/plugin-barcode-scanner"
}
]
},
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/%40tauri-apps/plugin-deep-link@2.4.9", "bomRef": "pkg:npm/%40tauri-apps/plugin-deep-link@2.4.9",
@ -1754,4 +1723,4 @@
] ]
} }
] ]
} }

View file

@ -160,19 +160,6 @@
"licenseFolder": "licenses/@tauri-apps_cli@2.11.4", "licenseFolder": "licenses/@tauri-apps_cli@2.11.4",
"sourcePackageDir": "apps/tauri/node_modules/@tauri-apps/cli" "sourcePackageDir": "apps/tauri/node_modules/@tauri-apps/cli"
}, },
{
"name": "@tauri-apps/plugin-barcode-scanner",
"version": "2.4.5",
"license": "MIT OR Apache-2.0",
"homepage": null,
"repository": "https://github.com/tauri-apps/plugins-workspace",
"description": "Scan QR codes, EAN-13 and other kinds of barcodes on Android and iOS",
"files": [
"LICENSE.spdx"
],
"licenseFolder": "licenses/@tauri-apps_plugin-barcode-scanner@2.4.5",
"sourcePackageDir": "apps/tauri/node_modules/@tauri-apps/plugin-barcode-scanner"
},
{ {
"name": "@tauri-apps/plugin-deep-link", "name": "@tauri-apps/plugin-deep-link",
"version": "2.4.9", "version": "2.4.9",
@ -678,4 +665,4 @@
"sourcePackageDir": "packages/call/node_modules/zustand" "sourcePackageDir": "packages/call/node_modules/zustand"
} }
] ]
} }

View file

@ -12,8 +12,7 @@
"scripts": { "scripts": {
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",
"lint": "eslint src", "lint": "eslint src",
"test": "vitest run", "build": "tsc -p tsconfig.json --noEmit"
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@tensamin/mtp": "workspace:*", "@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, CardHeader,
Drawer, Drawer,
DrawerContent, DrawerContent,
DrawerDescription,
DrawerTitle,
DrawerTrigger, DrawerTrigger,
Popover, Popover,
PopoverContent, PopoverContent,

View file

@ -12,7 +12,7 @@
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",
"lint": "eslint src", "lint": "eslint src",
"test": "vitest run", "test": "vitest run",
"build": "pnpm run test && tsc -p tsconfig.json --noEmit" "build": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.8", "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(); const hasPermissions = await requestNotificationPermission();
if (hasPermissions) { if (hasPermissions) {
const notification = new Notification(user.Display, { const options: NotificationOptions = {
body: content, body: content,
icon: user.Avatar || user.Display.slice(0, 2).toUpperCase(), icon: user.Avatar || "/icons/icon-192.png",
badge: user.Avatar || user.Display.slice(0, 2).toUpperCase(), badge: "/icons/notification-badge.png",
tag: `message-${user.UserId}`, tag: `message-${user.UserId}`,
silent: true, 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 = () => { notification.onclick = () => {
window.focus(); window.focus();
navigate({ navigate({
to: `/chat?id=${user.UserId}`, to: `/chat?id=${user.UserId}`,
}); });
notification.close(); notification.close();
}; };
} else { } else {

View file

@ -21,17 +21,37 @@ export {
type OnboardingStepControls, type OnboardingStepControls,
} from "@methanium/ui"; } from "@methanium/ui";
interface GateState { interface GateState {
docs: z.infer<typeof legalDocsSchema>; docs: z.infer<typeof legalDocsSchema>;
acceptedPP: boolean; acceptedPP: boolean;
acceptedTOS: boolean; acceptedTOS: boolean;
changedPP: boolean;
changedTOS: boolean;
includeLegal: boolean; includeLegal: boolean;
includeOnboarding: boolean; includeOnboarding: boolean;
includeTauriPermissions: 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 }) { export default function OnboardingGate({ children }: { children: ReactNode }) {
const { load, save } = useStorage(); const { load, save } = useStorage();
const [state, setState] = useState<GateState>(); const [state, setState] = useState<GateState>();
@ -51,25 +71,9 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
void (async () => { void (async () => {
try { 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 [ const [
ppHash,
tosHash,
localDocs, localDocs,
acceptedPP, acceptedPP,
acceptedTOS, acceptedTOS,
@ -77,6 +81,8 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
onboardingStarted, onboardingStarted,
tauriPermissionsDone, tauriPermissionsDone,
] = await Promise.all([ ] = await Promise.all([
fetchLegalDocumentHash("privacy-policy"),
fetchLegalDocumentHash("terms-of-service"),
load("legal_docs"), load("legal_docs"),
load("accepted_privacy_policy"), load("accepted_privacy_policy"),
load("accepted_terms_of_service"), load("accepted_terms_of_service"),
@ -87,10 +93,16 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
if (!active) return; 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 = const currentAcceptedPP =
acceptedPP && localDocs.pp.hash === parsed.data.pp.hash; acceptedPP && localDocs.pp.hash === docs.pp.hash;
const currentAcceptedTOS = const currentAcceptedTOS =
acceptedTOS && localDocs.tos.hash === parsed.data.tos.hash; acceptedTOS && localDocs.tos.hash === docs.tos.hash;
const existingUser = acceptedPP && acceptedTOS; const existingUser = acceptedPP && acceptedTOS;
const includeOnboarding = const includeOnboarding =
!onboardingDone && (!existingUser || onboardingStarted); !onboardingDone && (!existingUser || onboardingStarted);
@ -104,9 +116,11 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
} }
setState({ setState({
docs: parsed.data, docs,
acceptedPP: currentAcceptedPP, acceptedPP: currentAcceptedPP,
acceptedTOS: currentAcceptedTOS, acceptedTOS: currentAcceptedTOS,
changedPP,
changedTOS,
includeLegal: !currentAcceptedPP || !currentAcceptedTOS, includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
includeOnboarding, includeOnboarding,
includeTauriPermissions: includeTauriPermissions:
@ -168,16 +182,28 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
const steps: OnboardingStep[] = []; const steps: OnboardingStep[] = [];
if (state.includeLegal) { if (state.includeLegal) {
const changedDocuments = [
state.changedPP && "Privacy Policy",
state.changedTOS && "Terms of Service",
].filter(Boolean);
steps.push({ steps.push({
id: "legal", id: "legal",
title: "Privacy Policy & ToS", title:
description: `${state.docs.pp.version} / ${state.docs.tos.version}`, changedDocuments.length > 0
? "Legal documents changed"
: "Privacy Policy & ToS",
description:
changedDocuments.length > 0
? changedDocuments.join(" & ")
: "Review and accept our legal documents",
defaultCanContinue: false, defaultCanContinue: false,
content: ( content: (
<LegalPage <LegalPage
docs={state.docs}
initiallyAcceptedPP={state.acceptedPP} initiallyAcceptedPP={state.acceptedPP}
initiallyAcceptedTOS={state.acceptedTOS} initiallyAcceptedTOS={state.acceptedTOS}
changedPP={state.changedPP}
changedTOS={state.changedTOS}
onAccept={acceptLegal} onAccept={acceptLegal}
/> />
), ),

View file

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

View file

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

View file

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

View file

@ -6,7 +6,9 @@
"exports": { "exports": {
"./session": "./src/session.tsx", "./session": "./src/session.tsx",
"./context": "./src/context.tsx", "./context": "./src/context.tsx",
"./secure": "./src/secure.ts" "./secure": "./src/secure.ts",
"./browserSecure": "./src/browserSecure.ts",
"./credentials": "./src/credentials.ts"
}, },
"scripts": { "scripts": {
"format": "pnpm exec prettier --write .", "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 }; export type SaveOptions = { secure?: boolean };
interface StorageContextValue { export interface StorageContextValue {
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>; load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
save<K extends keyof StorageSchema>( save<K extends keyof StorageSchema>(
key: K, 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 = { export type SecureStorageStatus = {
backend: backend:
| "electron-keyring" "electron-keyring" | "application-storage" | "webcrypto" | "indexeddb";
| "application-storage"
| "webcrypto"
| "indexeddb";
secure: boolean; secure: boolean;
reason?: string; reason?: string;
}; };
@ -71,7 +68,12 @@ async function getKey() {
if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") { if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") {
return null; return null;
} }
if (window.tensaminDesktop?.secureStorage) return loadElectronKey(); if (
typeof window !== "undefined" &&
window.tensaminDesktop?.secureStorage
) {
return loadElectronKey();
}
return loadBrowserKey(); return loadBrowserKey();
})().catch(() => null); })().catch(() => null);
return keyPromise; return keyPromise;
@ -120,7 +122,10 @@ export async function decodeSecureValue(value: unknown): Promise<unknown> {
} }
export async function getSecureStorageStatus(): Promise<SecureStorageStatus> { export async function getSecureStorageStatus(): Promise<SecureStorageStatus> {
const desktop = window.tensaminDesktop?.secureStorage; const desktop =
typeof window === "undefined"
? undefined
: window.tensaminDesktop?.secureStorage;
if (desktop?.getStatus) { if (desktop?.getStatus) {
const status = await desktop.getStatus(); const status = await desktop.getStatus();
if (status.available) return { backend: "electron-keyring", secure: true }; if (status.available) return { backend: "electron-keyring", secure: true };

3150
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff