feat: tray, window-state, sqlite cache, decrypt worker
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
This commit is contained in:
Generated
+16
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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::<TrayUnreadPayload>(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
|
||||
|
||||
@@ -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<ConversationsContextValue>(
|
||||
() => ({
|
||||
conversations,
|
||||
|
||||
@@ -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<string, Pending>();
|
||||
|
||||
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<DecryptResult[]> {
|
||||
if (items.length === 0) return [];
|
||||
const w = spawn();
|
||||
if (!w) return inlineDecrypt(items);
|
||||
|
||||
return new Promise<DecryptResult[]>((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<DecryptResult[]> {
|
||||
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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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: <T>(sql: string, values?: unknown[]) => Promise<T[]>;
|
||||
close: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface DatabaseStatic {
|
||||
load: (path: string) => Promise<Database>;
|
||||
}
|
||||
|
||||
const DB_NAME = 'chatapp-cache.db';
|
||||
|
||||
let dbPromise: Promise<Database | null> | null = null;
|
||||
|
||||
async function loadDb(): Promise<Database | null> {
|
||||
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<Database | null> {
|
||||
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<DecryptedMessage[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
try {
|
||||
const rows = await db.select<Row>(
|
||||
'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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
await emit('tray-unread-update', { count: Math.max(0, Math.floor(count)) });
|
||||
} catch (err: unknown) {
|
||||
console.warn('updateTrayUnread failed', err);
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 }> }
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
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<typeof sodium> | null = null;
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
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<DecryptRequest>) => {
|
||||
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);
|
||||
})();
|
||||
});
|
||||
@@ -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<Array<{ id: string; plaintext: string | null }>>;
|
||||
}
|
||||
|
||||
// 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<DecryptedMessage[]> {
|
||||
const out: DecryptedMessage[] = [];
|
||||
// Group versions to avoid redundant lookups.
|
||||
const versions = new Map<string, Map<number, Uint8Array | null>>(); // 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<DecryptedMes
|
||||
key = handle?.key ?? null;
|
||||
convCache.set(m.keyVersion, key);
|
||||
}
|
||||
resolved.push({ message: m, key });
|
||||
}
|
||||
|
||||
// Second pass: symmetric decrypt. If a delegate is supplied (Web Worker),
|
||||
// batch the CPU-heavy step to it; otherwise fall back to inline.
|
||||
if (opts.aeadBatchDelegate) {
|
||||
const batch = resolved
|
||||
.filter((r): r is Resolved & { key: Uint8Array } => r.key !== null)
|
||||
.map((r) => ({
|
||||
id: r.message.id,
|
||||
ciphertext: r.message.ciphertext,
|
||||
nonce: r.message.nonce,
|
||||
key: r.key,
|
||||
}));
|
||||
const plaintextById = new Map<string, string | null>();
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user