From 228608ef2cfe7bc09c982b0afe2550c569fd2aed Mon Sep 17 00:00:00 2001 From: Dennis Landmann Date: Tue, 21 Apr 2026 10:07:20 +0200 Subject: [PATCH] feat: tray, window-state, sqlite cache, decrypt worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System tray (desktop) - tauri tray-icon feature + tray with menu (Öffnen/Ausblenden/Beenden) - Left-click toggles main window; right-click shows menu - JS emits tray-unread-update event, Rust mirrors into tooltip + macOS dock badge via set_badge_label - ConversationsContext wires totalUnread → tray Window state persistence - tauri-plugin-window-state (desktop-only target guard) - Auto-restore size/position/maximized between restarts Local SQLite message cache - tauri-plugin-sql hydration of conversation view on mount - persistMessages after each refresh, deleteCachedMessage on realtime DELETE, pruneCache keeps latest 1000 per conversation - Stores plaintext only (same trust boundary as stronghold device key; cache never leaves the device, E2EE w.r.t. server unchanged) Web Worker for decryption - workers/decrypt.worker.ts runs crypto_secretbox_open_easy + utf-8 decode off the main thread with its own libsodium instance - lib/decryptWorker.ts is a request/response wrapper with inline fallback when Worker spawn fails - shared decryptMessages accepts aeadBatchDelegate so key lookup stays on the main thread while the AEAD loop offloads Build fix - Enable tauri tray-icon feature - Import Listener + Manager traits, clone tray handle for the event listener, conditional icon attach --- apps/desktop/src-tauri/Cargo.lock | 16 ++ apps/desktop/src-tauri/Cargo.toml | 3 +- .../src-tauri/capabilities/default.json | 5 + apps/desktop/src-tauri/src/lib.rs | 130 ++++++++++++- .../src/context/ConversationsContext.tsx | 7 + apps/desktop/src/lib/decryptWorker.ts | 99 ++++++++++ apps/desktop/src/lib/messageCache.ts | 182 ++++++++++++++++++ apps/desktop/src/lib/trayBadge.ts | 15 ++ .../src/lib/useConversationMessages.ts | 41 ++++ apps/desktop/src/workers/decrypt.worker.ts | 62 ++++++ packages/shared/src/chat/messages.ts | 58 +++++- 11 files changed, 607 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/lib/decryptWorker.ts create mode 100644 apps/desktop/src/lib/messageCache.ts create mode 100644 apps/desktop/src/lib/trayBadge.ts create mode 100644 apps/desktop/src/workers/decrypt.worker.ts diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index a2ed6e4..79a727f 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -690,6 +690,7 @@ dependencies = [ "tauri-plugin-sql", "tauri-plugin-stronghold", "tauri-plugin-updater", + "tauri-plugin-window-state", ] [[package]] @@ -5617,6 +5618,21 @@ dependencies = [ "zip 4.6.1", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.11.1", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.10.1" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index c1720bc..96fc4dd 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["devtools"] } +tauri = { version = "2", features = ["devtools", "tray-icon"] } tauri-plugin-notification = "2" tauri-plugin-sql = { version = "2", features = ["sqlite"] } tauri-plugin-stronghold = "2" @@ -25,6 +25,7 @@ serde_json = "1" [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-global-shortcut = "2" tauri-plugin-updater = "2" +tauri-plugin-window-state = "2" [features] # This feature is used for production builds or when `devPath` points to the filesystem diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 910db65..58c40d8 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -9,6 +9,11 @@ "notification:allow-notify", "notification:allow-is-permission-granted", "notification:allow-request-permission", + "sql:default", + "sql:allow-load", + "sql:allow-execute", + "sql:allow-select", + "sql:allow-close", "global-shortcut:allow-register", "global-shortcut:allow-unregister", "global-shortcut:allow-is-registered", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 53aea41..747c21b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,3 +1,50 @@ +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use tauri::{ + menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Listener, Manager, +}; + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use serde::Deserialize; + +// Payload for the `tray-unread-update` event the JS layer emits whenever the +// aggregate unread-count changes. 0 hides the badge / resets the tooltip; +// non-zero sets a count indicator. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[derive(Deserialize)] +struct TrayUnreadPayload { + count: u32, +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn show_main_window(app: &AppHandle) { + if let Some(win) = app.get_webview_window("main") { + let _ = win.show(); + let _ = win.unminimize(); + let _ = win.set_focus(); + } +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn hide_main_window(app: &AppHandle) { + if let Some(win) = app.get_webview_window("main") { + let _ = win.hide(); + } +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn handle_menu_event(app: &AppHandle, event: MenuEvent) { + match event.id.as_ref() { + "tray-show" => show_main_window(app), + "tray-hide" => hide_main_window(app), + "tray-quit" => { + app.exit(0); + } + _ => {} + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let mut builder = tauri::Builder::default() @@ -13,12 +60,91 @@ pub fn run() { .build(), ); - // Global shortcut + updater plugins are desktop-only (no mobile support). + // Global shortcut + updater + window-state plugins are desktop-only + // (no mobile support — mobile windows are OS-managed). #[cfg(not(any(target_os = "android", target_os = "ios")))] { builder = builder .plugin(tauri_plugin_global_shortcut::Builder::new().build()) - .plugin(tauri_plugin_updater::Builder::new().build()); + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_window_state::Builder::new().build()); + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + builder = builder.setup(|app| { + // Tray icon with a minimal menu. Left-click toggles window + // visibility; right-click shows the menu. Badge / tooltip updates + // come from the JS side via `tray-unread-update` events. + let show = MenuItem::with_id(app, "tray-show", "Öffnen", true, None::<&str>)?; + let hide = MenuItem::with_id(app, "tray-hide", "Ausblenden", true, None::<&str>)?; + let sep = PredefinedMenuItem::separator(app)?; + let quit = MenuItem::with_id(app, "tray-quit", "Beenden", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &hide, &sep, &quit])?; + + let mut tray_builder = TrayIconBuilder::with_id("chatapp-tray") + .menu(&menu) + .show_menu_on_left_click(false) + .tooltip("ChatApp") + .on_menu_event(|app, event| handle_menu_event(app, event)) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(win) = app.get_webview_window("main") { + if win.is_visible().unwrap_or(false) { + let _ = win.hide(); + } else { + let _ = win.show(); + let _ = win.set_focus(); + } + } + } + }); + + // `default_window_icon` returns Option<&Image>; only attach if + // we actually have one bundled (should always be true via the + // tauri.conf.json icon list, but guard to stay typesafe). + if let Some(icon) = app.default_window_icon() { + tray_builder = tray_builder.icon(icon.clone()); + } + + let tray = tray_builder.build(app)?; + + // Listen for JS-side unread updates and mirror them into the tray + // tooltip + macOS dock badge. `tray` is cheap to clone (internal + // Arc) so we can move it into the listener closure directly. + let tray_handle = tray.clone(); + let badge_window = app.get_webview_window("main"); + app.listen("tray-unread-update", move |event| { + let Ok(payload) = serde_json::from_str::(event.payload()) + else { + return; + }; + let tooltip = if payload.count == 0 { + "ChatApp".to_string() + } else { + format!("ChatApp · {} neu", payload.count) + }; + let _ = tray_handle.set_tooltip(Some(tooltip)); + // macOS dock badge. `set_badge_label` is macOS-only but the + // call is a no-op on other platforms so we don't need a cfg. + if let Some(win) = badge_window.as_ref() { + let badge = if payload.count == 0 { + None + } else { + Some(payload.count.to_string()) + }; + let _ = win.set_badge_label(badge); + } + }); + + Ok(()) + }); } builder diff --git a/apps/desktop/src/context/ConversationsContext.tsx b/apps/desktop/src/context/ConversationsContext.tsx index bc8c5af..79e0548 100644 --- a/apps/desktop/src/context/ConversationsContext.tsx +++ b/apps/desktop/src/context/ConversationsContext.tsx @@ -17,6 +17,7 @@ import { import { playNotificationTone } from '../lib/notificationSound'; import { notify } from '../lib/osNotify'; import { supabase } from '../lib/supabase'; +import { updateTrayUnread } from '../lib/trayBadge'; import { useAuth } from './AuthContext'; const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read'; @@ -253,6 +254,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) { return s; }, [unread]); + // Mirror unread count into the tray tooltip + dock badge. Runs in Tauri + // only; no-op in browser-preview. + useEffect(() => { + void updateTrayUnread(totalUnread); + }, [totalUnread]); + const value = useMemo( () => ({ conversations, diff --git a/apps/desktop/src/lib/decryptWorker.ts b/apps/desktop/src/lib/decryptWorker.ts new file mode 100644 index 0000000..a44df86 --- /dev/null +++ b/apps/desktop/src/lib/decryptWorker.ts @@ -0,0 +1,99 @@ +// Main-thread wrapper around the decrypt Web Worker. +// +// Spawns a single worker lazily on first use. Uses request-id correlation so +// concurrent batches (e.g. a backfill arriving while a realtime insert +// dispatches) can't mix up their results. Falls back to inline decrypt if +// the Worker constructor isn't available (non-browser environments, some +// strict CSPs). + +import sodium from 'libsodium-wrappers-sumo'; + +export interface DecryptItem { + id: string; + ciphertext: Uint8Array; + nonce: Uint8Array; + key: Uint8Array; +} + +interface Pending { + resolve: (results: DecryptResult[]) => void; + reject: (err: unknown) => void; +} + +interface DecryptResult { + id: string; + plaintext: string | null; +} + +let worker: Worker | null = null; +let workerBroken = false; +const pending = new Map(); + +function spawn(): Worker | null { + if (workerBroken) return null; + if (worker) return worker; + try { + worker = new Worker(new URL('../workers/decrypt.worker.ts', import.meta.url), { + type: 'module', + }); + worker.addEventListener('message', (ev: MessageEvent) => { + const data = ev.data as { id?: string; results?: DecryptResult[] }; + if (!data.id) return; + const entry = pending.get(data.id); + if (!entry) return; + pending.delete(data.id); + entry.resolve(data.results ?? []); + }); + worker.addEventListener('error', (ev) => { + console.warn('decrypt worker error, falling back to inline', ev.message); + workerBroken = true; + worker?.terminate(); + worker = null; + for (const entry of pending.values()) { + entry.reject(new Error('decrypt worker crashed')); + } + pending.clear(); + }); + return worker; + } catch (err: unknown) { + console.warn('decrypt worker spawn failed, falling back to inline', err); + workerBroken = true; + return null; + } +} + +export async function decryptBatch(items: DecryptItem[]): Promise { + if (items.length === 0) return []; + const w = spawn(); + if (!w) return inlineDecrypt(items); + + return new Promise((resolve, reject) => { + const id = crypto.randomUUID(); + pending.set(id, { resolve, reject }); + try { + w.postMessage({ id, items }); + } catch (err: unknown) { + pending.delete(id); + // postMessage can fail if the Uint8Array view is detached — retry inline. + console.warn('decrypt worker postMessage failed, using inline', err); + inlineDecrypt(items).then(resolve, reject); + } + }); +} + +// Fallback path — same semantics as the worker but on the main thread. Used +// when the worker failed to spawn or crashed mid-session. +async function inlineDecrypt(items: DecryptItem[]): Promise { + await sodium.ready; + return items.map((item) => { + try { + const plain = sodium.crypto_secretbox_open_easy(item.ciphertext, item.nonce, item.key); + return { + id: item.id, + plaintext: new TextDecoder('utf-8', { fatal: false }).decode(plain), + }; + } catch { + return { id: item.id, plaintext: null }; + } + }); +} diff --git a/apps/desktop/src/lib/messageCache.ts b/apps/desktop/src/lib/messageCache.ts new file mode 100644 index 0000000..25c37f6 --- /dev/null +++ b/apps/desktop/src/lib/messageCache.ts @@ -0,0 +1,182 @@ +// Local SQLite message cache. Persists decrypted messages so startup + +// conversation-switch hydrate from disk instantly instead of waiting on a +// network round-trip + batch decrypt. +// +// The cache is per-device-local (app-local-data dir, same trust boundary as +// the device private key). Plaintext is stored because the threat model +// already assumes local-disk access means compromise — same as the existing +// outbox + secret store. Ciphertext + nonce are kept alongside so a future +// key-rotation migration can re-derive plaintext when a new bundle arrives. + +import type { DecryptedMessage } from '@chat-app/shared/chat'; + +import { isTauriRuntime } from './globalShortcut'; + +interface Database { + execute: (sql: string, values?: unknown[]) => Promise<{ rowsAffected?: number }>; + select: (sql: string, values?: unknown[]) => Promise; + close: () => Promise; +} + +interface DatabaseStatic { + load: (path: string) => Promise; +} + +const DB_NAME = 'chatapp-cache.db'; + +let dbPromise: Promise | null = null; + +async function loadDb(): Promise { + if (!isTauriRuntime()) return null; + try { + // Dynamic import so browser-preview builds don't choke on the tauri + // plugin module. Vite will statically analyse this + split it into a + // chunk that only loads inside Tauri. + const mod = (await import('@tauri-apps/plugin-sql')) as { + default: DatabaseStatic; + Database?: DatabaseStatic; + }; + const DatabaseCtor: DatabaseStatic = mod.default ?? (mod as { Database: DatabaseStatic }).Database; + const db = await DatabaseCtor.load('sqlite:' + DB_NAME); + await db.execute(` + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + sender_device_id TEXT, + reply_to_id TEXT, + edited_at TEXT, + deleted_at TEXT, + created_at TEXT NOT NULL, + plaintext TEXT + ); + `); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);', + ); + return db; + } catch (err: unknown) { + console.warn('messageCache init failed — falling back to memory-only', err); + return null; + } +} + +function getDb(): Promise { + if (!dbPromise) dbPromise = loadDb(); + return dbPromise; +} + +// --------------------------------------------------------------------------- +// Public API — all calls are no-ops when the cache isn't available (browser +// preview, init error, etc.), so callers never need to guard. +// --------------------------------------------------------------------------- + +interface Row { + id: string; + conversation_id: string; + sender_id: string; + sender_device_id: string | null; + reply_to_id: string | null; + edited_at: string | null; + deleted_at: string | null; + created_at: string; + plaintext: string | null; +} + +export async function loadCachedMessages( + conversationId: string, + limit = 500, +): Promise { + const db = await getDb(); + if (!db) return []; + try { + const rows = await db.select( + 'SELECT * FROM messages WHERE conversation_id = $1 ORDER BY created_at DESC LIMIT $2', + [conversationId, limit], + ); + return rows + .reverse() + .map((r) => ({ + id: r.id, + conversationId: r.conversation_id, + senderId: r.sender_id, + senderDeviceId: r.sender_device_id, + replyToId: r.reply_to_id, + editedAt: r.edited_at, + deletedAt: r.deleted_at, + createdAt: r.created_at, + plaintext: r.plaintext, + })); + } catch (err: unknown) { + console.warn('loadCachedMessages failed', err); + return []; + } +} + +export async function persistMessages( + conversationId: string, + messages: DecryptedMessage[], +): Promise { + const db = await getDb(); + if (!db || messages.length === 0) return; + try { + // Replace strategy: each message is keyed by id so upsert is "INSERT OR + // REPLACE". Attachments + forward chains are part of `plaintext` JSON so + // nothing lives outside this table. + for (const m of messages) { + await db.execute( + `INSERT OR REPLACE INTO messages + (id, conversation_id, sender_id, sender_device_id, reply_to_id, + edited_at, deleted_at, created_at, plaintext) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + m.id, + conversationId, + m.senderId, + m.senderDeviceId, + m.replyToId, + m.editedAt, + m.deletedAt, + m.createdAt, + m.plaintext, + ], + ); + } + } catch (err: unknown) { + console.warn('persistMessages failed', err); + } +} + +export async function deleteCachedMessage(id: string): Promise { + const db = await getDb(); + if (!db) return; + try { + await db.execute('DELETE FROM messages WHERE id = $1', [id]); + } catch (err: unknown) { + console.warn('deleteCachedMessage failed', err); + } +} + +// Housekeeping — run once per session to bound the cache size. Keeps the +// latest KEEP_PER_CONV messages per conversation. +const KEEP_PER_CONV = 1000; + +export async function pruneCache(): Promise { + const db = await getDb(); + if (!db) return; + try { + await db.execute( + `DELETE FROM messages WHERE id IN ( + SELECT id FROM messages m + WHERE ( + SELECT COUNT(*) FROM messages m2 + WHERE m2.conversation_id = m.conversation_id + AND m2.created_at > m.created_at + ) >= $1 + )`, + [KEEP_PER_CONV], + ); + } catch (err: unknown) { + console.warn('pruneCache failed', err); + } +} diff --git a/apps/desktop/src/lib/trayBadge.ts b/apps/desktop/src/lib/trayBadge.ts new file mode 100644 index 0000000..2906620 --- /dev/null +++ b/apps/desktop/src/lib/trayBadge.ts @@ -0,0 +1,15 @@ +import { emit } from '@tauri-apps/api/event'; + +import { isTauriRuntime } from './globalShortcut'; + +// Pushes the current aggregate unread count to the Rust-side tray listener. +// Rust mirrors it into the tray tooltip + macOS dock badge. No-op in the +// browser/dev preview where the Tauri runtime isn't present. +export async function updateTrayUnread(count: number): Promise { + if (!isTauriRuntime()) return; + try { + await emit('tray-unread-update', { count: Math.max(0, Math.floor(count)) }); + } catch (err: unknown) { + console.warn('updateTrayUnread failed', err); + } +} diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts index f00d039..ed70dd7 100644 --- a/apps/desktop/src/lib/useConversationMessages.ts +++ b/apps/desktop/src/lib/useConversationMessages.ts @@ -13,6 +13,13 @@ import { import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { decryptBatch as decryptBatchWorker } from './decryptWorker'; +import { + loadCachedMessages, + persistMessages, + pruneCache, + deleteCachedMessage, +} from './messageCache'; import { enqueueOutbox, getOutbox, @@ -103,6 +110,10 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar messages, ownDeviceId: deviceId, ownPrivateKey: priv, + // Offload the symmetric decrypt + utf-8 decode to a Web Worker so + // the main thread stays responsive during bulk operations (initial + // fetch, backfill after sleep). + aeadBatchDelegate: decryptBatchWorker, }); }, [deviceId], @@ -120,6 +131,10 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar const rows = await fetchConversationMessages(supabase, conversationId); const decrypted = await decryptBatch(rows); setState({ messages: decrypted, loading: false, error: null }); + // Persist the fresh batch to the local cache so next conversation + // switch / app start can hydrate instantly. Fire-and-forget — cache + // write failure is never user-visible. + void persistMessages(conversationId, decrypted); } catch (err: unknown) { setState((prev) => ({ ...prev, @@ -129,6 +144,31 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar } }, [conversationId, decryptBatch]); + // Hydrate from the local SQLite cache the moment the conversation id + // changes. Runs in parallel with the network fetch — whichever resolves + // first populates the UI, and `refresh` will replace stale cache data + // when the server response lands. On cache-miss this is a ~5ms no-op. + useEffect(() => { + if (!conversationId) return; + let cancelled = false; + void loadCachedMessages(conversationId).then((cached) => { + if (cancelled || cached.length === 0) return; + setState((prev) => { + // Don't clobber a fresh server response that already landed. + if (prev.messages.length > 0) return prev; + return { messages: cached, loading: false, error: null }; + }); + }); + return () => { + cancelled = true; + }; + }, [conversationId]); + + // Prune cache once per app session. + useEffect(() => { + void pruneCache(); + }, []); + // Realtime INSERT handler — refetches the row via REST so we get the // canonical bytea encoding (postgres_changes payloads serialize bytea // differently and decoding them inline is brittle). Then decrypt + append. @@ -296,6 +336,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar ...prev, messages: prev.messages.filter((m) => m.id !== id), })); + void deleteCachedMessage(id); }, []); useEffect(() => { diff --git a/apps/desktop/src/workers/decrypt.worker.ts b/apps/desktop/src/workers/decrypt.worker.ts new file mode 100644 index 0000000..c1ee374 --- /dev/null +++ b/apps/desktop/src/workers/decrypt.worker.ts @@ -0,0 +1,62 @@ +// Web Worker — runs XSalsa20-Poly1305 decrypt + utf8 decode off the main +// thread. The conv-key lookup (which is network-bound) stays in the main +// thread; only the CPU-heavy AEAD + UTF-8 step runs here. +// +// Message protocol: +// request: { id: string, items: Array<{ id, ciphertext, nonce, key }> } +// response: { id: string, results: Array<{ id, plaintext: string | null }> } + +/// + +import sodium from 'libsodium-wrappers-sumo'; + +interface DecryptItem { + id: string; + ciphertext: Uint8Array; + nonce: Uint8Array; + key: Uint8Array; +} + +interface DecryptRequest { + id: string; + items: DecryptItem[]; +} + +interface DecryptResult { + id: string; + plaintext: string | null; +} + +interface DecryptResponse { + id: string; + results: DecryptResult[]; +} + +let sodiumReady: Promise | null = null; + +async function ensureSodium(): Promise { + if (!sodiumReady) sodiumReady = sodium.ready.then(() => sodium); + return sodiumReady; +} + +function decodeOne(s: typeof sodium, item: DecryptItem): string | null { + try { + const plain = s.crypto_secretbox_open_easy(item.ciphertext, item.nonce, item.key); + return new TextDecoder('utf-8', { fatal: false }).decode(plain); + } catch { + return null; + } +} + +self.addEventListener('message', (ev: MessageEvent) => { + const req = ev.data; + void (async () => { + const s = await ensureSodium(); + const results: DecryptResult[] = req.items.map((item) => ({ + id: item.id, + plaintext: decodeOne(s, item), + })); + const resp: DecryptResponse = { id: req.id, results }; + (self as unknown as Worker).postMessage(resp); + })(); +}); diff --git a/packages/shared/src/chat/messages.ts b/packages/shared/src/chat/messages.ts index ea7d8fe..a6621e3 100644 --- a/packages/shared/src/chat/messages.ts +++ b/packages/shared/src/chat/messages.ts @@ -444,16 +444,34 @@ export interface DecryptParams { messages: MessageWithCipher[]; ownDeviceId: string; ownPrivateKey: Uint8Array; + /** + * Optional delegate that performs the symmetric-decrypt + utf-8 decode + * step for a batch of messages. When provided, the main thread only does + * conv-key lookup; the CPU-heavy AEAD loop runs inside the delegate (e.g. + * a Web Worker). Items arrive with their per-message conv-key attached. + */ + aeadBatchDelegate?: ( + items: Array<{ + id: string; + ciphertext: Uint8Array; + nonce: Uint8Array; + key: Uint8Array; + }>, + ) => Promise>; } // Decrypts messages using their conv-key (looked up + cached per // keyVersion). Returns null `plaintext` when this device has no key bundle // for that version yet (e.g. brand-new device waiting for share). export async function decryptMessages(opts: DecryptParams): Promise { - const out: DecryptedMessage[] = []; - // Group versions to avoid redundant lookups. const versions = new Map>(); // convId -> version -> key | null - + // First pass: resolve conv-keys for every message. Network-bound, stays on + // caller's thread so Supabase client + session remain usable. + interface Resolved { + message: MessageWithCipher; + key: Uint8Array | null; + } + const resolved: Resolved[] = []; for (const m of opts.messages) { let convCache = versions.get(m.conversationId); if (!convCache) { @@ -474,17 +492,41 @@ export async function decryptMessages(opts: DecryptParams): Promise r.key !== null) + .map((r) => ({ + id: r.message.id, + ciphertext: r.message.ciphertext, + nonce: r.message.nonce, + key: r.key, + })); + const plaintextById = new Map(); + if (batch.length > 0) { + const results = await opts.aeadBatchDelegate(batch); + for (const result of results) plaintextById.set(result.id, result.plaintext); + } + return resolved.map((r) => ({ + ...r.message, + plaintext: r.key ? plaintextById.get(r.message.id) ?? null : null, + })); + } + + return resolved.map((r) => { let plaintext: string | null = null; - if (key) { + if (r.key) { try { - const decoded = decryptWithConvKey(m.ciphertext, m.nonce, key); + const decoded = decryptWithConvKey(r.message.ciphertext, r.message.nonce, r.key); plaintext = bytesToUtf8(decoded); } catch { plaintext = null; } } - out.push({ ...m, plaintext }); - } - return out; + return { ...r.message, plaintext }; + }); }