Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ad28b3ebf | |||
|
7b36218ffa |
40 changed files with 4014 additions and 922 deletions
36
apps/pwa/package.json
Normal file
36
apps/pwa/package.json
Normal 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
237
apps/pwa/src/runtime.tsx
Normal 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;
|
||||
}
|
||||
158
apps/pwa/src/serviceWorker.ts
Normal file
158
apps/pwa/src/serviceWorker.ts
Normal 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
18
apps/pwa/src/style.css
Normal 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
217
apps/pwa/src/vite.ts
Normal 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
33
apps/pwa/todo.md
Normal 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
23
apps/pwa/tsconfig.json
Normal 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"]
|
||||
}
|
||||
21
apps/pwa/tsconfig.worker.json
Normal file
21
apps/pwa/tsconfig.worker.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -7,10 +7,6 @@
|
|||
"./deeplinkHandler": {
|
||||
"types": "./src/deeplinkHandler.tsx",
|
||||
"default": "./src/deeplinkHandler.tsx"
|
||||
},
|
||||
"./qrCodeScanner": {
|
||||
"types": "./src/qrCodeScanner.tsx",
|
||||
"default": "./src/qrCodeScanner.tsx"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
|
@ -28,7 +24,6 @@
|
|||
"dependencies": {
|
||||
"@methanium/ui": "*",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-barcode-scanner": "~2.4.5",
|
||||
"@tauri-apps/plugin-deep-link": "~2.4.9",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"react": "^19.2.8",
|
||||
|
|
|
|||
15
apps/tauri/src-tauri/Cargo.lock
generated
15
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -5035,20 +5035,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.9"
|
||||
|
|
@ -5309,7 +5295,6 @@ dependencies = [
|
|||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-barcode-scanner",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-notification",
|
||||
|
|
|
|||
|
|
@ -39,9 +39,6 @@ default-features = true
|
|||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.22"
|
||||
|
||||
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
|
||||
tauri-plugin-barcode-scanner = "2"
|
||||
|
||||
[patch.crates-io.tauri]
|
||||
git = "https://github.com/tauri-apps/tauri"
|
||||
rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41"
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@
|
|||
"permissions": [
|
||||
"core:event:default",
|
||||
"deep-link:default",
|
||||
"barcode-scanner:default",
|
||||
"barcode-scanner:allow-scan",
|
||||
"barcode-scanner:allow-cancel",
|
||||
"notification:default",
|
||||
"log:default"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@ pub fn run() {
|
|||
.plugin(tauri_plugin_deep_link::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
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
accessibility_backend::accessibility_get_initial_scale,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
"lint": "eslint src",
|
||||
"dev": "vite",
|
||||
"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 .."
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
"@tanstack/react-router": "^1.170.21",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tensamin/pwa": "workspace:*",
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/call": "workspace:*",
|
||||
"@tensamin/chat": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
data-pwa-navbar
|
||||
className={`${forMobile && "border-b"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@ import {
|
|||
useState,
|
||||
} from "react";
|
||||
import { z } from "zod";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
|
||||
import { subscribeTuFileLaunch } from "@tensamin/pwa/runtime";
|
||||
import {
|
||||
parseTuFileContent,
|
||||
persistMtpCredentials,
|
||||
} from "@tensamin/storage/credentials";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
const fetchedUser = z.object({
|
||||
|
|
@ -35,50 +38,6 @@ const formSchema = z.object({
|
|||
mtp_keyring: z.string().min(1).max(92),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parses a .tu file payload into credentials.
|
||||
* @param rawFileContent UTF-8 file content from an uploaded .tu file.
|
||||
* @returns Parsed user id and private key credentials.
|
||||
*/
|
||||
function parseTuFileContent(rawFileContent: string): {
|
||||
userId: number;
|
||||
privateKey: string;
|
||||
domain: string | null;
|
||||
} {
|
||||
if (rawFileContent.trim().length === 0) {
|
||||
throw new Error("File is empty");
|
||||
} else if (!rawFileContent.includes("::")) {
|
||||
throw new Error("Invalid file");
|
||||
} else if (rawFileContent.split("::").length !== 2) {
|
||||
throw new Error("Invalid file");
|
||||
}
|
||||
|
||||
const left = rawFileContent.split("::")[0];
|
||||
const right = rawFileContent.split("::")[1];
|
||||
|
||||
if (left.length === 0) {
|
||||
throw new Error("Invalid file");
|
||||
} else if (right.length === 0) {
|
||||
throw new Error("Invalid file");
|
||||
} else if (isNaN(Number(left)) && !left.includes("@")) {
|
||||
throw new Error("Invalid file");
|
||||
}
|
||||
|
||||
const [userIdString, privateKeyValue] = rawFileContent.split("::");
|
||||
const privateKey = privateKeyValue.trim();
|
||||
const userId = isNaN(Number(userIdString))
|
||||
? Number(userIdString.split("@")[0])
|
||||
: Number(userIdString);
|
||||
|
||||
const domain = userIdString.includes("@") ? userIdString.split("@")[1] : null;
|
||||
|
||||
if (!userId || !privateKey) {
|
||||
throw new Error("Invalid file");
|
||||
}
|
||||
|
||||
return { userId, privateKey, domain };
|
||||
}
|
||||
|
||||
export default function Form() {
|
||||
const isMobile = useIsMobile();
|
||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||
|
|
@ -92,27 +51,12 @@ export default function Form() {
|
|||
if (loginPendingRef.current) return false;
|
||||
loginPendingRef.current = true;
|
||||
try {
|
||||
if (domain) await save("omega_url", `https://${domain}/`);
|
||||
if (isTauri()) {
|
||||
const [omegaUrl, forcedOmikronUrl, forcedOmikronPublicKey] =
|
||||
await Promise.all([
|
||||
domain ? `https://${domain}/` : load("omega_url"),
|
||||
load("forced_omikron_url"),
|
||||
load("forced_omikron_public_key"),
|
||||
]);
|
||||
await invoke("mtp_store_credentials", {
|
||||
config: {
|
||||
userId,
|
||||
keyring: privateKey,
|
||||
omegaUrl,
|
||||
forcedOmikronUrl,
|
||||
forcedOmikronPublicKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
await save("mtp_keyring", privateKey, { secure: true });
|
||||
await save("session_id", Date.now());
|
||||
await save("user_id", userId);
|
||||
await persistMtpCredentials({
|
||||
storage: { load, save },
|
||||
userId,
|
||||
keyring: privateKey,
|
||||
domain,
|
||||
});
|
||||
await navigate({ to: "/", replace: true });
|
||||
return true;
|
||||
} finally {
|
||||
|
|
@ -143,6 +87,11 @@ export default function Form() {
|
|||
[persistLogin],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => subscribeTuFileLaunch(processDroppedFile),
|
||||
[processDroppedFile],
|
||||
);
|
||||
|
||||
// Handle .tu files
|
||||
const handleFileInputChange = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
|
|
@ -281,32 +230,10 @@ export default function Form() {
|
|||
|
||||
return (
|
||||
<div className="relative flex md:flex-row flex-col gap-15">
|
||||
{isTauri() && isMobile ? (
|
||||
<>
|
||||
<QrCodeScanner
|
||||
onData={async (data) => {
|
||||
if (!data.startsWith("tensamin://tu::")) {
|
||||
toast("error", "Invalid QR code");
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = data.replace("tensamin://tu::", "");
|
||||
|
||||
try {
|
||||
const { userId, privateKey, domain } =
|
||||
parseTuFileContent(decoded);
|
||||
|
||||
await persistLogin(userId, privateKey, domain);
|
||||
} catch (error) {
|
||||
log(0, "login", "red", error);
|
||||
toast("error", "Failed to parse QR code data");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={() => uploadRef.current?.click()}>
|
||||
Select .tu file
|
||||
</Button>
|
||||
</>
|
||||
{isMobile ? (
|
||||
<Button onClick={() => uploadRef.current?.click()}>
|
||||
Select .tu file
|
||||
</Button>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => uploadRef.current?.click()}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { Provider as MTPProvider } from "@tensamin/mtp";
|
|||
import UserProvider from "@tensamin/user/context";
|
||||
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
|
||||
import NotificationsProvider from "@tensamin/notifications/context";
|
||||
import PwaRuntime from "@tensamin/pwa/runtime";
|
||||
|
||||
import TAuthWrapper from "@tensamin/tauth/context";
|
||||
|
||||
|
|
@ -253,10 +254,14 @@ function RootShell() {
|
|||
parentThemeStorageKey={null}
|
||||
designStorageKey={null}
|
||||
>
|
||||
<div className="w-screen h-dvh overflow-hidden">
|
||||
<div
|
||||
data-pwa-root
|
||||
className="box-border w-screen h-dvh overflow-hidden"
|
||||
>
|
||||
<Toaster position={isMobile ? "top-center" : "bottom-right"} />
|
||||
<TooltipProvider>
|
||||
<Storage>
|
||||
<PwaRuntime />
|
||||
<HotkeysProvider>
|
||||
<ThemeStorageBridge />
|
||||
<LoginWrapper>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export default function Layout({ children }: { children: ReactNode }) {
|
|||
<Sidebar />
|
||||
<CallPopout />
|
||||
<div
|
||||
data-app-layout
|
||||
// Background of ui that is overlapping with the system ui
|
||||
className={cn(
|
||||
"w-full h-full min-h-0 flex flex-col overflow-hidden",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import react from "@vitejs/plugin-react";
|
|||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { mtp } from "mtp/vite";
|
||||
import { methaniumUi } from "@methanium/ui/vite";
|
||||
import { tensaminPwa } from "@tensamin/pwa/vite";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
const appDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -125,6 +126,7 @@ export default defineConfig({
|
|||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||
},
|
||||
plugins: [
|
||||
...tensaminPwa(),
|
||||
methaniumUi({ defaultThemeId: "tensamin" }),
|
||||
deepFilterAssetHeaders(resolve(appDir, "public")),
|
||||
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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`
|
||||
- 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
|
||||
|
||||
- License: MIT OR Apache-2.0
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"bomRef": "pkg:npm/%40tauri-apps/plugin-deep-link@2.4.9",
|
||||
|
|
|
|||
|
|
@ -160,19 +160,6 @@
|
|||
"licenseFolder": "licenses/@tauri-apps_cli@2.11.4",
|
||||
"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",
|
||||
"version": "2.4.9",
|
||||
|
|
|
|||
3
packages/cache/package.json
vendored
3
packages/cache/package.json
vendored
|
|
@ -12,8 +12,7 @@
|
|||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
|
|
|
|||
50
packages/cache/src/helpers.test.ts
vendored
50
packages/cache/src/helpers.test.ts
vendored
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,8 +4,6 @@ import {
|
|||
CardHeader,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"format": "pnpm exec prettier --write .",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -141,20 +141,30 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
const hasPermissions = await requestNotificationPermission();
|
||||
|
||||
if (hasPermissions) {
|
||||
const notification = new Notification(user.Display, {
|
||||
const options: NotificationOptions = {
|
||||
body: content,
|
||||
icon: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
|
||||
badge: user.Avatar || user.Display.slice(0, 2).toUpperCase(),
|
||||
icon: user.Avatar || "/icons/icon-192.png",
|
||||
badge: "/icons/notification-badge.png",
|
||||
tag: `message-${user.UserId}`,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
};
|
||||
if ("serviceWorker" in navigator) {
|
||||
const registration =
|
||||
await navigator.serviceWorker.getRegistration();
|
||||
if (registration) {
|
||||
await registration.showNotification(user.Display, {
|
||||
...options,
|
||||
data: { url: `/chat?id=${user.UserId}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const notification = new Notification(user.Display, options);
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
navigate({
|
||||
to: `/chat?id=${user.UserId}`,
|
||||
});
|
||||
|
||||
notification.close();
|
||||
};
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -21,17 +21,37 @@ export {
|
|||
type OnboardingStepControls,
|
||||
} from "@methanium/ui";
|
||||
|
||||
|
||||
|
||||
interface GateState {
|
||||
docs: z.infer<typeof legalDocsSchema>;
|
||||
acceptedPP: boolean;
|
||||
acceptedTOS: boolean;
|
||||
changedPP: boolean;
|
||||
changedTOS: boolean;
|
||||
includeLegal: boolean;
|
||||
includeOnboarding: boolean;
|
||||
includeTauriPermissions: boolean;
|
||||
}
|
||||
|
||||
async function fetchLegalDocumentHash(document: string) {
|
||||
const response = await fetch(
|
||||
`https://legal.methanium.net/tensamin/${document}/raw`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Legal document request failed: ${response.status}`);
|
||||
}
|
||||
if (!response.headers.get("content-type")?.startsWith("text/plain")) {
|
||||
throw new Error("Legal document request returned an invalid content type");
|
||||
}
|
||||
|
||||
const hash = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
await response.arrayBuffer(),
|
||||
);
|
||||
return Array.from(new Uint8Array(hash), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
|
||||
export default function OnboardingGate({ children }: { children: ReactNode }) {
|
||||
const { load, save } = useStorage();
|
||||
const [state, setState] = useState<GateState>();
|
||||
|
|
@ -51,25 +71,9 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch("https://legal.tensamin.net/api/current");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Legal documents request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const current: unknown = await response.json();
|
||||
if (!active) return;
|
||||
|
||||
const parsed = legalDocsSchema.safeParse(current);
|
||||
if (!parsed.success) {
|
||||
setError("Failed to load legal documents");
|
||||
setErrorDescription(
|
||||
"The legal documents data received from the server is invalid. Please try again later.",
|
||||
);
|
||||
log(0, "Legal", "red", "Invalid legal documents data", parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const [
|
||||
ppHash,
|
||||
tosHash,
|
||||
localDocs,
|
||||
acceptedPP,
|
||||
acceptedTOS,
|
||||
|
|
@ -77,6 +81,8 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
onboardingStarted,
|
||||
tauriPermissionsDone,
|
||||
] = await Promise.all([
|
||||
fetchLegalDocumentHash("privacy-policy"),
|
||||
fetchLegalDocumentHash("terms-of-service"),
|
||||
load("legal_docs"),
|
||||
load("accepted_privacy_policy"),
|
||||
load("accepted_terms_of_service"),
|
||||
|
|
@ -87,10 +93,16 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
|
||||
if (!active) return;
|
||||
|
||||
const docs = legalDocsSchema.parse({
|
||||
pp: { hash: ppHash },
|
||||
tos: { hash: tosHash },
|
||||
});
|
||||
const changedPP = acceptedPP && localDocs.pp.hash !== docs.pp.hash;
|
||||
const changedTOS = acceptedTOS && localDocs.tos.hash !== docs.tos.hash;
|
||||
const currentAcceptedPP =
|
||||
acceptedPP && localDocs.pp.hash === parsed.data.pp.hash;
|
||||
acceptedPP && localDocs.pp.hash === docs.pp.hash;
|
||||
const currentAcceptedTOS =
|
||||
acceptedTOS && localDocs.tos.hash === parsed.data.tos.hash;
|
||||
acceptedTOS && localDocs.tos.hash === docs.tos.hash;
|
||||
const existingUser = acceptedPP && acceptedTOS;
|
||||
const includeOnboarding =
|
||||
!onboardingDone && (!existingUser || onboardingStarted);
|
||||
|
|
@ -104,9 +116,11 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
setState({
|
||||
docs: parsed.data,
|
||||
docs,
|
||||
acceptedPP: currentAcceptedPP,
|
||||
acceptedTOS: currentAcceptedTOS,
|
||||
changedPP,
|
||||
changedTOS,
|
||||
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
|
||||
includeOnboarding,
|
||||
includeTauriPermissions:
|
||||
|
|
@ -168,16 +182,28 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
|
||||
const steps: OnboardingStep[] = [];
|
||||
if (state.includeLegal) {
|
||||
const changedDocuments = [
|
||||
state.changedPP && "Privacy Policy",
|
||||
state.changedTOS && "Terms of Service",
|
||||
].filter(Boolean);
|
||||
|
||||
steps.push({
|
||||
id: "legal",
|
||||
title: "Privacy Policy & ToS",
|
||||
description: `${state.docs.pp.version} / ${state.docs.tos.version}`,
|
||||
title:
|
||||
changedDocuments.length > 0
|
||||
? "Legal documents changed"
|
||||
: "Privacy Policy & ToS",
|
||||
description:
|
||||
changedDocuments.length > 0
|
||||
? changedDocuments.join(" & ")
|
||||
: "Review and accept our legal documents",
|
||||
defaultCanContinue: false,
|
||||
content: (
|
||||
<LegalPage
|
||||
docs={state.docs}
|
||||
initiallyAcceptedPP={state.acceptedPP}
|
||||
initiallyAcceptedTOS={state.acceptedTOS}
|
||||
changedPP={state.changedPP}
|
||||
changedTOS={state.changedTOS}
|
||||
onAccept={acceptLegal}
|
||||
/>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,21 +1,17 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { Checkbox, Label, Link } from "@methanium/ui";
|
||||
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { useOnboardingStep } from "@methanium/ui";
|
||||
|
||||
|
||||
import { Checkbox, Label, Link, useOnboardingStep } from "@methanium/ui";
|
||||
|
||||
export default function LegalPage({
|
||||
docs,
|
||||
initiallyAcceptedPP,
|
||||
initiallyAcceptedTOS,
|
||||
changedPP,
|
||||
changedTOS,
|
||||
onAccept,
|
||||
}: {
|
||||
docs: z.infer<typeof legalDocsSchema>;
|
||||
initiallyAcceptedPP: boolean;
|
||||
initiallyAcceptedTOS: boolean;
|
||||
changedPP: boolean;
|
||||
changedTOS: boolean;
|
||||
onAccept: () => Promise<void>;
|
||||
}) {
|
||||
const [acceptedPP, setAcceptedPP] = useState(initiallyAcceptedPP);
|
||||
|
|
@ -34,34 +30,61 @@ export default function LegalPage({
|
|||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-17rem)] w-full max-w-5xl flex-col gap-10 p-2 py-20 md:min-h-[calc(100dvh-20.5rem)] md:p-24">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<BigCheckbox
|
||||
id="acceptPP"
|
||||
checked={acceptedPP}
|
||||
onChange={setAcceptedPP}
|
||||
label="I agree to the Privacy Policy"
|
||||
/>
|
||||
<BigCheckbox
|
||||
id="acceptTOS"
|
||||
checked={acceptedTOS}
|
||||
onChange={setAcceptedTOS}
|
||||
label="I agree to the Terms of Service"
|
||||
/>
|
||||
<div className="w-full border-t-2" />
|
||||
<Link
|
||||
label="Privacy Policy"
|
||||
link={`https://legal.tensamin.net/pp/${docs.pp.version}`}
|
||||
/>
|
||||
<Link
|
||||
label="Terms of Service"
|
||||
link={`https://legal.tensamin.net/tos/${docs.tos.version}`}
|
||||
/>
|
||||
<div className="flex w-full max-w-xl flex-col items-start gap-8">
|
||||
{!initiallyAcceptedPP && (
|
||||
<LegalDocumentAcceptance
|
||||
id="acceptPP"
|
||||
name="Privacy Policy"
|
||||
link="https://legal.methanium.net/tensamin/privacy-policy/"
|
||||
changed={changedPP}
|
||||
checked={acceptedPP}
|
||||
onChange={setAcceptedPP}
|
||||
/>
|
||||
)}
|
||||
{!initiallyAcceptedTOS && (
|
||||
<LegalDocumentAcceptance
|
||||
id="acceptTOS"
|
||||
name="Terms of Service"
|
||||
link="https://legal.methanium.net/tensamin/terms-of-service/"
|
||||
changed={changedTOS}
|
||||
checked={acceptedTOS}
|
||||
onChange={setAcceptedTOS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalDocumentAcceptance({
|
||||
id,
|
||||
name,
|
||||
link,
|
||||
changed,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
link: string;
|
||||
changed: boolean;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start gap-3">
|
||||
<Link label={`Read the ${name}`} link={link} />
|
||||
<BigCheckbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
label={`I agree to the ${changed ? `updated ${name}` : name}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigCheckbox({
|
||||
id,
|
||||
label,
|
||||
|
|
|
|||
|
|
@ -580,20 +580,11 @@ export const storageDefaults: Storage = {
|
|||
analytics_done: false,
|
||||
...settingsStorageDefaults,
|
||||
legal_docs: {
|
||||
eula: {
|
||||
version: "0.0",
|
||||
hash: "000000000000",
|
||||
unix: 0,
|
||||
},
|
||||
tos: {
|
||||
version: "0.0",
|
||||
hash: "000000000000",
|
||||
unix: 0,
|
||||
hash: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
},
|
||||
pp: {
|
||||
version: "0.0",
|
||||
hash: "000000000000",
|
||||
unix: 0,
|
||||
hash: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
},
|
||||
},
|
||||
cached_contacts: [],
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const legalDocSchema = z.object({
|
||||
version: z.string().regex(/^\d+\.\d+$/),
|
||||
hash: z.string().regex(/^[a-f0-9]{12}$/),
|
||||
unix: z.number().int().positive(),
|
||||
hash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
|
||||
export const legalDocsSchema = z.object({
|
||||
eula: legalDocSchema,
|
||||
tos: legalDocSchema,
|
||||
pp: legalDocSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"exports": {
|
||||
"./session": "./src/session.tsx",
|
||||
"./context": "./src/context.tsx",
|
||||
"./secure": "./src/secure.ts"
|
||||
"./secure": "./src/secure.ts",
|
||||
"./browserSecure": "./src/browserSecure.ts",
|
||||
"./credentials": "./src/credentials.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
|
|
|
|||
40
packages/storage/src/browserSecure.ts
Normal file
40
packages/storage/src/browserSecure.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ import {
|
|||
|
||||
export type SaveOptions = { secure?: boolean };
|
||||
|
||||
interface StorageContextValue {
|
||||
export interface StorageContextValue {
|
||||
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
|
||||
save<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
|
|
|
|||
68
packages/storage/src/credentials.ts
Normal file
68
packages/storage/src/credentials.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -4,10 +4,7 @@ import { getDatabaseEntry, setDatabaseEntry } from "@tensamin/shared/indexedDb";
|
|||
|
||||
export type SecureStorageStatus = {
|
||||
backend:
|
||||
| "electron-keyring"
|
||||
| "application-storage"
|
||||
| "webcrypto"
|
||||
| "indexeddb";
|
||||
"electron-keyring" | "application-storage" | "webcrypto" | "indexeddb";
|
||||
secure: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
|
@ -71,7 +68,12 @@ async function getKey() {
|
|||
if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") {
|
||||
return null;
|
||||
}
|
||||
if (window.tensaminDesktop?.secureStorage) return loadElectronKey();
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.tensaminDesktop?.secureStorage
|
||||
) {
|
||||
return loadElectronKey();
|
||||
}
|
||||
return loadBrowserKey();
|
||||
})().catch(() => null);
|
||||
return keyPromise;
|
||||
|
|
@ -120,7 +122,10 @@ export async function decodeSecureValue(value: unknown): Promise<unknown> {
|
|||
}
|
||||
|
||||
export async function getSecureStorageStatus(): Promise<SecureStorageStatus> {
|
||||
const desktop = window.tensaminDesktop?.secureStorage;
|
||||
const desktop =
|
||||
typeof window === "undefined"
|
||||
? undefined
|
||||
: window.tensaminDesktop?.secureStorage;
|
||||
if (desktop?.getStatus) {
|
||||
const status = await desktop.getStatus();
|
||||
if (status.available) return { backend: "electron-keyring", secure: true };
|
||||
|
|
|
|||
3150
pnpm-lock.yaml
generated
3150
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue