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