From 790a1db7883cbf59be9ec78d5e98c5c47b080e8c Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 11 Jul 2026 22:08:01 +0200 Subject: [PATCH] (feat): add cache package (feat): improve local storage security (feat): move settings to dedicated settings package --- apps/electron/src/main/main.ts | 19 ++ apps/electron/src/main/secureStorage.ts | 130 +++++++++ apps/electron/src/preload/preload.ts | 27 ++ apps/electron/src/shared/ipc.ts | 15 + apps/web/package.json | 2 + apps/web/src/components/navbar.tsx | 2 - .../web/src/components/screens/login/form.tsx | 56 ++-- apps/web/src/index.tsx | 91 ++---- apps/web/src/routes/app/home.tsx | 26 +- apps/web/src/routes/settings/licenses.tsx | 144 --------- apps/web/src/routes/settings/profile.tsx | 218 -------------- apps/web/src/routes/settings/security.tsx | 93 ------ apps/web/src/routes/settings/theme.tsx | 20 -- apps/web/vite.config.ts | 2 + packages/cache/package.json | 25 ++ packages/cache/src/helpers.test.ts | 50 ++++ packages/cache/src/helpers.ts | 30 ++ packages/cache/src/index.ts | 276 ++++++++++++++++++ packages/cache/src/schemas.ts | 27 ++ packages/cache/src/sync.tsx | 271 +++++++++++++++++ packages/cache/tsconfig.json | 13 + packages/call/src/screenshare.ts | 41 +-- packages/chat/package.json | 1 + packages/chat/src/context.tsx | 84 ++++-- packages/chat/todo.md | 3 +- packages/mtp/src/context.tsx | 40 ++- packages/mtp/src/index.ts | 8 +- packages/settings/package.json | 30 ++ .../settings/src}/components.tsx | 92 ++---- packages/settings/src/index.tsx | 24 ++ .../settings/src}/layout.tsx | 46 +-- packages/settings/src/manifest.ts | 37 +++ packages/settings/src/pages/cache.tsx | 101 +++++++ .../settings/src/pages}/chat.tsx | 21 +- .../settings/src/pages}/index.tsx | 2 +- packages/settings/src/pages/licenses.tsx | 32 ++ packages/settings/src/pages/profile.tsx | 67 +++++ packages/settings/src/pages/security.tsx | 25 ++ packages/settings/src/pages/theme.tsx | 5 + packages/settings/tsconfig.json | 14 + packages/shared/package.json | 1 + packages/shared/src/data.ts | 4 + packages/shared/src/desktopMedia.tsx | 7 + packages/shared/src/indexedDb.ts | 98 +++++++ packages/shared/src/settings.ts | 1 + packages/storage/package.json | 3 +- packages/storage/src/context.tsx | 196 +++++++++---- packages/storage/src/indexed-db.ts | 92 ------ packages/storage/src/secure.ts | 142 +++++++++ packages/storage/src/session.tsx | 48 +-- packages/user/package.json | 1 + packages/user/src/context.tsx | 52 +++- pnpm-lock.yaml | 73 +++++ todo.md | 1 - 54 files changed, 1983 insertions(+), 946 deletions(-) create mode 100644 apps/electron/src/main/secureStorage.ts delete mode 100644 apps/web/src/routes/settings/licenses.tsx delete mode 100644 apps/web/src/routes/settings/profile.tsx delete mode 100644 apps/web/src/routes/settings/security.tsx delete mode 100644 apps/web/src/routes/settings/theme.tsx create mode 100644 packages/cache/package.json create mode 100644 packages/cache/src/helpers.test.ts create mode 100644 packages/cache/src/helpers.ts create mode 100644 packages/cache/src/index.ts create mode 100644 packages/cache/src/schemas.ts create mode 100644 packages/cache/src/sync.tsx create mode 100644 packages/cache/tsconfig.json create mode 100644 packages/settings/package.json rename {apps/web/src/features/settings => packages/settings/src}/components.tsx (59%) create mode 100644 packages/settings/src/index.tsx rename {apps/web/src/features/settings => packages/settings/src}/layout.tsx (53%) create mode 100644 packages/settings/src/manifest.ts create mode 100644 packages/settings/src/pages/cache.tsx rename {apps/web/src/routes/settings => packages/settings/src/pages}/chat.tsx (81%) rename {apps/web/src/routes/settings => packages/settings/src/pages}/index.tsx (71%) create mode 100644 packages/settings/src/pages/licenses.tsx create mode 100644 packages/settings/src/pages/profile.tsx create mode 100644 packages/settings/src/pages/security.tsx create mode 100644 packages/settings/src/pages/theme.tsx create mode 100644 packages/settings/tsconfig.json create mode 100644 packages/shared/src/indexedDb.ts delete mode 100644 packages/storage/src/indexed-db.ts create mode 100644 packages/storage/src/secure.ts diff --git a/apps/electron/src/main/main.ts b/apps/electron/src/main/main.ts index 91eaa9b..e3099f2 100644 --- a/apps/electron/src/main/main.ts +++ b/apps/electron/src/main/main.ts @@ -17,6 +17,13 @@ import { type DesktopScreenShareCapabilities, } from "../shared/ipc.js"; import { initTray, setTrayCallStatus } from "./tray.js"; +import { + clearSecureStorage, + deleteSecureStorage, + getSecureStorageStatus, + loadSecureStorage, + saveSecureStorage, +} from "./secureStorage.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const verbose = process.argv.includes("--verbose"); @@ -190,6 +197,18 @@ function registerIpc() { ); ipcMain.handle(ipcChannels.getVersion, () => app.getVersion()); ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates); + ipcMain.handle(ipcChannels.getSecureStorageStatus, getSecureStorageStatus); + ipcMain.handle(ipcChannels.loadSecureStorage, (_event, key: unknown) => + loadSecureStorage(key), + ); + ipcMain.handle( + ipcChannels.saveSecureStorage, + (_event, key: unknown, value: unknown) => saveSecureStorage(key, value), + ); + ipcMain.handle(ipcChannels.deleteSecureStorage, (_event, key: unknown) => + deleteSecureStorage(key), + ); + ipcMain.handle(ipcChannels.clearSecureStorage, clearSecureStorage); ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => { if ( typeof status !== "object" || diff --git a/apps/electron/src/main/secureStorage.ts b/apps/electron/src/main/secureStorage.ts new file mode 100644 index 0000000..b09117e --- /dev/null +++ b/apps/electron/src/main/secureStorage.ts @@ -0,0 +1,130 @@ +import { app, safeStorage } from "electron"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { + secureStorageLimits, + type DesktopSecureStorageStatus, +} from "../shared/ipc.js"; + +type StoredValues = Record; + +let pendingWrite = Promise.resolve(); + +function storagePath() { + return join(app.getPath("userData"), "secure-storage.json"); +} + +function validateKey(key: unknown): asserts key is string { + if ( + typeof key !== "string" || + key.length === 0 || + Buffer.byteLength(key, "utf8") > secureStorageLimits.maxKeyBytes + ) { + throw new Error("Invalid secure storage key."); + } +} + +function validateValue(value: unknown): asserts value is string { + if ( + typeof value !== "string" || + Buffer.byteLength(value, "utf8") > secureStorageLimits.maxValueBytes + ) { + throw new Error("Invalid secure storage value."); + } +} + +export function getSecureStorageStatus(): DesktopSecureStorageStatus { + if (!safeStorage.isEncryptionAvailable()) { + return { available: false, backend: null }; + } + + const backend = + process.platform === "linux" + ? safeStorage.getSelectedStorageBackend() + : process.platform === "darwin" + ? "keychain" + : process.platform === "win32" + ? "dpapi" + : null; + + return { + available: process.platform !== "linux" || backend !== "basic_text", + backend, + }; +} + +function requireAvailable() { + if (!getSecureStorageStatus().available) { + throw new Error("Secure storage is unavailable."); + } +} + +async function readValues(): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(storagePath(), "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Invalid secure storage data."); + } + + const values = parsed as Record; + if (Object.values(values).some((value) => typeof value !== "string")) { + throw new Error("Invalid secure storage data."); + } + return values as StoredValues; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw error; + } +} + +async function writeValues(values: StoredValues) { + const path = storagePath(); + const temporaryPath = `${path}.tmp`; + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFile(temporaryPath, JSON.stringify(values), { mode: 0o600 }); + await rename(temporaryPath, path); +} + +function mutateValues(mutation: (values: StoredValues) => void) { + const operation = pendingWrite.then(async () => { + const values = await readValues(); + mutation(values); + await writeValues(values); + }); + pendingWrite = operation.catch(() => undefined); + return operation; +} + +export async function loadSecureStorage(key: unknown): Promise { + requireAvailable(); + validateKey(key); + await pendingWrite; + const encrypted = (await readValues())[key]; + if (encrypted === undefined) return null; + return safeStorage.decryptString(Buffer.from(encrypted, "base64")); +} + +export function saveSecureStorage(key: unknown, value: unknown) { + requireAvailable(); + validateKey(key); + validateValue(value); + const encrypted = safeStorage.encryptString(value).toString("base64"); + return mutateValues((values) => { + values[key] = encrypted; + }); +} + +export function deleteSecureStorage(key: unknown) { + requireAvailable(); + validateKey(key); + return mutateValues((values) => { + delete values[key]; + }); +} + +export function clearSecureStorage() { + requireAvailable(); + return mutateValues((values) => { + for (const key of Object.keys(values)) delete values[key]; + }); +} diff --git a/apps/electron/src/preload/preload.ts b/apps/electron/src/preload/preload.ts index 6ecfad5..c6b61e4 100644 --- a/apps/electron/src/preload/preload.ts +++ b/apps/electron/src/preload/preload.ts @@ -3,12 +3,21 @@ import { ipcChannels, type DesktopCallStatus, type DesktopScreenShareSource, + secureStorageLimits, } from "../shared/ipc.js"; function windowAction(channel: string) { return () => ipcRenderer.invoke(channel); } +function validKey(key: string) { + return ( + typeof key === "string" && + key.length > 0 && + Buffer.byteLength(key, "utf8") <= secureStorageLimits.maxKeyBytes + ); +} + const desktopApi = { media: { listScreenShareSources: () => @@ -46,6 +55,24 @@ const desktopApi = { return ipcRenderer.invoke(ipcChannels.setCallStatus, status); }, }, + secureStorage: { + getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus), + load: (key: string) => + validKey(key) + ? ipcRenderer.invoke(ipcChannels.loadSecureStorage, key) + : Promise.reject(new Error("Invalid secure storage key.")), + save: (key: string, value: string) => + validKey(key) && + typeof value === "string" && + Buffer.byteLength(value, "utf8") <= secureStorageLimits.maxValueBytes + ? ipcRenderer.invoke(ipcChannels.saveSecureStorage, key, value) + : Promise.reject(new Error("Invalid secure storage key or value.")), + delete: (key: string) => + validKey(key) + ? ipcRenderer.invoke(ipcChannels.deleteSecureStorage, key) + : Promise.reject(new Error("Invalid secure storage key.")), + clear: () => ipcRenderer.invoke(ipcChannels.clearSecureStorage), + }, window: { minimize: () => windowAction(ipcChannels.minimizeWindow), maximize: () => windowAction(ipcChannels.maximizeWindow), diff --git a/apps/electron/src/shared/ipc.ts b/apps/electron/src/shared/ipc.ts index bec8688..0679349 100644 --- a/apps/electron/src/shared/ipc.ts +++ b/apps/electron/src/shared/ipc.ts @@ -26,6 +26,16 @@ export type DesktopCallStatus = { iconDataUrl?: string; }; +export type DesktopSecureStorageStatus = { + available: boolean; + backend: string | null; +}; + +export const secureStorageLimits = { + maxKeyBytes: 256, + maxValueBytes: 1024 * 1024, +} as const; + export type ReleaseArtifact = { name: string; platform: string; @@ -62,4 +72,9 @@ export const ipcChannels = { getVersion: "app:getVersion", checkForUpdates: "updates:checkForUpdates", setCallStatus: "call:setStatus", + getSecureStorageStatus: "secureStorage:getStatus", + loadSecureStorage: "secureStorage:load", + saveSecureStorage: "secureStorage:save", + deleteSecureStorage: "secureStorage:delete", + clearSecureStorage: "secureStorage:clear", } as const; diff --git a/apps/web/package.json b/apps/web/package.json index 0c1971b..dfa243a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,9 +50,11 @@ "@tanstack/react-virtual": "^3.13.24", "@tauri-apps/api": "^2", "@tensamin/call": "workspace:*", + "@tensamin/cache": "workspace:*", "@tensamin/chat": "workspace:*", "@tensamin/crypto": "workspace:*", "@tensamin/shared": "workspace:*", + "@tensamin/settings": "workspace:*", "@tensamin/storage": "workspace:*", "@tensamin/tauri": "workspace:*", "@tensamin/mtp": "workspace:*", diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 53fb740..692dd00 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -58,7 +58,6 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) { +
+
+ + +
+ {secureStorage && !secureStorage.secure && ( +
+ +
+

Secure storage is unavailable

+

{secureStorage.reason}

+

+ Your keyring and cached messages will get saved in regular + storage. +

+
+
+ )}
); } diff --git a/apps/web/src/routes/settings/licenses.tsx b/apps/web/src/routes/settings/licenses.tsx deleted file mode 100644 index 31293b1..0000000 --- a/apps/web/src/routes/settings/licenses.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { - Card, - CardContent, - CardTitle, - CardHeader, - CardDescription, - CardFooter, - Button, - Badge, - Dialog, - DialogContent, - DialogTrigger, -} from "@tensamin/ui"; -import { - packages, - packageCount, - generatedAt, -} from "../../../../../licenses/third-party-credits.json"; - -const licenseTexts = import.meta.glob("../../../../../licenses/**/*", { - eager: true, - import: "default", - query: "?raw", -}) as Record; - -function getLicenseFiles(licensePackage: (typeof packages)[number]) { - return licensePackage.files.map((fileName) => { - const path = - "../../../../../" + licensePackage.licenseFolder + "/" + fileName; - - return { - fileName, - text: licenseTexts[path], - }; - }); -} - -export default function Page() { - return ( -
-
-

Last generated: {generatedAt}

-

Package Count: {packageCount}

-
-
-
- {packages.map((licensePackage) => ( - - - - {licensePackage.license} {licensePackage.name}{" "} - {licensePackage.version} - - - {licensePackage.description && ( - - - {licensePackage.description} - - - )} - - - {licensePackage.repository ? ( - - - - ) : ( - - )} - {licensePackage.homepage ? ( - - - - ) : ( - - )} - - - ))} -
-
-
- ); -} - -function LicenseDialog({ - licensePackage, -}: { - licensePackage: (typeof packages)[number]; -}) { - const licenseFiles = getLicenseFiles(licensePackage); - const hasLicenseText = licenseFiles.some(({ text }) => text); - - return ( - - - Open License - - } - /> - -
- {licenseFiles.map(({ fileName, text }, index) => ( -
-

= 1 && "pt-2"}`} - > - {fileName} -

-
-                {text ||
-                  "License text could not be loaded. Please contact support@tensamin.net"}
-              
-
- ))} -
-
-
- ); -} diff --git a/apps/web/src/routes/settings/profile.tsx b/apps/web/src/routes/settings/profile.tsx deleted file mode 100644 index 5a88f82..0000000 --- a/apps/web/src/routes/settings/profile.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { useStorage } from "@tensamin/storage/context"; -import { - Avatar, - AvatarFallback, - AvatarImage, - Button, - cn, - Input, - useIsMobile, -} from "@tensamin/ui"; -import { useUser, type User } from "@tensamin/user/context"; -import { useEffect, useRef, useState } from "react"; -import MDInput from "@tensamin/markdown/input"; -import { mtp } from "@tensamin/shared/data"; -import { useMTP } from "@tensamin/mtp"; -import { Check } from "lucide-react"; - -async function prepImage( - file: File, - size = 300, - quality = 0.8, -): Promise { - const bitmap = await createImageBitmap(file); - - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("Could not get canvas context"); - - const scale = Math.max(size / bitmap.width, size / bitmap.height); - const width = bitmap.width * scale; - const height = bitmap.height * scale; - const x = (size - width) / 2; - const y = (size - height) / 2; - - ctx.drawImage(bitmap, x, y, width, height); - - return canvas.toDataURL("image/webp", quality); -} - -export default function Page() { - const { get } = useUser(); - const { load } = useStorage(); - const { send } = useMTP(); - const [currentUser, setCurrentUser] = useState(null); - const [draftUser, setDraftUser] = useState>({}); - const [errorMessage, setErrorMessage] = useState(""); - const [saveSucceeded, setSaveSucceeded] = useState(false); - const avatarUploadRef = useRef(null); - const draftInitializedRef = useRef(false); - const effectiveAvatar = - draftUser.Avatar === "none" ? undefined : draftUser.Avatar; - - const updateDraftUser = ( - updater: (previous: Partial) => Partial, - ) => { - setSaveSucceeded(false); - setErrorMessage(""); - setDraftUser(updater); - }; - - useEffect(() => { - const fetchUser = async () => { - const user = await get(await load("user_id")); - setCurrentUser(user); - }; - - fetchUser(); - }, [load, get]); - - useEffect(() => { - if (!currentUser || draftInitializedRef.current) return; - - setDraftUser(currentUser); - draftInitializedRef.current = true; - }, [currentUser]); - - const handleAvatarUpload = async (file: File) => { - const final = await prepImage(file); - updateDraftUser((prev) => ({ ...prev, avatar: final })); - if (avatarUploadRef.current) { - avatarUploadRef.current.value = ""; - } - }; - - const isMobile = useIsMobile(); - - return currentUser ? ( - <> - - e.target.files?.[0] && handleAvatarUpload(e.target.files[0]) - } - type="file" - /> -
-
- - - - {draftUser.Display?.slice(0, 2).toUpperCase() || - currentUser.Display.slice(0, 2).toUpperCase()} - - -
-

Avatar

-
- - -
-

- GIFs are supported in decentralised mode or with Tensamin Premium. -
- Maximum file size is 16mb. -

-
-
- - updateDraftUser((prev) => ({ - ...prev, - display: event.target.value, - })) - } - placeholder="Display Name" - value={draftUser.Display || ""} - /> - - updateDraftUser((prev) => ({ - ...prev, - username: event.target.value, - })) - } - placeholder="Username" - value={draftUser.Username || ""} - /> - - updateDraftUser((prev) => ({ ...prev, about: value })) - } - value={draftUser.About || ""} - /> - - {errorMessage && ( -

{errorMessage}

- )} -
- - ) : ( -

Loading...

- ); -} diff --git a/apps/web/src/routes/settings/security.tsx b/apps/web/src/routes/settings/security.tsx deleted file mode 100644 index 315d4dd..0000000 --- a/apps/web/src/routes/settings/security.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useStorage } from "@tensamin/storage/context"; -import { Button, Input, Label } from "@tensamin/ui"; -import { useEffect, useState } from "react"; - -export default function Page() { - const { save, load } = useStorage(); - const [draftOmegaUrl, setDraftOmegaUrl] = useState(""); - const [currentOmegaUrl, setCurrentOmegaUrl] = useState(""); - - const [draftForcedOmikronUrl, setDraftForcedOmikronUrl] = useState(""); - const [currentForcedOmikronUrl, setForcedForcedOmikronUrl] = useState(""); - const [draftForcedOmikronPublicKey, setDraftForcedOmikronPublicKey] = - useState(""); - const [currentForcedOmikronPublicKey, setForcedForcedOmikronPublicKey] = - useState(""); - - useEffect(() => { - load("omega_url").then((value) => { - setDraftOmegaUrl(value); - setCurrentOmegaUrl(value); - }); - load("forced_omikron_url").then((value) => { - setDraftForcedOmikronUrl(value || ""); - setForcedForcedOmikronUrl(value || ""); - }); - load("forced_omikron_public_key").then((value) => { - setDraftForcedOmikronPublicKey(value || ""); - setForcedForcedOmikronPublicKey(value || ""); - }); - }, [load]); - - return ( -
-

- It's best not to touch these! They can be exploited to gain access to - your account! -

-
- -
- setDraftOmegaUrl(e.target.value)} - /> - -
-
-
- -
- setDraftForcedOmikronUrl(e.target.value)} - /> - setDraftForcedOmikronPublicKey(e.target.value)} - /> - -
-
-
- ); -} diff --git a/apps/web/src/routes/settings/theme.tsx b/apps/web/src/routes/settings/theme.tsx deleted file mode 100644 index 2d512b3..0000000 --- a/apps/web/src/routes/settings/theme.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { StylePicker } from "@tensamin/ui"; - -export default function Page() { - return ( - - ); -} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index b0ea9d5..3e05688 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -85,6 +85,7 @@ export default defineConfig({ "@tanstack/router-core", "@tanstack/store", "@tensamin/crypto", + "@tensamin/settings", "@tensamin/storage", "@tensamin/mtp", "@tensamin/user", @@ -138,6 +139,7 @@ export default defineConfig({ "@tensamin/shared", "@tensamin/shared/data", "@tensamin/shared/log", + "@tensamin/settings", "@tensamin/storage", "@tensamin/storage/context", "@tensamin/tauri", diff --git a/packages/cache/package.json b/packages/cache/package.json new file mode 100644 index 0000000..b8397ed --- /dev/null +++ b/packages/cache/package.json @@ -0,0 +1,25 @@ +{ + "name": "@tensamin/cache", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./helpers": "./src/helpers.ts", + "./schemas": "./src/schemas.ts", + "./sync": "./src/sync.tsx" + }, + "scripts": { + "format": "pnpm exec prettier --write .", + "lint": "eslint src", + "test": "vitest run", + "build": "pnpm run test && tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@tensamin/mtp": "workspace:*", + "@tensamin/shared": "workspace:*", + "@tensamin/storage": "workspace:*", + "react": "^19.2.0", + "zod": "^4.3.6" + } +} diff --git a/packages/cache/src/helpers.test.ts b/packages/cache/src/helpers.test.ts new file mode 100644 index 0000000..035c285 --- /dev/null +++ b/packages/cache/src/helpers.test.ts @@ -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]); + }); +}); diff --git a/packages/cache/src/helpers.ts b/packages/cache/src/helpers.ts new file mode 100644 index 0000000..b7ec58a --- /dev/null +++ b/packages/cache/src/helpers.ts @@ -0,0 +1,30 @@ +import type { CachedMessage, ConversationWindow } from "./schemas"; + +export function trimMessages( + messages: readonly CachedMessage[], + limit: number, +): CachedMessage[] { + return [...messages] + .sort((a, b) => b.SendTime - a.SendTime) + .slice(0, limit) + .sort((a, b) => a.SendTime - b.SendTime); +} + +export function replaceConversation( + windows: readonly ConversationWindow[], + replacement: ConversationWindow, +): ConversationWindow[] { + return [ + replacement, + ...windows.filter((item) => item.UserId !== replacement.UserId), + ]; +} + +export function selectConversationWindows( + windows: readonly ConversationWindow[], + limit: number, +): ConversationWindow[] { + return [...windows] + .sort((a, b) => b.LastMessageAt - a.LastMessageAt || a.UserId - b.UserId) + .slice(0, limit); +} diff --git a/packages/cache/src/index.ts b/packages/cache/src/index.ts new file mode 100644 index 0000000..cd78102 --- /dev/null +++ b/packages/cache/src/index.ts @@ -0,0 +1,276 @@ +import type { z } from "zod"; +import { storageDefaults } from "@tensamin/shared/data"; +import { + deleteDatabaseEntry, + getDatabaseEntry, + listDatabaseEntries, + setDatabaseEntry, +} from "@tensamin/shared/indexedDb"; +import { + replaceConversation, + selectConversationWindows, + trimMessages, +} from "./helpers"; +import { + accountIdSchema, + contactsSchema, + conversationWindowSchema, + userProfileSchema, + type Contact, + type ConversationWindow, + type UserProfile, +} from "./schemas"; + +export * from "./helpers"; +export * from "./schemas"; + +type CacheStore = "contacts" | "profiles" | "conversations"; + +export interface SecureValueCodec { + encode(value: unknown): unknown | Promise; + decode(value: unknown): unknown | Promise; +} + +export interface CacheOptions { + codec?: SecureValueCodec; + contacts?: number; + messagesPerChat?: number; +} + +export interface CacheLifecycle { + clearAccount(): Promise; + close(): Promise; +} + +const identityCodec: SecureValueCodec = { + encode: (value) => value, + decode: (value) => value, +}; + +export function createCache(accountId: string, options: CacheOptions = {}) { + const account = accountIdSchema.parse(accountId); + const codec = options.codec ?? identityCodec; + const getLimits = async () => { + const [storedContacts, storedMessagesPerChat] = await Promise.all([ + getDatabaseEntry("storage", "cache_contacts"), + getDatabaseEntry("storage", "cache_messages_per_chat"), + ]); + return { + contacts: Math.max( + 0, + Math.floor( + options.contacts ?? + (typeof storedContacts === "number" + ? storedContacts + : storageDefaults.cache_contacts), + ), + ), + messagesPerChat: Math.max( + 0, + Math.floor( + options.messagesPerChat ?? + (typeof storedMessagesPerChat === "number" + ? storedMessagesPerChat + : storageDefaults.cache_messages_per_chat), + ), + ), + }; + }; + const prefix = `${account}:`; + const storedPrefix = (store: CacheStore) => `${store}:${prefix}`; + const storedKey = (store: CacheStore, key: string) => + `${storedPrefix(store)}${key}`; + const entries = async (store: CacheStore) => { + const storePrefix = storedPrefix(store); + return (await listDatabaseEntries("cache", storePrefix)).map( + ([key, value]) => [key.slice(storePrefix.length), value] as const, + ); + }; + let closed = false; + const ensureOpen = () => { + if (closed) throw new Error("Cache is closed"); + }; + const read = async ( + store: CacheStore, + key: string, + schema: z.ZodType, + ) => { + ensureOpen(); + const value = await getDatabaseEntry("cache", storedKey(store, key)); + return value === undefined + ? undefined + : schema.parse(await codec.decode(value)); + }; + const write = async ( + store: CacheStore, + key: string, + schema: z.ZodType, + value: T, + ) => { + ensureOpen(); + await setDatabaseEntry( + "cache", + storedKey(store, key), + await codec.encode(schema.parse(value)), + ); + }; + const remove = async (store: CacheStore, key: string) => { + ensureOpen(); + await deleteDatabaseEntry("cache", storedKey(store, key)); + }; + const listConversations = async () => { + ensureOpen(); + const storedEntries = await entries("conversations"); + const windows = await Promise.all( + storedEntries.map(async ([, value]) => + conversationWindowSchema.parse(await codec.decode(value)), + ), + ); + const { contacts } = await getLimits(); + return selectConversationWindows(windows, contacts); + }; + + return { + contacts: { + get: () => read("contacts", "authoritative", contactsSchema), + replace: (contacts: Contact[]) => + write("contacts", "authoritative", contactsSchema, contacts), + clear: () => remove("contacts", "authoritative"), + }, + profiles: { + get: (userId: number) => + read("profiles", String(userId), userProfileSchema), + put: (profile: UserProfile) => + write("profiles", String(profile.UserId), userProfileSchema, profile), + delete: (userId: number) => remove("profiles", String(userId)), + }, + conversations: { + list: listConversations, + get: (userId: number) => + read("conversations", String(userId), conversationWindowSchema), + replace: async (window: ConversationWindow) => { + const { contacts, messagesPerChat } = await getLimits(); + const candidate = conversationWindowSchema.parse({ + ...window, + Messages: trimMessages(window.Messages, messagesPerChat), + }); + const selected = selectConversationWindows( + replaceConversation(await listConversations(), candidate), + contacts, + ); + await Promise.all( + selected.map((item) => + write( + "conversations", + String(item.UserId), + conversationWindowSchema, + item, + ), + ), + ); + const retained = new Set(selected.map((item) => item.UserId)); + const storedEntries = await entries("conversations"); + await Promise.all( + storedEntries.flatMap(([key]) => { + const userId = Number(key); + return retained.has(userId) + ? [] + : [ + deleteDatabaseEntry( + "cache", + storedKey("conversations", key), + ), + ]; + }), + ); + }, + replaceSelected: async (windows: ConversationWindow[]) => { + const { contacts, messagesPerChat } = await getLimits(); + const selected = selectConversationWindows( + windows.map((window) => ({ + ...window, + Messages: trimMessages(window.Messages, messagesPerChat), + })), + contacts, + ); + await Promise.all( + selected.map((item) => + write( + "conversations", + String(item.UserId), + conversationWindowSchema, + item, + ), + ), + ); + const retained = new Set(selected.map((item) => item.UserId)); + const storedEntries = await entries("conversations"); + await Promise.all( + storedEntries.flatMap(([key]) => { + const userId = Number(key); + return retained.has(userId) + ? [] + : [ + deleteDatabaseEntry( + "cache", + storedKey("conversations", key), + ), + ]; + }), + ); + }, + prune: async () => { + const { messagesPerChat } = await getLimits(); + const windows = await listConversations(); + await Promise.all( + windows.map((window) => + write( + "conversations", + String(window.UserId), + conversationWindowSchema, + { + ...window, + Messages: trimMessages(window.Messages, messagesPerChat), + }, + ), + ), + ); + const retained = new Set(windows.map((window) => window.UserId)); + const storedEntries = await entries("conversations"); + await Promise.all( + storedEntries.flatMap(([key]) => + retained.has(Number(key)) + ? [] + : [ + deleteDatabaseEntry( + "cache", + storedKey("conversations", key), + ), + ], + ), + ); + }, + delete: (userId: number) => remove("conversations", String(userId)), + }, + clearAccount: async () => { + ensureOpen(); + await Promise.all( + (["contacts", "profiles", "conversations"] as CacheStore[]).map( + async (store) => { + const storedEntries = await entries(store); + await Promise.all( + storedEntries.map(([key]) => + deleteDatabaseEntry("cache", storedKey(store, key)), + ), + ); + }, + ), + ); + }, + close: async () => { + closed = true; + }, + }; +} + +export type Cache = ReturnType; diff --git a/packages/cache/src/schemas.ts b/packages/cache/src/schemas.ts new file mode 100644 index 0000000..268988c --- /dev/null +++ b/packages/cache/src/schemas.ts @@ -0,0 +1,27 @@ +import { mtp } from "@tensamin/shared/data"; +import { z } from "zod"; + +export const accountIdSchema = z.string().min(1); +export const contactSchema = z.object({ + LastMessageAt: z.number(), + UserId: z.number(), + LastMessage: z + .object({ Content: z.base64(), SenderId: z.number() }) + .optional(), + Messages: z.array(mtp.MessageGet.response), +}); +export const contactsSchema = z.array(contactSchema); +export const userProfileSchema = mtp.GetUserData.response; + +// Content remains the protocol base64 ciphertext. This package never decrypts messages. +export const cachedMessageSchema = mtp.MessageGet.response; +export const conversationWindowSchema = z.object({ + UserId: z.number(), + LastMessageAt: z.number(), + Messages: z.array(cachedMessageSchema), +}); + +export type Contact = z.infer; +export type UserProfile = z.infer; +export type CachedMessage = z.infer; +export type ConversationWindow = z.infer; diff --git a/packages/cache/src/sync.tsx b/packages/cache/src/sync.tsx new file mode 100644 index 0000000..85c7870 --- /dev/null +++ b/packages/cache/src/sync.tsx @@ -0,0 +1,271 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + createCache, + type CachedMessage, + type UserProfile, +} from "@tensamin/cache"; +import { useMTP, type MTPExchange, type ProtocolMessage } from "@tensamin/mtp"; +import { useStorage } from "@tensamin/storage/context"; +import { secureValueCodec } from "@tensamin/storage/secure"; + +function isError(message: ProtocolMessage) { + return message.type.startsWith("Error"); +} + +export default function CacheSync() { + const { addInterceptor, contextReady, freshContacts, subscribePush } = + useMTP(); + const { load } = useStorage(); + const [accountId, setAccountId] = useState(0); + const queueRef = useRef(Promise.resolve()); + const reactionPartnersRef = useRef(new Map()); + + useEffect(() => { + void load("user_id").then(setAccountId); + }, [load]); + + const enqueue = useCallback((operation: () => Promise) => { + const next = queueRef.current.then(operation); + queueRef.current = next.catch(() => undefined); + return next; + }, []); + + const secureCache = useCallback( + () => + createCache(String(accountId), { + codec: secureValueCodec, + }), + [accountId], + ); + + const replaceMessage = useCallback( + async ( + partnerId: number, + sendTime: number, + edit: Partial, + ) => { + const cache = secureCache(); + const window = await cache.conversations.get(partnerId); + if (!window) return; + await cache.conversations.replace({ + ...window, + Messages: window.Messages.map((message) => + message.SendTime === sendTime ? { ...message, ...edit } : message, + ), + }); + }, + [secureCache], + ); + + const insertMessage = useCallback( + async (partnerId: number, message: CachedMessage) => { + const cache = secureCache(); + const window = await cache.conversations.get(partnerId); + await cache.conversations.replace({ + UserId: partnerId, + LastMessageAt: Math.max(window?.LastMessageAt ?? 0, message.SendTime), + Messages: [ + ...(window?.Messages ?? []).filter( + (cached) => cached.SendTime !== message.SendTime, + ), + message, + ], + }); + }, + [secureCache], + ); + + const removeMessage = useCallback( + async (partnerId: number, sendTime: number) => { + const cache = secureCache(); + const window = await cache.conversations.get(partnerId); + if (!window) return; + await cache.conversations.replace({ + ...window, + Messages: window.Messages.filter( + (message) => message.SendTime !== sendTime, + ), + }); + }, + [secureCache], + ); + + useEffect(() => { + if (!accountId || !contextReady) return; + void enqueue(async () => { + const cache = secureCache(); + await cache.contacts.replace(freshContacts); + await cache.conversations.replaceSelected( + freshContacts.map((contact) => ({ + UserId: contact.UserId, + LastMessageAt: contact.LastMessageAt, + Messages: contact.Messages, + })), + ); + }); + }, [accountId, contextReady, enqueue, freshContacts, secureCache]); + + const synchronizeExchange = useCallback( + async ({ type, data, response }: MTPExchange) => { + if (!accountId || isError(response)) return; + const request = (data ?? {}) as Record; + const result = response.data as Record; + + if (type === "GetUserData") { + await createCache(String(accountId)).profiles.put( + result as unknown as UserProfile, + ); + return; + } + + if (type === "MessagesGet" && Number(request.Offset) === 0) { + const partnerId = Number(request.UserId); + const messages = result.Messages as CachedMessage[]; + const cache = secureCache(); + const previous = await cache.conversations.get(partnerId); + await cache.conversations.replace({ + UserId: partnerId, + LastMessageAt: Math.max( + previous?.LastMessageAt ?? 0, + ...messages.map((message) => message.SendTime), + ), + // This replacement is authoritative: absent server messages are deleted. + Messages: messages, + }); + return; + } + + if (type === "MessageSend") { + const partnerId = Number(request.ReceiverId); + await insertMessage(partnerId, { + Content: String(request.Content), + Files: request.Files as CachedMessage["Files"], + MessageState: "sent", + SenderId: accountId, + SendTime: Number(request.SendTime), + }); + return; + } + + if (type === "MessageEdit") { + await replaceMessage( + Number(request.ChatPartnerId), + Number(request.SendTime), + { Content: String(request.Content), Edited: true }, + ); + return; + } + + if (type === "MessageDelete") { + await removeMessage( + Number(request.ChatPartnerId), + Number(request.SendTime), + ); + return; + } + + if (type === "MessageReactionAdd" || type === "MessageReactionRemove") { + const partnerId = Number(request.ChatPartnerId); + const sendTime = Number(request.SendTime); + const reaction = String(request.Reaction); + const cache = secureCache(); + const window = await cache.conversations.get(partnerId); + const message = window?.Messages.find( + (candidate) => candidate.SendTime === sendTime, + ); + if (!message) return; + const reactions = (message.Reactions ?? []).filter( + (candidate) => + candidate.SenderId !== accountId || candidate.Reaction !== reaction, + ); + if (type === "MessageReactionAdd") { + reactions.push({ SenderId: accountId, Reaction: reaction }); + } + await replaceMessage(partnerId, sendTime, { Reactions: reactions }); + return; + } + + if (type === "MessageState") { + await replaceMessage( + Number(result.ChatPartnerId), + Number(result.SendTime), + { + MessageState: result.MessageState as CachedMessage["MessageState"], + }, + ); + return; + } + + if (type === "MessageGet") { + const message = result as unknown as CachedMessage; + const mappedPartner = reactionPartnersRef.current.get(message.SendTime); + reactionPartnersRef.current.delete(message.SendTime); + if (mappedPartner) { + await insertMessage(mappedPartner, message); + return; + } + const windows = await secureCache().conversations.list(); + const window = windows.find((candidate) => + candidate.Messages.some( + (cached) => cached.SendTime === message.SendTime, + ), + ); + if (window) await insertMessage(window.UserId, message); + } + }, + [accountId, insertMessage, removeMessage, replaceMessage, secureCache], + ); + + useEffect(() => { + if (!accountId) return; + return addInterceptor((exchange) => + enqueue(() => synchronizeExchange(exchange)), + ); + }, [accountId, addInterceptor, enqueue, synchronizeExchange]); + + const synchronizePush = useCallback( + async (message: ProtocolMessage) => { + if (!accountId || isError(message)) return; + const data = message.data as Record; + if (message.type === "MessageLive") { + await insertMessage( + Number(data.SenderId), + data.Message as CachedMessage, + ); + return; + } + if (message.type === "MessageEditLive") { + await replaceMessage( + Number(data.ChatPartnerId), + Number(data.SendTime), + { Content: String(data.Content), Edited: true }, + ); + return; + } + if (message.type === "MessageState") { + await replaceMessage( + Number(data.ChatPartnerId), + Number(data.SendTime), + { MessageState: data.MessageState as CachedMessage["MessageState"] }, + ); + return; + } + if (message.type === "MessageReactionLive") { + reactionPartnersRef.current.set( + Number(data.SendTime), + Number(data.ChatPartnerId), + ); + } + }, + [accountId, insertMessage, replaceMessage], + ); + + useEffect(() => { + if (!accountId || !contextReady) return; + return subscribePush((message) => { + void enqueue(() => synchronizePush(message)); + }); + }, [accountId, contextReady, enqueue, subscribePush, synchronizePush]); + + return null; +} diff --git a/packages/cache/tsconfig.json b/packages/cache/tsconfig.json new file mode 100644 index 0000000..2206f33 --- /dev/null +++ b/packages/cache/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} diff --git a/packages/call/src/screenshare.ts b/packages/call/src/screenshare.ts index 5046506..416b750 100644 --- a/packages/call/src/screenshare.ts +++ b/packages/call/src/screenshare.ts @@ -1,4 +1,5 @@ import { log } from "@tensamin/shared/log"; +import type {} from "@tensamin/shared/desktopMedia"; import { type LocalTrack, Room, @@ -21,46 +22,6 @@ type ScreenShareStoreSetState = ( | ((state: ScreenShareStoreState) => Partial), ) => void; -declare global { - interface Window { - tensaminDesktop?: { - media?: { - getScreenShareCapabilities?: () => Promise<{ - runtime?: "electron" | "tauri"; - platform: "linux" | "macos" | "windows" | "other"; - showAudioOutputSelector: boolean; - showAudioSwitch: boolean; - hasReliableSystemAudio: boolean; - }>; - listScreenShareSources?: () => Promise< - Array<{ - id: string; - kind: "screen" | "window"; - name: string; - subtitle?: string | null; - thumbnail?: string | null; - }> - >; - listScreenShareAudioOutputs?: () => Promise< - Array<{ - id: string; - name: string; - isDefault: boolean; - }> - >; - selectScreenShareSource?: (sourceId: string) => Promise; - }; - call?: { - setStatus?: (status: { - inCall: boolean; - speaking: boolean; - iconDataUrl?: string; - }) => Promise; - }; - }; - } -} - type ScreenShareControllerOptions = { room: Room; getState: () => ScreenShareStoreState; diff --git a/packages/chat/package.json b/packages/chat/package.json index f262acc..f98e87d 100644 --- a/packages/chat/package.json +++ b/packages/chat/package.json @@ -16,6 +16,7 @@ "build": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@tensamin/cache": "workspace:*", "@tanstack/pacer": "^0.21.1", "@tanstack/react-query": "^5.0.0", "@tanstack/react-router": "^1.0.0", diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 0136643..23c735c 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -27,6 +27,8 @@ import { useMTP } from "@tensamin/mtp"; import { log, toast } from "@tensamin/shared/log"; import { useSession } from "@tensamin/storage/session"; import { useUser } from "@tensamin/user/context"; +import { createCache } from "@tensamin/cache"; +import { secureValueCodec } from "@tensamin/storage/secure"; export const context = createContext(undefined); @@ -158,6 +160,7 @@ export default function Provider({ children }: { children: ReactNode }) { const [errorDescription, setErrorDescription] = useState(""); const [liveMessagesState, setLiveMessagesState] = useState([]); + const [ownId, setOwnId] = useState(0); const [currentChatSecretState, setCurrentChatSecretState] = useState<{ userId: number; value: Uint8Array | null; @@ -186,6 +189,10 @@ export default function Provider({ children }: { children: ReactNode }) { return currentChatSecretState.value; }, [currentChatSecretState, userIdValue]); + useEffect(() => { + load("user_id").then(setOwnId); + }, [load]); + useEffect(() => { if (!userIdValue) return; @@ -328,6 +335,44 @@ export default function Provider({ children }: { children: ReactNode }) { [load, send], ); + const decryptMessages = useCallback( + async (messages: RawMessages) => { + if (!currentChatSecret) return []; + return Promise.all( + messages.map(async (message) => { + try { + return { + ...message, + Content: await decryptChatText( + currentChatSecret, + message.Content, + ), + }; + } catch (err) { + log(1, "chat", "red", "Failed to decrypt historical message", err, { + SendTime: message.SendTime, + }); + return { + ...message, + Content: "Failed to decrypt message", + decryptionFailed: true, + }; + } + }), + ); + }, + [currentChatSecret], + ); + + const getCachedMessages = useCallback(async () => { + if (!ownId || !userIdValue || !currentChatSecret) return []; + const cache = createCache(String(ownId), { + codec: secureValueCodec, + }); + const window = await cache.conversations.get(userIdValue); + return decryptMessages(window?.Messages ?? []); + }, [currentChatSecret, decryptMessages, ownId, userIdValue]); + const getMessages = useCallback( async (amount: number, offset: number) => { if (!currentChatSecret) { @@ -359,36 +404,22 @@ export default function Provider({ children }: { children: ReactNode }) { }); } - return await Promise.all( - sorted.map(async (message) => { - try { - return { - ...message, - Content: await decryptChatText( - currentChatSecret, - message.Content, - ), - }; - } catch (err) { - log(1, "chat", "red", "Failed to decrypt historical message", err, { - SendTime: message.SendTime, - }); - return { - ...message, - Content: "Failed to decrypt message", - decryptionFailed: true, - }; - } - }), - ); + return decryptMessages(sorted); }, - [currentChatSecret, send, userIdValue], + [currentChatSecret, decryptMessages, send, userIdValue], ); - const [ownId, setOwnId] = useState(0); useEffect(() => { - load("user_id").then(setOwnId); - }, [load]); + if (!currentChatSecret || !ownId || !userIdValue) return; + const queryKey = ["chat-messages", String(userIdValue), true] as const; + void getCachedMessages().then((messages) => { + if (messages.length === 0 || queryClient.getQueryData(queryKey)) return; + queryClient.setQueryData>(queryKey, { + pages: [messages], + pageParams: [0], + }); + }); + }, [currentChatSecret, getCachedMessages, ownId, userIdValue]); const editMessage = useCallback( (sendTime: number, edit: MessageEdit) => { @@ -452,7 +483,6 @@ export default function Provider({ children }: { children: ReactNode }) { setLiveMessagesState((prev) => prev.filter((message) => message.SendTime !== sendTime), ); - const queryKey = [ "chat-messages", String(userIdValue), diff --git a/packages/chat/todo.md b/packages/chat/todo.md index fd2fa5f..2ce0e5f 100644 --- a/packages/chat/todo.md +++ b/packages/chat/todo.md @@ -6,6 +6,7 @@ - Placeholder image if media fails to load - Signature verifications via ed25519 key - Confirmation when exiting with text in the input box. -- Add arrow up hotkey to edit last message +- Add arrow up hotkey to edit last message (req: packages/hotkeys) - Drop any unique reactions above 10 - Make the emoji picker not get moved with the mini context menu +- Add proper loading skeleton diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index 1da68e3..7710510 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -60,6 +60,14 @@ export type BoundSendFn = ( export type PushHandler = (message: ProtocolMessage) => void; +export type MTPExchange = { + type: keyof Schemas & string; + data: unknown; + response: ProtocolMessage; +}; + +export type MTPInterceptor = (exchange: MTPExchange) => void | Promise; + type ContextType = { send: BoundSendFn; subscribe: ( @@ -67,6 +75,7 @@ type ContextType = { handler: (message: ProtocolMessage) => void, ) => () => void; subscribePush: (handler: PushHandler) => () => void; + addInterceptor: (interceptor: MTPInterceptor) => () => void; readyState: number; ownPing: number; iotaPing: number; @@ -153,6 +162,7 @@ export function Provider(props: { const clientRef = useRef> | null>( null, ); + const interceptorsRef = useRef(new Set()); const connected = readyState === ConnectionState.Connected; @@ -216,6 +226,11 @@ export function Provider(props: { }; }, []); + const addInterceptor = useCallback((interceptor: MTPInterceptor) => { + interceptorsRef.current.add(interceptor); + return () => interceptorsRef.current.delete(interceptor); + }, []); + // Custom Pings useEffect(() => { if (!connected || !identified) { @@ -284,7 +299,13 @@ export function Provider(props: { await MTPClient.init(); - const userId = await load("user_id"); + const [userId, keyring] = await Promise.all([ + load("user_id"), + load("mtp_keyring"), + ]); + if (!userId || !keyring) { + throw new Error("Missing login credentials"); + } const forcedOmikronUrl = await load("forced_omikron_url"); const forcedOmikronPublicKey = await load("forced_omikron_public_key"); @@ -338,7 +359,7 @@ export function Provider(props: { url, credentials: { clientId: userId, - keyring: base64ToUint8Array(await load("mtp_keyring")), + keyring: base64ToUint8Array(keyring), }, hostPublicKey: omikronPublicKey, descriptor: "client", @@ -396,6 +417,10 @@ export function Provider(props: { (message) => { try { unsubscribe(); + if (message.type.startsWith("Error")) { + reject(new Error(`Authentication failed: ${message.type}`)); + return; + } resolve(validateResponse("IdentificationResponse", message)); } catch (authPayloadError) { unsubscribe(); @@ -563,7 +588,15 @@ export function Provider(props: { const sendQueued: BoundSendFn = useMemo( () => async (type, data, options) => { const mtp = await mtpRef.get(); - return mtp.send(type, data, options); + const response = await mtp.send(type, data, options); + for (const interceptor of interceptorsRef.current) { + void Promise.resolve( + interceptor({ type, data, response: response as ProtocolMessage }), + ).catch((error) => { + log(1, "mtp", "yellow", "MTP interceptor failed", error, { type }); + }); + } + return response; }, [mtpRef], ); @@ -574,6 +607,7 @@ export function Provider(props: { send: sendQueued, subscribe, subscribePush, + addInterceptor, readyState, ownPing, iotaPing, diff --git a/packages/mtp/src/index.ts b/packages/mtp/src/index.ts index dff2352..39bce46 100644 --- a/packages/mtp/src/index.ts +++ b/packages/mtp/src/index.ts @@ -1,2 +1,8 @@ export { Provider, useMTP } from "./context"; -export type { BoundSendFn, PushHandler, ProtocolMessage } from "./context"; +export type { + BoundSendFn, + MTPExchange, + MTPInterceptor, + PushHandler, + ProtocolMessage, +} from "./context"; diff --git a/packages/settings/package.json b/packages/settings/package.json new file mode 100644 index 0000000..ec935a1 --- /dev/null +++ b/packages/settings/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tensamin/settings", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.tsx" + }, + "scripts": { + "format": "pnpm exec prettier --write .", + "lint": "eslint src", + "build": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@tanstack/react-router": "^1.169.1", + "@tensamin/cache": "workspace:*", + "@tensamin/markdown": "workspace:*", + "@tensamin/mtp": "workspace:*", + "@tensamin/shared": "workspace:*", + "@tensamin/storage": "workspace:*", + "@tensamin/ui": "*", + "@tensamin/user": "workspace:*", + "lucide-react": "^1.14.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "vite": "^8.0.10" + } +} diff --git a/apps/web/src/features/settings/components.tsx b/packages/settings/src/components.tsx similarity index 59% rename from apps/web/src/features/settings/components.tsx rename to packages/settings/src/components.tsx index 491b839..201d4dd 100644 --- a/apps/web/src/features/settings/components.tsx +++ b/packages/settings/src/components.tsx @@ -26,10 +26,7 @@ type ListStorageKey = { type ListStorageItem = Storage[K] extends Array ? Item : never; -export function Switch({ - label, - id, -}: { +export function Switch({ label, id }: { label: React.ReactNode; id: keyof typeof settingsStorageDefaults & BooleanStorageKey; }) { @@ -42,23 +39,16 @@ export function Switch({ return (
- { - setValue(value); - save(id, value); - }} - /> + { + setValue(nextValue); + save(id, nextValue); + }} />
); } -export function List({ - label, - id, -}: { +export function List({ label, id }: { label: React.ReactNode; id: K; }) { @@ -78,30 +68,20 @@ export function List({ const toStorageItem = (value: string): ListStorageItem => { const referenceItem = items[0] ?? storageDefaults[id][0]; - - if (typeof referenceItem === "number") { - return Number(value) as ListStorageItem; - } - - return value as ListStorageItem; + return (typeof referenceItem === "number" ? Number(value) : value) as ListStorageItem; }; const addItem = () => { const trimmedValue = inputValue.trim(); if (!trimmedValue) return; - const nextItem = toStorageItem(trimmedValue); if (typeof nextItem === "number" && Number.isNaN(nextItem)) return; - persistItems([...items, nextItem] as Storage[K]); setInputValue(""); }; const deleteItems = (indexes: Set) => { - const nextItems = items.filter( - (_, index) => !indexes.has(index), - ) as Storage[K]; - + const nextItems = items.filter((_, index) => !indexes.has(index)) as Storage[K]; setItems(nextItems); setSelectedItems(new Set()); save(id, nextItems); @@ -112,13 +92,9 @@ export function List({
- setInputValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") addItem(); - }} - /> + setInputValue(event.target.value)} onKeyDown={(event) => { + if (event.key === "Enter") addItem(); + }} />
@@ -126,45 +102,19 @@ export function List({ const labelId = `${String(id)}-${index}`; const selected = selectedItems.has(index); const deletingSelectedItems = selectedItems.size > 1; - return ( - - { - setSelectedItems((previous) => { - const nextSelected = new Set(previous); - - if (checked) { - nextSelected.add(index); - } else { - nextSelected.delete(index); - } - - return nextSelected; - }); - }} - /> - -
-
- } - /> + + setSelectedItems((previous) => { + const nextSelected = new Set(previous); + if (checked) nextSelected.add(index); + else nextSelected.delete(index); + return nextSelected; + })} /> +
+
} /> - - deleteItems( - deletingSelectedItems - ? selectedItems - : new Set([index]), - ) - } - > + deleteItems(deletingSelectedItems ? selectedItems : new Set([index]))}> {deletingSelectedItems ? "Delete Selected" : "Delete"} diff --git a/packages/settings/src/index.tsx b/packages/settings/src/index.tsx new file mode 100644 index 0000000..9315658 --- /dev/null +++ b/packages/settings/src/index.tsx @@ -0,0 +1,24 @@ +import { createRoute, type AnyRoute } from "@tanstack/react-router"; + +import SettingsLayout from "./layout"; +import { settingsPages } from "./manifest"; + +export function createSettingsRoute(parentRoute: AnyRoute) { + const settingsRoute = createRoute({ + getParentRoute: () => parentRoute, + path: "settings", + component: SettingsLayout, + staticData: { showMobileNavbar: true }, + }); + + return settingsRoute.addChildren( + settingsPages.map((page) => + createRoute({ + getParentRoute: () => settingsRoute, + path: page.path, + component: page.component, + staticData: { showMobileNavbar: true }, + }), + ), + ); +} diff --git a/apps/web/src/features/settings/layout.tsx b/packages/settings/src/layout.tsx similarity index 53% rename from apps/web/src/features/settings/layout.tsx rename to packages/settings/src/layout.tsx index 36d366a..1864d1b 100644 --- a/apps/web/src/features/settings/layout.tsx +++ b/packages/settings/src/layout.tsx @@ -1,25 +1,22 @@ -import { Button, ClearStorageButton } from "@tensamin/ui"; - -import options from "@tensamin/shared/settings"; -import { cn, useIsMobile } from "@tensamin/ui"; import { Outlet, useLocation, useNavigate } from "@tanstack/react-router"; +import { Button, ClearStorageButton, cn, useIsMobile } from "@tensamin/ui"; -export default function Screen() { +import { settingsNavigation } from "./manifest"; + +export default function SettingsLayout() { const isMobile = useIsMobile(); const location = useLocation(); return (
{!isMobile && } - - {/* Page */}

{location.pathname .split("/") .pop() ?.replace(/-/g, " ") - .replace(/\b\w/g, (l) => l.toUpperCase())} + .replace(/\b\w/g, (letter) => letter.toUpperCase())}

@@ -28,45 +25,32 @@ export default function Screen() { } export function SettingsSidebar() { - const settingsOptions = options as Record< - string, - Record> - >; - const isMobile = useIsMobile(); const navigate = useNavigate(); + const categories = [...new Set(settingsNavigation.map((page) => page.category))]; return (
- {/* Settings */} - {Object.keys(settingsOptions).map((category) => ( - // Category + {categories.map((category) => (

{category}

- {Object.keys(settingsOptions[category]).map((page) => ( - // Page -
+ {settingsNavigation + .filter((page) => page.category === category) + .map((page) => ( -
- ))} + ))}
))}
diff --git a/packages/settings/src/manifest.ts b/packages/settings/src/manifest.ts new file mode 100644 index 0000000..38a404f --- /dev/null +++ b/packages/settings/src/manifest.ts @@ -0,0 +1,37 @@ +import Cache from "./pages/cache"; +import Chat from "./pages/chat"; +import Index from "./pages/index"; +import Licenses from "./pages/licenses"; +import Profile from "./pages/profile"; +import Security from "./pages/security"; +import Theme from "./pages/theme"; + +export const settingsPages = [ + { path: "/", component: Index }, + { category: "general", path: "chat", label: "Chat", component: Chat }, + { + category: "account", + path: "profile", + label: "Profile", + component: Profile, + }, + { + category: "account", + path: "security", + label: "Security", + component: Security, + }, + { category: "application", path: "cache", label: "Cache", component: Cache }, + { category: "application", path: "theme", label: "Theme", component: Theme }, + { + category: "application", + path: "licenses", + label: "Licenses", + component: Licenses, + }, +] as const; + +export const settingsNavigation = settingsPages.filter( + (page): page is Exclude<(typeof settingsPages)[number], { path: "/" }> => + page.path !== "/", +); diff --git a/packages/settings/src/pages/cache.tsx b/packages/settings/src/pages/cache.tsx new file mode 100644 index 0000000..2d189ec --- /dev/null +++ b/packages/settings/src/pages/cache.tsx @@ -0,0 +1,101 @@ +import { createCache } from "@tensamin/cache"; +import { storageDefaults } from "@tensamin/shared/data"; +import { useStorage } from "@tensamin/storage/context"; +import { secureValueCodec } from "@tensamin/storage/secure"; +import { Button, Input, Label } from "@tensamin/ui"; +import { useEffect, useState } from "react"; + +const validLimit = (value: number) => Number.isSafeInteger(value) && value >= 0; + +export default function Page() { + const { load, save } = useStorage(); + const [contacts, setContacts] = useState(storageDefaults.cache_contacts); + const [messagesPerChat, setMessagesPerChat] = useState( + storageDefaults.cache_messages_per_chat, + ); + const [savedContacts, setSavedContacts] = useState(contacts); + const [savedMessagesPerChat, setSavedMessagesPerChat] = + useState(messagesPerChat); + const [saving, setSaving] = useState(false); + useEffect(() => { + void Promise.all([ + load("cache_contacts"), + load("cache_messages_per_chat"), + ]).then(([nextContacts, nextMessages]) => { + setContacts(nextContacts); + setSavedContacts(nextContacts); + setMessagesPerChat(nextMessages); + setSavedMessagesPerChat(nextMessages); + }); + }, [load]); + const valid = validLimit(contacts) && validLimit(messagesPerChat); + const changed = + contacts !== savedContacts || messagesPerChat !== savedMessagesPerChat; + async function persist() { + if (!valid || saving) return; + setSaving(true); + try { + await Promise.all([ + save("cache_contacts", contacts), + save("cache_messages_per_chat", messagesPerChat), + ]); + const accountId = await load("user_id"); + if (accountId) + await createCache(String(accountId), { + codec: secureValueCodec, + }).conversations.prune(); + setSavedContacts(contacts); + setSavedMessagesPerChat(messagesPerChat); + } finally { + setSaving(false); + } + } + return ( +
+
+ + setContacts(event.currentTarget.valueAsNumber)} + /> +
+
+ + + setMessagesPerChat(event.currentTarget.valueAsNumber) + } + /> +
+ {!valid && ( +

+ Cache limits must be whole numbers greater than or equal to 0. +

+ )} +
+ + +
+
+ ); +} diff --git a/apps/web/src/routes/settings/chat.tsx b/packages/settings/src/pages/chat.tsx similarity index 81% rename from apps/web/src/routes/settings/chat.tsx rename to packages/settings/src/pages/chat.tsx index b7a582c..e8b9018 100644 --- a/apps/web/src/routes/settings/chat.tsx +++ b/packages/settings/src/pages/chat.tsx @@ -1,11 +1,10 @@ -import { List, Switch } from "@/features/settings/components"; -import { Button, Kbd } from "@tensamin/ui"; -import { useStorage } from "@tensamin/storage/context"; import { storageDefaults } from "@tensamin/shared/data"; +import { useStorage } from "@tensamin/storage/context"; +import { Button, Kbd } from "@tensamin/ui"; +import { List, Switch } from "../components"; export default function Page() { const { save } = useStorage(); - return (
- +
+ +
); diff --git a/apps/web/src/routes/settings/index.tsx b/packages/settings/src/pages/index.tsx similarity index 71% rename from apps/web/src/routes/settings/index.tsx rename to packages/settings/src/pages/index.tsx index 568d53f..e691f6c 100644 --- a/apps/web/src/routes/settings/index.tsx +++ b/packages/settings/src/pages/index.tsx @@ -1,5 +1,5 @@ -import { SettingsSidebar } from "@/features/settings/layout"; import { useIsMobile } from "@tensamin/ui"; +import { SettingsSidebar } from "../layout"; export default function Page() { const isMobile = useIsMobile(); diff --git a/packages/settings/src/pages/licenses.tsx b/packages/settings/src/pages/licenses.tsx new file mode 100644 index 0000000..c1a06d2 --- /dev/null +++ b/packages/settings/src/pages/licenses.tsx @@ -0,0 +1,32 @@ +import { Badge, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Dialog, DialogContent, DialogTrigger } from "@tensamin/ui"; +import { generatedAt, packageCount, packages } from "../../../../licenses/third-party-credits.json"; + +const licenseTexts = import.meta.glob("../../../../licenses/**/*", { eager: true, import: "default", query: "?raw" }) as Record; + +function getLicenseFiles(licensePackage: (typeof packages)[number]) { + return licensePackage.files.map((fileName) => ({ + fileName, + text: licenseTexts["../../../../" + licensePackage.licenseFolder + "/" + fileName], + })); +} + +export default function Page() { + return
+

Last generated: {generatedAt}

Package Count: {packageCount}

+
+ {packages.map((licensePackage) => + {licensePackage.license} {licensePackage.name} {licensePackage.version} + {licensePackage.description && {licensePackage.description}} + + {licensePackage.repository ? : } + {licensePackage.homepage ? : } + + )} +
+
; +} + +function LicenseDialog({ licensePackage }: { licensePackage: (typeof packages)[number] }) { + const licenseFiles = getLicenseFiles(licensePackage); + return text)} className="cursor-pointer">Open License} />
{licenseFiles.map(({ fileName, text }, index) =>

= 1 && "pt-2"}`}>{fileName}

{text || "License text could not be loaded. Please contact support@tensamin.net"}
)}
; +} diff --git a/packages/settings/src/pages/profile.tsx b/packages/settings/src/pages/profile.tsx new file mode 100644 index 0000000..8e33dec --- /dev/null +++ b/packages/settings/src/pages/profile.tsx @@ -0,0 +1,67 @@ +import MDInput from "@tensamin/markdown/input"; +import { useMTP } from "@tensamin/mtp"; +import { mtp } from "@tensamin/shared/data"; +import { useStorage } from "@tensamin/storage/context"; +import { Avatar, AvatarFallback, AvatarImage, Button, cn, Input, useIsMobile } from "@tensamin/ui"; +import { useUser, type User } from "@tensamin/user/context"; +import { Check } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +async function prepImage(file: File, size = 300, quality = 0.8): Promise { + const bitmap = await createImageBitmap(file); + const canvas = document.createElement("canvas"); + canvas.width = size; canvas.height = size; + const context = canvas.getContext("2d"); + if (!context) throw new Error("Could not get canvas context"); + const scale = Math.max(size / bitmap.width, size / bitmap.height); + const width = bitmap.width * scale; + const height = bitmap.height * scale; + context.drawImage(bitmap, (size - width) / 2, (size - height) / 2, width, height); + return canvas.toDataURL("image/webp", quality); +} + +export default function Page() { + const { get } = useUser(); + const { load } = useStorage(); + const { send } = useMTP(); + const isMobile = useIsMobile(); + const [currentUser, setCurrentUser] = useState(null); + const [draftUser, setDraftUser] = useState>({}); + const [errorMessage, setErrorMessage] = useState(""); + const [saveSucceeded, setSaveSucceeded] = useState(false); + const avatarUploadRef = useRef(null); + const draftInitializedRef = useRef(false); + const effectiveAvatar = draftUser.Avatar === "none" ? undefined : draftUser.Avatar; + const updateDraftUser = (updater: (previous: Partial) => Partial) => { + setSaveSucceeded(false); setErrorMessage(""); setDraftUser(updater); + }; + useEffect(() => { void (async () => setCurrentUser(await get(await load("user_id"))))(); }, [get, load]); + useEffect(() => { + if (!currentUser || draftInitializedRef.current) return; + setDraftUser(currentUser); draftInitializedRef.current = true; + }, [currentUser]); + async function handleAvatarUpload(file: File) { + const avatar = await prepImage(file); + updateDraftUser((previous) => ({ ...previous, avatar })); + if (avatarUploadRef.current) avatarUploadRef.current.value = ""; + } + if (!currentUser) return

Loading...

; + return <> + event.target.files?.[0] && handleAvatarUpload(event.target.files[0])} type="file" /> +
+
{draftUser.Display?.slice(0, 2).toUpperCase() || currentUser.Display.slice(0, 2).toUpperCase()}

