Files
ChatApp/apps/desktop/src/lib/decryptWorker.ts
T
byGalax 228608ef2c 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
2026-04-21 10:07:20 +02:00

100 lines
3.0 KiB
TypeScript

// 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 };
}
});
}