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:
2026-04-21 10:07:20 +02:00
parent 24fdfee738
commit 228608ef2c
11 changed files with 607 additions and 11 deletions
@@ -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);
})();
});