Avatar

GIFs are supported in decentralised mode or with Tensamin Premium.
Maximum file size is 16mb.

+ updateDraftUser((previous) => ({ ...previous, display: event.target.value }))} placeholder="Display Name" value={draftUser.Display || ""} /> + updateDraftUser((previous) => ({ ...previous, username: event.target.value }))} placeholder="Username" value={draftUser.Username || ""} /> + updateDraftUser((previous) => ({ ...previous, about: value }))} value={draftUser.About || ""} /> + + {errorMessage &&

{errorMessage}

} +
+ ; +} diff --git a/packages/settings/src/pages/security.tsx b/packages/settings/src/pages/security.tsx new file mode 100644 index 0000000..d339b3a --- /dev/null +++ b/packages/settings/src/pages/security.tsx @@ -0,0 +1,25 @@ +import { useStorage } from "@tensamin/storage/context"; +import { Button, Input, Label } from "@tensamin/ui"; +import { useEffect, useState } from "react"; + +export default function Page() { + const { save, load } = useStorage(); + const [draftOmegaUrl, setDraftOmegaUrl] = useState(""); + const [currentOmegaUrl, setCurrentOmegaUrl] = useState(""); + const [draftForcedOmikronUrl, setDraftForcedOmikronUrl] = useState(""); + const [currentForcedOmikronUrl, setCurrentForcedOmikronUrl] = useState(""); + const [draftForcedOmikronPublicKey, setDraftForcedOmikronPublicKey] = useState(""); + const [currentForcedOmikronPublicKey, setCurrentForcedOmikronPublicKey] = useState(""); + + useEffect(() => { + load("omega_url").then((value) => { setDraftOmegaUrl(value); setCurrentOmegaUrl(value); }); + load("forced_omikron_url").then((value) => { setDraftForcedOmikronUrl(value || ""); setCurrentForcedOmikronUrl(value || ""); }); + load("forced_omikron_public_key").then((value) => { setDraftForcedOmikronPublicKey(value || ""); setCurrentForcedOmikronPublicKey(value || ""); }); + }, [load]); + + return
+

It's best not to touch these settings! They can be exploited to gain access to your account!

+
setDraftOmegaUrl(event.target.value)} />
+
setDraftForcedOmikronUrl(event.target.value)} /> setDraftForcedOmikronPublicKey(event.target.value)} />
+
; +} diff --git a/packages/settings/src/pages/theme.tsx b/packages/settings/src/pages/theme.tsx new file mode 100644 index 0000000..a0cfab2 --- /dev/null +++ b/packages/settings/src/pages/theme.tsx @@ -0,0 +1,5 @@ +import { StylePicker } from "@tensamin/ui"; + +export default function Page() { + return ; +} diff --git a/packages/settings/tsconfig.json b/packages/settings/tsconfig.json new file mode 100644 index 0000000..ce5e465 --- /dev/null +++ b/packages/settings/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 0b34da5..ec5b322 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -9,6 +9,7 @@ "./data": "./src/data.ts", "./desktopMedia": "./src/desktopMedia.tsx", "./log": "./src/log.tsx", + "./indexedDb": "./src/indexedDb.ts", "./settings": "./src/settings.ts", "./features/legal/schema": "./src/features/legal/schema.ts", "./features/conversation/schema": "./src/features/conversation/schema.ts" diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 2e69a35..907f8a4 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -423,6 +423,8 @@ export interface Storage extends SettingsStorageDefaults { legal_docs: z.infer; cached_contacts: Contacts; cached_communities: Communities; + cache_contacts: number; + cache_messages_per_chat: number; omega_url: string; forced_omikron_url: string | undefined; forced_omikron_public_key: string | undefined; @@ -475,6 +477,8 @@ export const storageDefaults: Storage = { }, cached_contacts: [], cached_communities: [], + cache_contacts: 5, + cache_messages_per_chat: 20, omega_url: "https://omega.tensamin.net", forced_omikron_url: undefined, forced_omikron_public_key: undefined, diff --git a/packages/shared/src/desktopMedia.tsx b/packages/shared/src/desktopMedia.tsx index ec26bb6..32ecb74 100644 --- a/packages/shared/src/desktopMedia.tsx +++ b/packages/shared/src/desktopMedia.tsx @@ -38,6 +38,13 @@ type ElectronDesktopApi = { iconDataUrl?: string; }) => Promise; }; + secureStorage?: { + getStatus?: () => Promise<{ available: boolean; backend: string | null }>; + load?: (key: string) => Promise; + save?: (key: string, value: string) => Promise; + delete?: (key: string) => Promise; + clear?: () => Promise; + }; }; declare global { diff --git a/packages/shared/src/indexedDb.ts b/packages/shared/src/indexedDb.ts new file mode 100644 index 0000000..c043787 --- /dev/null +++ b/packages/shared/src/indexedDb.ts @@ -0,0 +1,98 @@ +export const TENSAMIN_DB_NAME = "tensamin"; +export const TENSAMIN_DB_VERSION = 1; +export type TensaminStore = "storage" | "cache" | "keys"; + +const stores: TensaminStore[] = ["storage", "cache", "keys"]; +let databasePromise: Promise | undefined; + +export function openTensaminDatabase( + indexedDb: IDBFactory = globalThis.indexedDB, +) { + databasePromise ??= new Promise((resolve, reject) => { + const request = indexedDb.open(TENSAMIN_DB_NAME, TENSAMIN_DB_VERSION); + request.onupgradeneeded = () => { + for (const store of stores) { + if (!request.result.objectStoreNames.contains(store)) { + request.result.createObjectStore(store); + } + } + }; + request.onsuccess = () => { + request.result.onversionchange = () => request.result.close(); + resolve(request.result); + }; + request.onerror = () => { + databasePromise = undefined; + reject(request.error); + }; + request.onblocked = () => { + databasePromise = undefined; + reject(new Error("The Tensamin database upgrade is blocked.")); + }; + }); + return databasePromise; +} + +export async function getDatabaseEntry(store: TensaminStore, key: string) { + const database = await openTensaminDatabase(); + return new Promise((resolve, reject) => { + const request = database + .transaction(store, "readonly") + .objectStore(store) + .get(key); + request.onsuccess = () => resolve(request.result as T | undefined); + request.onerror = () => reject(request.error); + }); +} + +export async function setDatabaseEntry( + store: TensaminStore, + key: string, + value: unknown, +) { + const database = await openTensaminDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(store, "readwrite"); + transaction.objectStore(store).put(value, key); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); +} + +export async function deleteDatabaseEntry(store: TensaminStore, key: string) { + const database = await openTensaminDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(store, "readwrite"); + transaction.objectStore(store).delete(key); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); +} + +export async function listDatabaseEntries( + store: TensaminStore, + keyPrefix: string, +) { + const database = await openTensaminDatabase(); + return new Promise>((resolve, reject) => { + const entries: Array<[string, unknown]> = []; + const cursor = database + .transaction(store, "readonly") + .objectStore(store) + .openCursor(); + cursor.onsuccess = () => { + const value = cursor.result; + if (!value) { + resolve(entries); + return; + } + if (typeof value.key === "string" && value.key.startsWith(keyPrefix)) { + entries.push([value.key, value.value]); + } + value.continue(); + }; + cursor.onerror = () => reject(cursor.error); + }); +} diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts index 6739280..26ed299 100644 --- a/packages/shared/src/settings.ts +++ b/packages/shared/src/settings.ts @@ -46,6 +46,7 @@ const settings = { }, }, application: { + cache: {}, theme: {}, licenses: {}, }, diff --git a/packages/storage/package.json b/packages/storage/package.json index 00da001..416af70 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -6,7 +6,7 @@ "exports": { "./session": "./src/session.tsx", "./context": "./src/context.tsx", - "./indexed-db": "./src/indexed-db.ts" + "./secure": "./src/secure.ts" }, "scripts": { "format": "pnpm exec prettier --write .", @@ -14,6 +14,7 @@ "build": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@tensamin/cache": "workspace:*", "@tensamin/shared": "workspace:*", "@tensamin/mtp": "workspace:*", "@tensamin/ui": "*", diff --git a/packages/storage/src/context.tsx b/packages/storage/src/context.tsx index d25cf2e..d5cbca1 100644 --- a/packages/storage/src/context.tsx +++ b/packages/storage/src/context.tsx @@ -3,17 +3,32 @@ import { type Storage as StorageSchema, storageDefaults as defaults, } from "@tensamin/shared/data"; -import { getEntry, setEntry, deleteEntry } from "./indexed-db"; +import { + deleteDatabaseEntry, + getDatabaseEntry, + setDatabaseEntry, +} from "@tensamin/shared/indexedDb"; import { ErrorScreen } from "@tensamin/ui"; import { log } from "@tensamin/shared/log"; +import { + decodeSecureValue, + encodeSecureValue, + getSecureStorageStatus, + isSecureEnvelope, + type SecureStorageStatus, +} from "./secure"; + +export type SaveOptions = { secure?: boolean }; interface StorageContextValue { load(key: K): Promise; save( key: K, value: StorageSchema[K], + options?: SaveOptions, ): Promise; clear: () => Promise; + secureStorage: SecureStorageStatus | null; } const StorageContext = React.createContext( @@ -30,96 +45,151 @@ const isIndexedDBSupported = typeof indexedDB !== "undefined"; export default function StorageProvider(props: { children: React.ReactNode }) { const [storage, setStorage] = React.useState(defaults); const storageRef = React.useRef(storage); + const loadedKeys = React.useRef(new Set()); + const loadPromises = React.useRef( + new Map>(), + ); + const generations = React.useRef(new Map()); + const [secureStorage, setSecureStorage] = + React.useState(null); + const secureStorageRef = React.useRef(null); const [error, setError] = React.useState(""); const [errorDescription, setErrorDescription] = React.useState(""); React.useEffect(() => { - storageRef.current = storage; - }, [storage]); + void getSecureStorageStatus().then((status) => { + secureStorageRef.current = status; + setSecureStorage(status); + }); + }, []); - const loadIO = React.useCallback( + const commit = React.useCallback( + (key: K, nextValue: StorageSchema[K]) => { + const next = { ...storageRef.current, [key]: nextValue }; + storageRef.current = next; + loadedKeys.current.add(key); + setStorage(next); + }, + [], + ); + + const load = React.useCallback( async ( key: K, ): Promise => { - let stored: StorageSchema[K] | undefined; + if (loadedKeys.current.has(key)) return storageRef.current[key]; + const pending = loadPromises.current.get(key); + if (pending) return pending as Promise; - try { - stored = await getEntry(key); - } catch (err) { - setError("Failed to load data"); - setErrorDescription( - "An error occurred while loading data from IndexedDB. Please try again.", - ); - log(0, "Storage", "red", err); - } - - if (stored !== undefined) { - setStorage((prev) => ({ ...prev, [key]: stored })); - return stored; - } - - return defaults[key]; + const generation = generations.current.get(key) ?? 0; + const request = (async () => { + try { + const desktopStorage = window.tensaminDesktop?.secureStorage; + const desktopStatus = desktopStorage?.getStatus + ? await desktopStorage.getStatus() + : null; + const desktopValue = + desktopStatus?.available && desktopStorage?.load + ? await desktopStorage.load(String(key)) + : null; + let stored = + desktopValue === null + ? await getDatabaseEntry("storage", key) + : (JSON.parse(desktopValue) as StorageSchema[K]); + if (isSecureEnvelope(stored)) { + stored = (await decodeSecureValue(stored)) as StorageSchema[K]; + } + const value = stored ?? defaults[key]; + if ((generations.current.get(key) ?? 0) === generation) { + commit(key, value); + } + return (generations.current.get(key) ?? 0) === generation + ? value + : storageRef.current[key]; + } catch (err) { + setError("Failed to load data"); + setErrorDescription( + "An error occurred while loading local data. Please reload and try again.", + ); + log(0, "Storage", "red", err); + throw err; + } finally { + loadPromises.current.delete(key); + } + })(); + loadPromises.current.set( + key, + request as Promise, + ); + return request; }, - [], + [commit], ); - const saveIO = React.useCallback( + const save = React.useCallback( async ( key: K, value: StorageSchema[K], + options: SaveOptions = {}, ): Promise => { + generations.current.set(key, (generations.current.get(key) ?? 0) + 1); + const desktopStorage = window.tensaminDesktop?.secureStorage; if (JSON.stringify(value) === JSON.stringify(defaults[key])) { - await deleteEntry(key); - setStorage((prev) => ({ ...prev, [key]: defaults[key] })); + if (desktopStorage?.delete) + await desktopStorage.delete(String(key)).catch(() => undefined); + await deleteDatabaseEntry("storage", key); + commit(key, defaults[key]); } else { - await setEntry(key, value); - setStorage((prev) => ({ ...prev, [key]: value })); + const status = options.secure + ? (secureStorageRef.current ?? (await getSecureStorageStatus())) + : secureStorageRef.current; + if ( + options.secure && + status?.backend === "electron-keyring" && + desktopStorage?.save + ) { + await desktopStorage.save(String(key), JSON.stringify(value)); + await deleteDatabaseEntry("storage", key); + } else { + const persisted = options.secure + ? await encodeSecureValue(value) + : value; + await setDatabaseEntry("storage", key, persisted as StorageSchema[K]); + } + commit(key, value); } }, - [], + [commit], ); + const clear = React.useCallback(async () => { + const keys = Object.keys(defaults) as (keyof StorageSchema)[]; + for (const key of keys) { + generations.current.set(key, (generations.current.get(key) ?? 0) + 1); + } + await Promise.all( + keys.map((key) => deleteDatabaseEntry("storage", key)), + ); + await window.tensaminDesktop?.secureStorage + ?.clear?.() + .catch(() => undefined); + loadedKeys.current.clear(); + loadPromises.current.clear(); + storageRef.current = defaults; + setStorage(defaults); + }, []); + const value = React.useMemo( () => ({ - async load( - key: K, - ): Promise { - const current = storageRef.current[key]; - - if ( - current === undefined || - JSON.stringify(current) === JSON.stringify(defaults[key]) - ) { - const loadedValue = await loadIO(key); - setStorage((prev) => ({ ...prev, [key]: loadedValue })); - return loadedValue; - } - - return current; - }, - - async save( - key: K, - nextValue: StorageSchema[K], - ): Promise { - await saveIO(key, nextValue); - }, - - async clear() { - const keys = Object.keys(defaults) as (keyof StorageSchema)[]; - await Promise.all(keys.map((key) => deleteEntry(key))); - setStorage(defaults); - }, + load, + save, + clear, + secureStorage, }), - [loadIO, saveIO], + [clear, load, save, secureStorage], ); - React.useEffect(() => { - // @ts-expect-error development utility - window.save = value.save; - }, [value]); - if (error !== "" && errorDescription !== "") { return ; } diff --git a/packages/storage/src/indexed-db.ts b/packages/storage/src/indexed-db.ts deleted file mode 100644 index ebdc58d..0000000 --- a/packages/storage/src/indexed-db.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { Storage as StorageSchema } from "@tensamin/shared/data"; - -const DB_NAME = "tensamin"; -const DB_VERSION = 1; -const STORE_NAME = "storage"; - -let dbPromise: Promise | null = null; - -/** - * Executes openDB. - * @param none This function has no parameters. - * @returns Promise. - */ -function openDB(): Promise { - if (dbPromise) return dbPromise; - - dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME); - } - }; - - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - - return dbPromise; -} - -/** - * Executes getEntry. - * @param key Parameter key. - * @returns Promise. - */ -export async function getEntry( - key: K, -): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readonly"); - const store = tx.objectStore(STORE_NAME); - const request = store.get(key as string); - - request.onsuccess = () => - resolve(request.result as StorageSchema[K] | undefined); - request.onerror = () => reject(request.error); - }); -} - -/** - * Executes setEntry. - * @param key Parameter key. - * @param value Parameter value. - * @returns Promise. - */ -export async function setEntry( - key: K, - value: StorageSchema[K], -): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readwrite"); - const store = tx.objectStore(STORE_NAME); - const request = store.put(value, key as string); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); -} - -/** - * Executes deleteEntry. - * @param key Parameter key. - * @returns Promise. - */ -export async function deleteEntry( - key: K, -): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readwrite"); - const store = tx.objectStore(STORE_NAME); - const request = store.delete(key as string); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); -} diff --git a/packages/storage/src/secure.ts b/packages/storage/src/secure.ts new file mode 100644 index 0000000..06ded7a --- /dev/null +++ b/packages/storage/src/secure.ts @@ -0,0 +1,142 @@ +import type {} from "@tensamin/shared/desktopMedia"; +import { getDatabaseEntry, setDatabaseEntry } from "@tensamin/shared/indexedDb"; + +export type SecureStorageStatus = { + backend: "electron-keyring" | "webcrypto" | "indexeddb"; + secure: boolean; + reason?: string; +}; + +type SecureEnvelope = { + __tensaminSecure: 1; + version: 1; + iv: string; + data: string; +}; + +const MASTER_KEY_NAME = "master-v1"; +let keyPromise: Promise | undefined; + +function bytesToBase64(value: Uint8Array) { + let binary = ""; + for (const byte of value) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function base64ToBytes(value: string) { + const binary = atob(value); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +async function loadBrowserKey() { + const existing = await getDatabaseEntry("keys", MASTER_KEY_NAME); + if (existing) return existing; + + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + await setDatabaseEntry("keys", MASTER_KEY_NAME, key); + return key; +} + +async function loadElectronKey() { + const storage = window.tensaminDesktop?.secureStorage; + if (!storage?.getStatus || !storage.load || !storage.save) return null; + const status = await storage.getStatus(); + if (!status.available) return null; + + let encoded = await storage.load(MASTER_KEY_NAME); + if (encoded === null) { + encoded = bytesToBase64(crypto.getRandomValues(new Uint8Array(32))); + await storage.save(MASTER_KEY_NAME, encoded); + } + return crypto.subtle.importKey( + "raw", + base64ToBytes(encoded), + "AES-GCM", + false, + ["encrypt", "decrypt"], + ); +} + +async function getKey() { + keyPromise ??= (async () => { + if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") { + return null; + } + if (window.tensaminDesktop?.secureStorage) return loadElectronKey(); + return loadBrowserKey(); + })().catch(() => null); + return keyPromise; +} + +export function isSecureEnvelope(value: unknown): value is SecureEnvelope { + if (!value || typeof value !== "object") return false; + const envelope = value as Partial; + return ( + envelope.__tensaminSecure === 1 && + envelope.version === 1 && + typeof envelope.iv === "string" && + typeof envelope.data === "string" + ); +} + +export async function encodeSecureValue(value: unknown): Promise { + const key = await getKey(); + if (!key) return value; + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(JSON.stringify(value)); + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + plaintext, + ); + return { + __tensaminSecure: 1, + version: 1, + iv: bytesToBase64(iv), + data: bytesToBase64(new Uint8Array(encrypted)), + } satisfies SecureEnvelope; +} + +export async function decodeSecureValue(value: unknown): Promise { + if (!isSecureEnvelope(value)) return value; + const key = await getKey(); + if (!key) throw new Error("Secure storage key is unavailable."); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(value.iv) }, + key, + base64ToBytes(value.data), + ); + return JSON.parse(new TextDecoder().decode(plaintext)) as unknown; +} + +export async function getSecureStorageStatus(): Promise { + const desktop = window.tensaminDesktop?.secureStorage; + if (desktop?.getStatus) { + const status = await desktop.getStatus(); + if (status.available) return { backend: "electron-keyring", secure: true }; + return { + backend: "indexeddb", + secure: false, + reason: + status.backend === "basic_text" + ? "The operating system keyring is unavailable." + : "Electron secure storage is unavailable.", + }; + } + return (await getKey()) + ? { backend: "webcrypto", secure: true } + : { + backend: "indexeddb", + secure: false, + reason: "This browser cannot protect local credentials with WebCrypto.", + }; +} + +export const secureValueCodec = { + encode: encodeSecureValue, + decode: decodeSecureValue, +}; diff --git a/packages/storage/src/session.tsx b/packages/storage/src/session.tsx index f237192..394718b 100644 --- a/packages/storage/src/session.tsx +++ b/packages/storage/src/session.tsx @@ -8,6 +8,8 @@ import { } from "react"; import { useStorage } from "./context"; import type { Contacts, Communities, Calls } from "@tensamin/shared/data"; +import { createCache } from "@tensamin/cache"; +import { secureValueCodec } from "./secure"; interface SessionContextType { contacts: Contacts; @@ -21,11 +23,13 @@ interface SessionContextType { const SessionContext = createContext(undefined); export default function SessionProvider({ children }: { children: ReactNode }) { - const { freshContacts, freshCommunities, freshCalls } = useMTP(); + const { freshContacts, freshCommunities, freshCalls, contextReady } = + useMTP(); const { load, save } = useStorage(); const [contacts, setContacts] = useState([]); const [communities, setCommunities] = useState([]); const [localCalls, setLocalCalls] = useState([]); + const [accountId, setAccountId] = useState(null); const calls = [ ...freshCalls, ...localCalls.filter( @@ -33,17 +37,27 @@ export default function SessionProvider({ children }: { children: ReactNode }) { ), ]; - // Cached session data fills in items the server did not return freshly. useEffect(() => { - load("cached_contacts").then((cachedData) => { - setContacts([ - ...freshContacts, - ...cachedData.filter( - (item) => - !freshContacts.some((fresh) => fresh.UserId === item.UserId), - ), - ]); + void load("user_id").then(setAccountId); + }, [load]); + + // Cached contacts seed the session, then authenticated server data replaces them. + useEffect(() => { + if (!accountId) return; + const cache = createCache(String(accountId), { + codec: secureValueCodec, }); + void cache.contacts.get().then((cached) => { + if (cached) setContacts(cached); + }); + }, [accountId]); + + useEffect(() => { + if (!accountId || !contextReady) return; + setContacts(freshContacts); + }, [accountId, contextReady, freshContacts]); + + useEffect(() => { load("cached_communities").then((cachedData) => { if (cachedData && freshCommunities) { setCommunities([ @@ -57,11 +71,7 @@ export default function SessionProvider({ children }: { children: ReactNode }) { ]); } }); - }, [load, freshContacts, freshCommunities]); - - useEffect(() => { - save("cached_contacts", contacts); - }, [contacts, save]); + }, [load, freshCommunities]); useEffect(() => { save("cached_communities", communities); }, [communities, save]); @@ -72,8 +82,12 @@ export default function SessionProvider({ children }: { children: ReactNode }) { (contact) => contact.UserId === userId, ); if (userIndex === -1) return prevContacts; - const [user] = prevContacts.splice(userIndex, 1); - return [user, ...prevContacts]; + const user = prevContacts[userIndex]; + return [ + user, + ...prevContacts.slice(0, userIndex), + ...prevContacts.slice(userIndex + 1), + ]; }); }; diff --git a/packages/user/package.json b/packages/user/package.json index f72e50d..710147c 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -14,6 +14,7 @@ "build": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@tensamin/cache": "workspace:*", "@tensamin/mtp": "workspace:*", "@tensamin/shared": "workspace:*", "@tensamin/storage": "workspace:*", diff --git a/packages/user/src/context.tsx b/packages/user/src/context.tsx index 001d5ae..eb4b6dd 100644 --- a/packages/user/src/context.tsx +++ b/packages/user/src/context.tsx @@ -3,6 +3,9 @@ import { useMTP } from "@tensamin/mtp"; import { mtp as schemas } from "@tensamin/shared/data"; import type z from "zod"; +import { createCache } from "@tensamin/cache"; +import { useStorage } from "@tensamin/storage/context"; +import { useSession } from "@tensamin/storage/session"; export type User = z.infer; @@ -24,6 +27,15 @@ export default function UserProvider(props: { children: React.ReactNode }) { ); const { send } = useMTP(); + const { load } = useStorage(); + const { contacts } = useSession(); + const [accountId, setAccountId] = React.useState(null); + + React.useEffect(() => { + void load("user_id").then((accountId) => { + setAccountId(accountId); + }); + }, [load]); /** * Executes get. @@ -36,27 +48,30 @@ export default function UserProvider(props: { children: React.ReactNode }) { throw new Error("userId is required"); } - const cachedUser = storageRef.current[userId]; - if (cachedUser !== undefined) { - return cachedUser; - } - const pendingUser = pendingRef.current[userId]; if (pendingUser !== undefined) { return pendingUser; } const request = (async () => { - const userData = await send("GetUserData", { UserId: userId }); - const user = { - ...userData.data, - avatar: userData.data.Avatar - ? `data:image/webp;base64,${atob(userData.data.Avatar)}` - : undefined, - }; - - storageRef.current[userId] = user; - return user; + const cache = accountId ? createCache(String(accountId)) : null; + const cached = + storageRef.current[userId] ?? (await cache?.profiles.get(userId)); + if (cached) storageRef.current[userId] = cached; + try { + const userData = await send("GetUserData", { UserId: userId }); + const user = { + ...userData.data, + avatar: userData.data.Avatar + ? `data:image/webp;base64,${atob(userData.data.Avatar)}` + : undefined, + }; + storageRef.current[userId] = user; + return user; + } catch (error) { + if (cached) return cached; + throw error; + } })(); pendingRef.current[userId] = request; @@ -67,9 +82,14 @@ export default function UserProvider(props: { children: React.ReactNode }) { delete pendingRef.current[userId]; } }, - [send], + [accountId, send], ); + React.useEffect(() => { + if (!accountId) return; + for (const contact of contacts) void get(contact.UserId); + }, [accountId, contacts, get]); + return ( {props.children} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18c677e..d70ad4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,9 @@ importers: '@tauri-apps/api': specifier: ^2 version: 2.11.1 + '@tensamin/cache': + specifier: workspace:* + version: link:../../packages/cache '@tensamin/call': specifier: workspace:* version: link:../../packages/call @@ -254,6 +257,9 @@ importers: '@tensamin/notifications': specifier: workspace:* version: link:../../packages/notifications + '@tensamin/settings': + specifier: workspace:* + version: link:../../packages/settings '@tensamin/shared': specifier: workspace:* version: link:../../packages/shared @@ -499,6 +505,24 @@ importers: specifier: ^8.0.10 version: 8.1.3(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0) + packages/cache: + dependencies: + '@tensamin/mtp': + specifier: workspace:* + version: link:../mtp + '@tensamin/shared': + specifier: workspace:* + version: link:../shared + '@tensamin/storage': + specifier: workspace:* + version: link:../storage + react: + specifier: ^19.2.0 + version: 19.2.7 + zod: + specifier: ^4.3.6 + version: 4.4.3 + packages/call: dependencies: '@livekit/components-react': @@ -567,6 +591,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.0.0 version: 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tensamin/cache': + specifier: workspace:* + version: link:../cache '@tensamin/crypto': specifier: workspace:* version: link:../crypto @@ -731,6 +758,46 @@ importers: specifier: ^4.3.6 version: 4.4.3 + packages/settings: + dependencies: + '@tanstack/react-router': + specifier: ^1.169.1 + version: 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tensamin/cache': + specifier: workspace:* + version: link:../cache + '@tensamin/markdown': + specifier: workspace:* + version: link:../markdown + '@tensamin/mtp': + specifier: workspace:* + version: link:../mtp + '@tensamin/shared': + specifier: workspace:* + version: link:../shared + '@tensamin/storage': + specifier: workspace:* + version: link:../storage + '@tensamin/ui': + specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz + version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3) + '@tensamin/user': + specifier: workspace:* + version: link:../user + lucide-react: + specifier: ^1.14.0 + version: 1.23.0(react@19.2.7) + react: + specifier: ^19.2.0 + version: 19.2.7 + react-dom: + specifier: ^19.2.0 + version: 19.2.7(react@19.2.7) + devDependencies: + vite: + specifier: ^8.0.10 + version: 8.1.3(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0) + packages/shared: dependencies: '@tensamin/ui': @@ -751,6 +818,9 @@ importers: packages/storage: dependencies: + '@tensamin/cache': + specifier: workspace:* + version: link:../cache '@tensamin/mtp': specifier: workspace:* version: link:../mtp @@ -808,6 +878,9 @@ importers: packages/user: dependencies: + '@tensamin/cache': + specifier: workspace:* + version: link:../cache '@tensamin/mtp': specifier: workspace:* version: link:../mtp diff --git a/todo.md b/todo.md index 7c62ac9..faefaa8 100644 --- a/todo.md +++ b/todo.md @@ -1,5 +1,4 @@ - Move legal to extra onboarding package - Add a bunch of tests -- Add packages/cache/ to handle caching - Add packages/hotkeys/ - Full accessability