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
+182
View File
@@ -0,0 +1,182 @@
// Local SQLite message cache. Persists decrypted messages so startup +
// conversation-switch hydrate from disk instantly instead of waiting on a
// network round-trip + batch decrypt.
//
// The cache is per-device-local (app-local-data dir, same trust boundary as
// the device private key). Plaintext is stored because the threat model
// already assumes local-disk access means compromise — same as the existing
// outbox + secret store. Ciphertext + nonce are kept alongside so a future
// key-rotation migration can re-derive plaintext when a new bundle arrives.
import type { DecryptedMessage } from '@chat-app/shared/chat';
import { isTauriRuntime } from './globalShortcut';
interface Database {
execute: (sql: string, values?: unknown[]) => Promise<{ rowsAffected?: number }>;
select: <T>(sql: string, values?: unknown[]) => Promise<T[]>;
close: () => Promise<boolean>;
}
interface DatabaseStatic {
load: (path: string) => Promise<Database>;
}
const DB_NAME = 'chatapp-cache.db';
let dbPromise: Promise<Database | null> | null = null;
async function loadDb(): Promise<Database | null> {
if (!isTauriRuntime()) return null;
try {
// Dynamic import so browser-preview builds don't choke on the tauri
// plugin module. Vite will statically analyse this + split it into a
// chunk that only loads inside Tauri.
const mod = (await import('@tauri-apps/plugin-sql')) as {
default: DatabaseStatic;
Database?: DatabaseStatic;
};
const DatabaseCtor: DatabaseStatic = mod.default ?? (mod as { Database: DatabaseStatic }).Database;
const db = await DatabaseCtor.load('sqlite:' + DB_NAME);
await db.execute(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
sender_device_id TEXT,
reply_to_id TEXT,
edited_at TEXT,
deleted_at TEXT,
created_at TEXT NOT NULL,
plaintext TEXT
);
`);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);',
);
return db;
} catch (err: unknown) {
console.warn('messageCache init failed — falling back to memory-only', err);
return null;
}
}
function getDb(): Promise<Database | null> {
if (!dbPromise) dbPromise = loadDb();
return dbPromise;
}
// ---------------------------------------------------------------------------
// Public API — all calls are no-ops when the cache isn't available (browser
// preview, init error, etc.), so callers never need to guard.
// ---------------------------------------------------------------------------
interface Row {
id: string;
conversation_id: string;
sender_id: string;
sender_device_id: string | null;
reply_to_id: string | null;
edited_at: string | null;
deleted_at: string | null;
created_at: string;
plaintext: string | null;
}
export async function loadCachedMessages(
conversationId: string,
limit = 500,
): Promise<DecryptedMessage[]> {
const db = await getDb();
if (!db) return [];
try {
const rows = await db.select<Row>(
'SELECT * FROM messages WHERE conversation_id = $1 ORDER BY created_at DESC LIMIT $2',
[conversationId, limit],
);
return rows
.reverse()
.map((r) => ({
id: r.id,
conversationId: r.conversation_id,
senderId: r.sender_id,
senderDeviceId: r.sender_device_id,
replyToId: r.reply_to_id,
editedAt: r.edited_at,
deletedAt: r.deleted_at,
createdAt: r.created_at,
plaintext: r.plaintext,
}));
} catch (err: unknown) {
console.warn('loadCachedMessages failed', err);
return [];
}
}
export async function persistMessages(
conversationId: string,
messages: DecryptedMessage[],
): Promise<void> {
const db = await getDb();
if (!db || messages.length === 0) return;
try {
// Replace strategy: each message is keyed by id so upsert is "INSERT OR
// REPLACE". Attachments + forward chains are part of `plaintext` JSON so
// nothing lives outside this table.
for (const m of messages) {
await db.execute(
`INSERT OR REPLACE INTO messages
(id, conversation_id, sender_id, sender_device_id, reply_to_id,
edited_at, deleted_at, created_at, plaintext)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
m.id,
conversationId,
m.senderId,
m.senderDeviceId,
m.replyToId,
m.editedAt,
m.deletedAt,
m.createdAt,
m.plaintext,
],
);
}
} catch (err: unknown) {
console.warn('persistMessages failed', err);
}
}
export async function deleteCachedMessage(id: string): Promise<void> {
const db = await getDb();
if (!db) return;
try {
await db.execute('DELETE FROM messages WHERE id = $1', [id]);
} catch (err: unknown) {
console.warn('deleteCachedMessage failed', err);
}
}
// Housekeeping — run once per session to bound the cache size. Keeps the
// latest KEEP_PER_CONV messages per conversation.
const KEEP_PER_CONV = 1000;
export async function pruneCache(): Promise<void> {
const db = await getDb();
if (!db) return;
try {
await db.execute(
`DELETE FROM messages WHERE id IN (
SELECT id FROM messages m
WHERE (
SELECT COUNT(*) FROM messages m2
WHERE m2.conversation_id = m.conversation_id
AND m2.created_at > m.created_at
) >= $1
)`,
[KEEP_PER_CONV],
);
} catch (err: unknown) {
console.warn('pruneCache failed', err);
}
}