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:
@@ -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