228608ef2c
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
100 lines
3.0 KiB
TypeScript
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 };
|
|
}
|
|
});
|
|
}
|