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:
Generated
+16
@@ -690,6 +690,7 @@ dependencies = [
|
|||||||
"tauri-plugin-sql",
|
"tauri-plugin-sql",
|
||||||
"tauri-plugin-stronghold",
|
"tauri-plugin-stronghold",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tauri-plugin-window-state",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5617,6 +5618,21 @@ dependencies = [
|
|||||||
"zip 4.6.1",
|
"zip 4.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-window-state"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["devtools"] }
|
tauri = { version = "2", features = ["devtools", "tray-icon"] }
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||||
tauri-plugin-stronghold = "2"
|
tauri-plugin-stronghold = "2"
|
||||||
@@ -25,6 +25,7 @@ serde_json = "1"
|
|||||||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
tauri-plugin-window-state = "2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# This feature is used for production builds or when `devPath` points to the filesystem
|
# This feature is used for production builds or when `devPath` points to the filesystem
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
|
"sql:default",
|
||||||
|
"sql:allow-load",
|
||||||
|
"sql:allow-execute",
|
||||||
|
"sql:allow-select",
|
||||||
|
"sql:allow-close",
|
||||||
"global-shortcut:allow-register",
|
"global-shortcut:allow-register",
|
||||||
"global-shortcut:allow-unregister",
|
"global-shortcut:allow-unregister",
|
||||||
"global-shortcut:allow-is-registered",
|
"global-shortcut:allow-is-registered",
|
||||||
|
|||||||
@@ -1,3 +1,50 @@
|
|||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
AppHandle, Listener, Manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
// Payload for the `tray-unread-update` event the JS layer emits whenever the
|
||||||
|
// aggregate unread-count changes. 0 hides the badge / resets the tooltip;
|
||||||
|
// non-zero sets a count indicator.
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TrayUnreadPayload {
|
||||||
|
count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
fn show_main_window(app: &AppHandle) {
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.unminimize();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
fn hide_main_window(app: &AppHandle) {
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
fn handle_menu_event(app: &AppHandle, event: MenuEvent) {
|
||||||
|
match event.id.as_ref() {
|
||||||
|
"tray-show" => show_main_window(app),
|
||||||
|
"tray-hide" => hide_main_window(app),
|
||||||
|
"tray-quit" => {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let mut builder = tauri::Builder::default()
|
let mut builder = tauri::Builder::default()
|
||||||
@@ -13,12 +60,91 @@ pub fn run() {
|
|||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global shortcut + updater plugins are desktop-only (no mobile support).
|
// Global shortcut + updater + window-state plugins are desktop-only
|
||||||
|
// (no mobile support — mobile windows are OS-managed).
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
{
|
{
|
||||||
builder = builder
|
builder = builder
|
||||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build());
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_window_state::Builder::new().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
{
|
||||||
|
builder = builder.setup(|app| {
|
||||||
|
// Tray icon with a minimal menu. Left-click toggles window
|
||||||
|
// visibility; right-click shows the menu. Badge / tooltip updates
|
||||||
|
// come from the JS side via `tray-unread-update` events.
|
||||||
|
let show = MenuItem::with_id(app, "tray-show", "Öffnen", true, None::<&str>)?;
|
||||||
|
let hide = MenuItem::with_id(app, "tray-hide", "Ausblenden", true, None::<&str>)?;
|
||||||
|
let sep = PredefinedMenuItem::separator(app)?;
|
||||||
|
let quit = MenuItem::with_id(app, "tray-quit", "Beenden", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show, &hide, &sep, &quit])?;
|
||||||
|
|
||||||
|
let mut tray_builder = TrayIconBuilder::with_id("chatapp-tray")
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.tooltip("ChatApp")
|
||||||
|
.on_menu_event(|app, event| handle_menu_event(app, event))
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
let app = tray.app_handle();
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
if win.is_visible().unwrap_or(false) {
|
||||||
|
let _ = win.hide();
|
||||||
|
} else {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// `default_window_icon` returns Option<&Image>; only attach if
|
||||||
|
// we actually have one bundled (should always be true via the
|
||||||
|
// tauri.conf.json icon list, but guard to stay typesafe).
|
||||||
|
if let Some(icon) = app.default_window_icon() {
|
||||||
|
tray_builder = tray_builder.icon(icon.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let tray = tray_builder.build(app)?;
|
||||||
|
|
||||||
|
// Listen for JS-side unread updates and mirror them into the tray
|
||||||
|
// tooltip + macOS dock badge. `tray` is cheap to clone (internal
|
||||||
|
// Arc) so we can move it into the listener closure directly.
|
||||||
|
let tray_handle = tray.clone();
|
||||||
|
let badge_window = app.get_webview_window("main");
|
||||||
|
app.listen("tray-unread-update", move |event| {
|
||||||
|
let Ok(payload) = serde_json::from_str::<TrayUnreadPayload>(event.payload())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let tooltip = if payload.count == 0 {
|
||||||
|
"ChatApp".to_string()
|
||||||
|
} else {
|
||||||
|
format!("ChatApp · {} neu", payload.count)
|
||||||
|
};
|
||||||
|
let _ = tray_handle.set_tooltip(Some(tooltip));
|
||||||
|
// macOS dock badge. `set_badge_label` is macOS-only but the
|
||||||
|
// call is a no-op on other platforms so we don't need a cfg.
|
||||||
|
if let Some(win) = badge_window.as_ref() {
|
||||||
|
let badge = if payload.count == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(payload.count.to_string())
|
||||||
|
};
|
||||||
|
let _ = win.set_badge_label(badge);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
builder
|
builder
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { playNotificationTone } from '../lib/notificationSound';
|
import { playNotificationTone } from '../lib/notificationSound';
|
||||||
import { notify } from '../lib/osNotify';
|
import { notify } from '../lib/osNotify';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { updateTrayUnread } from '../lib/trayBadge';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
|
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
|
||||||
@@ -253,6 +254,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
return s;
|
return s;
|
||||||
}, [unread]);
|
}, [unread]);
|
||||||
|
|
||||||
|
// Mirror unread count into the tray tooltip + dock badge. Runs in Tauri
|
||||||
|
// only; no-op in browser-preview.
|
||||||
|
useEffect(() => {
|
||||||
|
void updateTrayUnread(totalUnread);
|
||||||
|
}, [totalUnread]);
|
||||||
|
|
||||||
const value = useMemo<ConversationsContextValue>(
|
const value = useMemo<ConversationsContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
conversations,
|
conversations,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { emit } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
import { isTauriRuntime } from './globalShortcut';
|
||||||
|
|
||||||
|
// Pushes the current aggregate unread count to the Rust-side tray listener.
|
||||||
|
// Rust mirrors it into the tray tooltip + macOS dock badge. No-op in the
|
||||||
|
// browser/dev preview where the Tauri runtime isn't present.
|
||||||
|
export async function updateTrayUnread(count: number): Promise<void> {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
try {
|
||||||
|
await emit('tray-unread-update', { count: Math.max(0, Math.floor(count)) });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('updateTrayUnread failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,13 @@ import {
|
|||||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
|
||||||
|
import {
|
||||||
|
loadCachedMessages,
|
||||||
|
persistMessages,
|
||||||
|
pruneCache,
|
||||||
|
deleteCachedMessage,
|
||||||
|
} from './messageCache';
|
||||||
import {
|
import {
|
||||||
enqueueOutbox,
|
enqueueOutbox,
|
||||||
getOutbox,
|
getOutbox,
|
||||||
@@ -103,6 +110,10 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
messages,
|
messages,
|
||||||
ownDeviceId: deviceId,
|
ownDeviceId: deviceId,
|
||||||
ownPrivateKey: priv,
|
ownPrivateKey: priv,
|
||||||
|
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
|
||||||
|
// the main thread stays responsive during bulk operations (initial
|
||||||
|
// fetch, backfill after sleep).
|
||||||
|
aeadBatchDelegate: decryptBatchWorker,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[deviceId],
|
[deviceId],
|
||||||
@@ -120,6 +131,10 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||||
const decrypted = await decryptBatch(rows);
|
const decrypted = await decryptBatch(rows);
|
||||||
setState({ messages: decrypted, loading: false, error: null });
|
setState({ messages: decrypted, loading: false, error: null });
|
||||||
|
// Persist the fresh batch to the local cache so next conversation
|
||||||
|
// switch / app start can hydrate instantly. Fire-and-forget — cache
|
||||||
|
// write failure is never user-visible.
|
||||||
|
void persistMessages(conversationId, decrypted);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -129,6 +144,31 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
}
|
}
|
||||||
}, [conversationId, decryptBatch]);
|
}, [conversationId, decryptBatch]);
|
||||||
|
|
||||||
|
// Hydrate from the local SQLite cache the moment the conversation id
|
||||||
|
// changes. Runs in parallel with the network fetch — whichever resolves
|
||||||
|
// first populates the UI, and `refresh` will replace stale cache data
|
||||||
|
// when the server response lands. On cache-miss this is a ~5ms no-op.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!conversationId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void loadCachedMessages(conversationId).then((cached) => {
|
||||||
|
if (cancelled || cached.length === 0) return;
|
||||||
|
setState((prev) => {
|
||||||
|
// Don't clobber a fresh server response that already landed.
|
||||||
|
if (prev.messages.length > 0) return prev;
|
||||||
|
return { messages: cached, loading: false, error: null };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
// Prune cache once per app session.
|
||||||
|
useEffect(() => {
|
||||||
|
void pruneCache();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Realtime INSERT handler — refetches the row via REST so we get the
|
// Realtime INSERT handler — refetches the row via REST so we get the
|
||||||
// canonical bytea encoding (postgres_changes payloads serialize bytea
|
// canonical bytea encoding (postgres_changes payloads serialize bytea
|
||||||
// differently and decoding them inline is brittle). Then decrypt + append.
|
// differently and decoding them inline is brittle). Then decrypt + append.
|
||||||
@@ -296,6 +336,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
...prev,
|
...prev,
|
||||||
messages: prev.messages.filter((m) => m.id !== id),
|
messages: prev.messages.filter((m) => m.id !== id),
|
||||||
}));
|
}));
|
||||||
|
void deleteCachedMessage(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Web Worker — runs XSalsa20-Poly1305 decrypt + utf8 decode off the main
|
||||||
|
// thread. The conv-key lookup (which is network-bound) stays in the main
|
||||||
|
// thread; only the CPU-heavy AEAD + UTF-8 step runs here.
|
||||||
|
//
|
||||||
|
// Message protocol:
|
||||||
|
// request: { id: string, items: Array<{ id, ciphertext, nonce, key }> }
|
||||||
|
// response: { id: string, results: Array<{ id, plaintext: string | null }> }
|
||||||
|
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import sodium from 'libsodium-wrappers-sumo';
|
||||||
|
|
||||||
|
interface DecryptItem {
|
||||||
|
id: string;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
nonce: Uint8Array;
|
||||||
|
key: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecryptRequest {
|
||||||
|
id: string;
|
||||||
|
items: DecryptItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecryptResult {
|
||||||
|
id: string;
|
||||||
|
plaintext: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecryptResponse {
|
||||||
|
id: string;
|
||||||
|
results: DecryptResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let sodiumReady: Promise<typeof sodium> | null = null;
|
||||||
|
|
||||||
|
async function ensureSodium(): Promise<typeof sodium> {
|
||||||
|
if (!sodiumReady) sodiumReady = sodium.ready.then(() => sodium);
|
||||||
|
return sodiumReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeOne(s: typeof sodium, item: DecryptItem): string | null {
|
||||||
|
try {
|
||||||
|
const plain = s.crypto_secretbox_open_easy(item.ciphertext, item.nonce, item.key);
|
||||||
|
return new TextDecoder('utf-8', { fatal: false }).decode(plain);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('message', (ev: MessageEvent<DecryptRequest>) => {
|
||||||
|
const req = ev.data;
|
||||||
|
void (async () => {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const results: DecryptResult[] = req.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
plaintext: decodeOne(s, item),
|
||||||
|
}));
|
||||||
|
const resp: DecryptResponse = { id: req.id, results };
|
||||||
|
(self as unknown as Worker).postMessage(resp);
|
||||||
|
})();
|
||||||
|
});
|
||||||
@@ -444,16 +444,34 @@ export interface DecryptParams {
|
|||||||
messages: MessageWithCipher[];
|
messages: MessageWithCipher[];
|
||||||
ownDeviceId: string;
|
ownDeviceId: string;
|
||||||
ownPrivateKey: Uint8Array;
|
ownPrivateKey: Uint8Array;
|
||||||
|
/**
|
||||||
|
* Optional delegate that performs the symmetric-decrypt + utf-8 decode
|
||||||
|
* step for a batch of messages. When provided, the main thread only does
|
||||||
|
* conv-key lookup; the CPU-heavy AEAD loop runs inside the delegate (e.g.
|
||||||
|
* a Web Worker). Items arrive with their per-message conv-key attached.
|
||||||
|
*/
|
||||||
|
aeadBatchDelegate?: (
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
nonce: Uint8Array;
|
||||||
|
key: Uint8Array;
|
||||||
|
}>,
|
||||||
|
) => Promise<Array<{ id: string; plaintext: string | null }>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypts messages using their conv-key (looked up + cached per
|
// Decrypts messages using their conv-key (looked up + cached per
|
||||||
// keyVersion). Returns null `plaintext` when this device has no key bundle
|
// keyVersion). Returns null `plaintext` when this device has no key bundle
|
||||||
// for that version yet (e.g. brand-new device waiting for share).
|
// for that version yet (e.g. brand-new device waiting for share).
|
||||||
export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMessage[]> {
|
export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMessage[]> {
|
||||||
const out: DecryptedMessage[] = [];
|
|
||||||
// Group versions to avoid redundant lookups.
|
|
||||||
const versions = new Map<string, Map<number, Uint8Array | null>>(); // convId -> version -> key | null
|
const versions = new Map<string, Map<number, Uint8Array | null>>(); // convId -> version -> key | null
|
||||||
|
// First pass: resolve conv-keys for every message. Network-bound, stays on
|
||||||
|
// caller's thread so Supabase client + session remain usable.
|
||||||
|
interface Resolved {
|
||||||
|
message: MessageWithCipher;
|
||||||
|
key: Uint8Array | null;
|
||||||
|
}
|
||||||
|
const resolved: Resolved[] = [];
|
||||||
for (const m of opts.messages) {
|
for (const m of opts.messages) {
|
||||||
let convCache = versions.get(m.conversationId);
|
let convCache = versions.get(m.conversationId);
|
||||||
if (!convCache) {
|
if (!convCache) {
|
||||||
@@ -474,17 +492,41 @@ export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMes
|
|||||||
key = handle?.key ?? null;
|
key = handle?.key ?? null;
|
||||||
convCache.set(m.keyVersion, key);
|
convCache.set(m.keyVersion, key);
|
||||||
}
|
}
|
||||||
|
resolved.push({ message: m, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: symmetric decrypt. If a delegate is supplied (Web Worker),
|
||||||
|
// batch the CPU-heavy step to it; otherwise fall back to inline.
|
||||||
|
if (opts.aeadBatchDelegate) {
|
||||||
|
const batch = resolved
|
||||||
|
.filter((r): r is Resolved & { key: Uint8Array } => r.key !== null)
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.message.id,
|
||||||
|
ciphertext: r.message.ciphertext,
|
||||||
|
nonce: r.message.nonce,
|
||||||
|
key: r.key,
|
||||||
|
}));
|
||||||
|
const plaintextById = new Map<string, string | null>();
|
||||||
|
if (batch.length > 0) {
|
||||||
|
const results = await opts.aeadBatchDelegate(batch);
|
||||||
|
for (const result of results) plaintextById.set(result.id, result.plaintext);
|
||||||
|
}
|
||||||
|
return resolved.map((r) => ({
|
||||||
|
...r.message,
|
||||||
|
plaintext: r.key ? plaintextById.get(r.message.id) ?? null : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved.map((r) => {
|
||||||
let plaintext: string | null = null;
|
let plaintext: string | null = null;
|
||||||
if (key) {
|
if (r.key) {
|
||||||
try {
|
try {
|
||||||
const decoded = decryptWithConvKey(m.ciphertext, m.nonce, key);
|
const decoded = decryptWithConvKey(r.message.ciphertext, r.message.nonce, r.key);
|
||||||
plaintext = bytesToUtf8(decoded);
|
plaintext = bytesToUtf8(decoded);
|
||||||
} catch {
|
} catch {
|
||||||
plaintext = null;
|
plaintext = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.push({ ...m, plaintext });
|
return { ...r.message, plaintext };
|
||||||
}
|
});
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user