825160ee46
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
260 lines
7.6 KiB
TypeScript
260 lines
7.6 KiB
TypeScript
// 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.
|
|
//
|
|
// Under Electron all SQL goes through the main-process sql bridge
|
|
// (better-sqlite3). Public API below is identical to the Tauri-era
|
|
// version — callers don't change.
|
|
|
|
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
|
|
|
import { isTauriRuntime } from './globalShortcut';
|
|
|
|
const DB_NAME = 'chatapp-cache';
|
|
|
|
let loadPromise: Promise<string | null> | null = null;
|
|
|
|
async function getHandle(): Promise<string | null> {
|
|
if (loadPromise) return loadPromise;
|
|
if (!isTauriRuntime()) {
|
|
loadPromise = Promise.resolve(null);
|
|
return loadPromise;
|
|
}
|
|
loadPromise = (async (): Promise<string | null> => {
|
|
try {
|
|
const handle = await window.electronAPI.sqlLoad({ name: DB_NAME });
|
|
await execute(
|
|
handle,
|
|
`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 execute(
|
|
handle,
|
|
'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);',
|
|
);
|
|
// FTS5 — conditional since not every SQLite build has it.
|
|
try {
|
|
await execute(
|
|
handle,
|
|
`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
message_id UNINDEXED,
|
|
conversation_id UNINDEXED,
|
|
plaintext,
|
|
tokenize = 'unicode61 remove_diacritics 2'
|
|
);`,
|
|
);
|
|
await execute(
|
|
handle,
|
|
`CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
|
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
|
|
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
|
|
END;`,
|
|
);
|
|
await execute(
|
|
handle,
|
|
`CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
|
DELETE FROM messages_fts WHERE message_id = old.id;
|
|
END;`,
|
|
);
|
|
await execute(
|
|
handle,
|
|
`CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
|
DELETE FROM messages_fts WHERE message_id = old.id;
|
|
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
|
|
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
|
|
END;`,
|
|
);
|
|
} catch (err: unknown) {
|
|
console.warn('FTS5 init failed — search falls back to in-memory scan', err);
|
|
}
|
|
return handle;
|
|
} catch (err: unknown) {
|
|
console.warn('messageCache init failed — falling back to memory-only', err);
|
|
return null;
|
|
}
|
|
})();
|
|
return loadPromise;
|
|
}
|
|
|
|
async function execute(handle: string, query: string, bindings?: unknown[]): Promise<void> {
|
|
await window.electronAPI.sqlExecute({ handle, query, bindings: bindings ?? [] });
|
|
}
|
|
|
|
async function selectRows<T = Row>(
|
|
handle: string,
|
|
query: string,
|
|
bindings?: unknown[],
|
|
): Promise<T[]> {
|
|
const rows = await window.electronAPI.sqlSelect({
|
|
handle,
|
|
query,
|
|
bindings: bindings ?? [],
|
|
});
|
|
return rows as unknown as T[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 handle = await getHandle();
|
|
if (!handle) return [];
|
|
try {
|
|
const rows = await selectRows<Row>(
|
|
handle,
|
|
'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 handle = await getHandle();
|
|
if (!handle || messages.length === 0) return;
|
|
try {
|
|
for (const m of messages) {
|
|
await execute(
|
|
handle,
|
|
`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 handle = await getHandle();
|
|
if (!handle) return;
|
|
try {
|
|
await execute(handle, 'DELETE FROM messages WHERE id = $1', [id]);
|
|
} catch (err: unknown) {
|
|
console.warn('deleteCachedMessage failed', err);
|
|
}
|
|
}
|
|
|
|
// Full-text search across cached messages. Empty result on cache-miss
|
|
// or FTS5 unavailable — caller should fall back to in-memory regex.
|
|
export async function searchCachedMessages(
|
|
conversationId: string,
|
|
query: string,
|
|
limit = 200,
|
|
): Promise<DecryptedMessage[]> {
|
|
const handle = await getHandle();
|
|
if (!handle) return [];
|
|
const trimmed = query.trim();
|
|
if (trimmed.length === 0) return [];
|
|
const tokens = trimmed
|
|
.replace(/["' |