Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1838bbec83 |
40 changed files with 920 additions and 4012 deletions
|
|
@ -1,36 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
/// <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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
@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))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
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",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
|
|
@ -7,6 +7,10 @@
|
||||||
"./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": {
|
||||||
|
|
@ -24,6 +28,7 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
15
apps/tauri/src-tauri/Cargo.lock
generated
15
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -5035,6 +5035,20 @@ 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"
|
||||||
|
|
@ -5295,6 +5309,7 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
"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"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ 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,
|
||||||
|
|
|
||||||
38
apps/tauri/src/qrCodeScanner.tsx
Normal file
38
apps/tauri/src/qrCodeScanner.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"build": "pnpm --filter @tensamin/pwa build && pnpm run test && tsc -b && vite build",
|
"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,7 +18,6 @@
|
||||||
"@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:*",
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ 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">
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,8 @@ import {
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { subscribeTuFileLaunch } from "@tensamin/pwa/runtime";
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
import {
|
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
|
||||||
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({
|
||||||
|
|
@ -38,6 +35,50 @@ 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);
|
||||||
|
|
@ -51,12 +92,27 @@ export default function Form() {
|
||||||
if (loginPendingRef.current) return false;
|
if (loginPendingRef.current) return false;
|
||||||
loginPendingRef.current = true;
|
loginPendingRef.current = true;
|
||||||
try {
|
try {
|
||||||
await persistMtpCredentials({
|
if (domain) await save("omega_url", `https://${domain}/`);
|
||||||
storage: { load, save },
|
if (isTauri()) {
|
||||||
userId,
|
const [omegaUrl, forcedOmikronUrl, forcedOmikronPublicKey] =
|
||||||
keyring: privateKey,
|
await Promise.all([
|
||||||
domain,
|
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 navigate({ to: "/", replace: true });
|
await navigate({ to: "/", replace: true });
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -87,11 +143,6 @@ 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> => {
|
||||||
|
|
@ -230,10 +281,32 @@ 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">
|
||||||
{isMobile ? (
|
{isTauri() && isMobile ? (
|
||||||
<Button onClick={() => uploadRef.current?.click()}>
|
<>
|
||||||
Select .tu file
|
<QrCodeScanner
|
||||||
</Button>
|
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>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
onClick={() => uploadRef.current?.click()}
|
onClick={() => uploadRef.current?.click()}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ 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";
|
||||||
|
|
||||||
|
|
@ -254,14 +253,10 @@ function RootShell() {
|
||||||
parentThemeStorageKey={null}
|
parentThemeStorageKey={null}
|
||||||
designStorageKey={null}
|
designStorageKey={null}
|
||||||
>
|
>
|
||||||
<div
|
<div className="w-screen h-dvh overflow-hidden">
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ 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));
|
||||||
|
|
@ -126,7 +125,6 @@ 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") }),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
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
|
||||||
|
|
@ -117,6 +117,15 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,37 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,19 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
3
packages/cache/package.json
vendored
3
packages/cache/package.json
vendored
|
|
@ -12,7 +12,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"test": "vitest run",
|
||||||
|
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
|
|
|
||||||
50
packages/cache/src/helpers.test.ts
vendored
Normal file
50
packages/cache/src/helpers.test.ts
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,8 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerTitle,
|
||||||
DrawerTrigger,
|
DrawerTrigger,
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
|
|
|
||||||
|
|
@ -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": "tsc -p tsconfig.json --noEmit"
|
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
|
|
|
||||||
65
packages/crypto/src/context.test.ts
Normal file
65
packages/crypto/src/context.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -141,30 +141,20 @@ export default function Provider(props: { children: React.ReactNode }) {
|
||||||
const hasPermissions = await requestNotificationPermission();
|
const hasPermissions = await requestNotificationPermission();
|
||||||
|
|
||||||
if (hasPermissions) {
|
if (hasPermissions) {
|
||||||
const options: NotificationOptions = {
|
const notification = new Notification(user.Display, {
|
||||||
body: content,
|
body: content,
|
||||||
icon: user.Avatar || "/icons/icon-192.png",
|
icon: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
|
||||||
badge: "/icons/notification-badge.png",
|
badge: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -21,37 +21,17 @@ 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>();
|
||||||
|
|
@ -71,9 +51,25 @@ 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,
|
||||||
|
|
@ -81,8 +77,6 @@ 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"),
|
||||||
|
|
@ -93,16 +87,10 @@ 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 === docs.pp.hash;
|
acceptedPP && localDocs.pp.hash === parsed.data.pp.hash;
|
||||||
const currentAcceptedTOS =
|
const currentAcceptedTOS =
|
||||||
acceptedTOS && localDocs.tos.hash === docs.tos.hash;
|
acceptedTOS && localDocs.tos.hash === parsed.data.tos.hash;
|
||||||
const existingUser = acceptedPP && acceptedTOS;
|
const existingUser = acceptedPP && acceptedTOS;
|
||||||
const includeOnboarding =
|
const includeOnboarding =
|
||||||
!onboardingDone && (!existingUser || onboardingStarted);
|
!onboardingDone && (!existingUser || onboardingStarted);
|
||||||
|
|
@ -116,11 +104,9 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
docs,
|
docs: parsed.data,
|
||||||
acceptedPP: currentAcceptedPP,
|
acceptedPP: currentAcceptedPP,
|
||||||
acceptedTOS: currentAcceptedTOS,
|
acceptedTOS: currentAcceptedTOS,
|
||||||
changedPP,
|
|
||||||
changedTOS,
|
|
||||||
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
|
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
|
||||||
includeOnboarding,
|
includeOnboarding,
|
||||||
includeTauriPermissions:
|
includeTauriPermissions:
|
||||||
|
|
@ -182,28 +168,16 @@ 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:
|
title: "Privacy Policy & ToS",
|
||||||
changedDocuments.length > 0
|
description: `${state.docs.pp.version} / ${state.docs.tos.version}`,
|
||||||
? "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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Checkbox, Label, Link, useOnboardingStep } from "@methanium/ui";
|
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";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
@ -30,61 +34,34 @@ 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 w-full max-w-xl flex-col items-start gap-8">
|
<div className="flex flex-col items-start gap-2">
|
||||||
{!initiallyAcceptedPP && (
|
<BigCheckbox
|
||||||
<LegalDocumentAcceptance
|
id="acceptPP"
|
||||||
id="acceptPP"
|
checked={acceptedPP}
|
||||||
name="Privacy Policy"
|
onChange={setAcceptedPP}
|
||||||
link="https://legal.methanium.net/tensamin/privacy-policy/"
|
label="I agree to the Privacy Policy"
|
||||||
changed={changedPP}
|
/>
|
||||||
checked={acceptedPP}
|
<BigCheckbox
|
||||||
onChange={setAcceptedPP}
|
id="acceptTOS"
|
||||||
/>
|
checked={acceptedTOS}
|
||||||
)}
|
onChange={setAcceptedTOS}
|
||||||
{!initiallyAcceptedTOS && (
|
label="I agree to the Terms of Service"
|
||||||
<LegalDocumentAcceptance
|
/>
|
||||||
id="acceptTOS"
|
<div className="w-full border-t-2" />
|
||||||
name="Terms of Service"
|
<Link
|
||||||
link="https://legal.methanium.net/tensamin/terms-of-service/"
|
label="Privacy Policy"
|
||||||
changed={changedTOS}
|
link={`https://legal.tensamin.net/pp/${docs.pp.version}`}
|
||||||
checked={acceptedTOS}
|
/>
|
||||||
onChange={setAcceptedTOS}
|
<Link
|
||||||
/>
|
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,
|
||||||
|
|
|
||||||
|
|
@ -580,11 +580,20 @@ 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: {
|
||||||
hash: "0000000000000000000000000000000000000000000000000000000000000000",
|
version: "0.0",
|
||||||
|
hash: "000000000000",
|
||||||
|
unix: 0,
|
||||||
},
|
},
|
||||||
pp: {
|
pp: {
|
||||||
hash: "0000000000000000000000000000000000000000000000000000000000000000",
|
version: "0.0",
|
||||||
|
hash: "000000000000",
|
||||||
|
unix: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
cached_contacts: [],
|
cached_contacts: [],
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const legalDocSchema = z.object({
|
const legalDocSchema = z.object({
|
||||||
hash: z.string().regex(/^[a-f0-9]{64}$/),
|
version: z.string().regex(/^\d+\.\d+$/),
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@
|
||||||
"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 .",
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -30,7 +30,7 @@ import {
|
||||||
|
|
||||||
export type SaveOptions = { secure?: boolean };
|
export type SaveOptions = { secure?: boolean };
|
||||||
|
|
||||||
export interface StorageContextValue {
|
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,
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,10 @@ import { getDatabaseEntry, setDatabaseEntry } from "@tensamin/shared/indexedDb";
|
||||||
|
|
||||||
export type SecureStorageStatus = {
|
export type SecureStorageStatus = {
|
||||||
backend:
|
backend:
|
||||||
"electron-keyring" | "application-storage" | "webcrypto" | "indexeddb";
|
| "electron-keyring"
|
||||||
|
| "application-storage"
|
||||||
|
| "webcrypto"
|
||||||
|
| "indexeddb";
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -68,12 +71,7 @@ async function getKey() {
|
||||||
if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") {
|
if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (
|
if (window.tensaminDesktop?.secureStorage) return loadElectronKey();
|
||||||
typeof window !== "undefined" &&
|
|
||||||
window.tensaminDesktop?.secureStorage
|
|
||||||
) {
|
|
||||||
return loadElectronKey();
|
|
||||||
}
|
|
||||||
return loadBrowserKey();
|
return loadBrowserKey();
|
||||||
})().catch(() => null);
|
})().catch(() => null);
|
||||||
return keyPromise;
|
return keyPromise;
|
||||||
|
|
@ -122,10 +120,7 @@ export async function decodeSecureValue(value: unknown): Promise<unknown> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSecureStorageStatus(): Promise<SecureStorageStatus> {
|
export async function getSecureStorageStatus(): Promise<SecureStorageStatus> {
|
||||||
const desktop =
|
const desktop = window.tensaminDesktop?.secureStorage;
|
||||||
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 };
|
||||||
|
|
|
||||||
3148
pnpm-lock.yaml
generated
3148
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue