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:
@@ -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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user