Compare commits

...

4 Commits

Author SHA1 Message Date
byGalax ff5ea274b9 fix(keysync): swallow expected supabase errors by code/status, not just message
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:08:54 +02:00
byGalax b0f9f1dada fix(keysync): silence expected RLS rejections during best-effort backfill
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:03:49 +02:00
byGalax 961ac2dde5 fix: idempotent conv-key upsert + guard tauri-only notification calls
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:00:21 +02:00
byGalax 7efbcf7e39 fix(vault): per-user filename so multi-account on same machine works
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 21:29:03 +02:00
5 changed files with 63 additions and 17 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp", "productName": "ChatApp",
"version": "0.4.0", "version": "0.4.4",
"identifier": "com.meinname.chatapp", "identifier": "com.meinname.chatapp",
"build": { "build": {
"beforeDevCommand": "pnpm vite:dev", "beforeDevCommand": "pnpm vite:dev",
+22 -3
View File
@@ -164,9 +164,12 @@ async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<vo
try { try {
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx); await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
} catch (err: unknown) { } catch (err: unknown) {
// Most common: this device hasn't been wrapped for us yet either, so // Backfill is best-effort. Most common silent failures:
// tryGetConvKey couldn't unwrap. Another peer with the key will fill // - tryGetConvKey couldn't unwrap (another peer will fill the gap).
// the gap when they hit syncAllExistingGaps. // - RLS rejects because the recipient's owner is a pending (not-yet
// accepted) DM member, or was removed from the conv.
// Both are recoverable / expected, swallow without spam.
if (isExpectedShareFailure(err)) continue;
console.warn('keySync: shareConvKeyToDevice gap-fill failed', { console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
convId, convId,
recipient: dev.id, recipient: dev.id,
@@ -176,6 +179,22 @@ async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<vo
} }
} }
function isExpectedShareFailure(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as { message?: string; code?: string; details?: string; status?: number };
const code = (e.code ?? '').toString();
const status = e.status;
const haystack = (e.message ?? '') + ' ' + (e.details ?? '');
return (
status === 403 ||
code === '42501' || // postgres: insufficient_privilege (RLS)
code === '23505' || // unique_violation
haystack.includes('row-level security') ||
haystack.includes('does not have it yet') ||
haystack.includes('Forbidden')
);
}
async function wrapForOneDevice( async function wrapForOneDevice(
ctx: SyncCtx, ctx: SyncCtx,
newDeviceId: string, newDeviceId: string,
+8 -1
View File
@@ -4,6 +4,8 @@ import {
sendNotification, sendNotification,
} from '@tauri-apps/plugin-notification'; } from '@tauri-apps/plugin-notification';
import { isTauriRuntime } from './globalShortcut';
// Tracks whether permission has already been requested this session so we // Tracks whether permission has already been requested this session so we
// don't spam the OS prompt. Actual permission state lives in the OS. // don't spam the OS prompt. Actual permission state lives in the OS.
let permissionChecked = false; let permissionChecked = false;
@@ -12,6 +14,11 @@ let permissionGranted = false;
export async function ensureNotificationPermission(): Promise<boolean> { export async function ensureNotificationPermission(): Promise<boolean> {
if (permissionChecked) return permissionGranted; if (permissionChecked) return permissionGranted;
permissionChecked = true; permissionChecked = true;
if (!isTauriRuntime()) {
// Web preview / Chrome — Tauri notification plugin not available.
permissionGranted = false;
return false;
}
try { try {
let granted = await isPermissionGranted(); let granted = await isPermissionGranted();
if (!granted) { if (!granted) {
@@ -20,7 +27,6 @@ export async function ensureNotificationPermission(): Promise<boolean> {
} }
permissionGranted = granted; permissionGranted = granted;
} catch (err: unknown) { } catch (err: unknown) {
// Not running under Tauri (e.g. web preview) — fall back silently.
permissionGranted = false; permissionGranted = false;
console.warn('notification permission check failed', err); console.warn('notification permission check failed', err);
} }
@@ -40,6 +46,7 @@ interface NotifyOpts {
export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> { export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> {
if (!force && isAppFocused()) return; if (!force && isAppFocused()) return;
if (!isTauriRuntime()) return;
const granted = await ensureNotificationPermission(); const granted = await ensureNotificationPermission();
if (!granted) return; if (!granted) return;
try { try {
+13 -2
View File
@@ -22,7 +22,17 @@ import sodium from 'libsodium-wrappers';
// renamed onto `<file>` so an interrupted write never corrupts the existing // renamed onto `<file>` so an interrupted write never corrupts the existing
// vault. // vault.
const FILE_NAME = 'chatapp-vault.bin'; // Per-user vault filename so multiple accounts on the same machine each get
// their own file (and Argon2 derives a different key per user, so cross-user
// decrypt is also blocked even if filenames collided).
async function vaultFileName(userId: string): Promise<string> {
const enc = new TextEncoder();
const buf = await crypto.subtle.digest('SHA-256', enc.encode('chatapp-vault-name:' + userId));
const hex = Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return 'chatapp-vault-' + hex.slice(0, 16) + '.bin';
}
const MAGIC = new TextEncoder().encode('CHATVLT1'); // 8 bytes const MAGIC = new TextEncoder().encode('CHATVLT1'); // 8 bytes
const SALT_LEN = 16; const SALT_LEN = 16;
const NONCE_LEN = 24; const NONCE_LEN = 24;
@@ -79,7 +89,8 @@ async function deriveKey(userId: string, salt: Uint8Array, s: typeof sodium): Pr
async function loadOrCreateVault(userId: string): Promise<VaultState> { async function loadOrCreateVault(userId: string): Promise<VaultState> {
const s = await ensureSodium(); const s = await ensureSodium();
const dir = await appLocalDataDir(); const dir = await appLocalDataDir();
const path = joinPath(dir, FILE_NAME); const fileName = await vaultFileName(userId);
const path = joinPath(dir, fileName);
const tmpPath = path + '.tmp'; const tmpPath = path + '.tmp';
try { try {
+19 -10
View File
@@ -161,7 +161,10 @@ export async function bootstrapConvKey(
}); });
} }
const { error } = await rawFrom(client, 'conversation_keys').insert(rows); const { error } = await rawFrom(client, 'conversation_keys').upsert(rows, {
onConflict: 'conversation_id,recipient_device_id,key_version',
ignoreDuplicates: true,
});
if (error) throw error; if (error) throw error;
const handle = { conversationId, keyVersion, key: convKey }; const handle = { conversationId, keyVersion, key: convKey };
@@ -262,15 +265,21 @@ export async function shareConvKeyToDevice(
recipientPublicKey, recipientPublicKey,
own.privateKey, own.privateKey,
); );
const { error } = await rawFrom(client, 'conversation_keys').insert({ const { error } = await rawFrom(client, 'conversation_keys').upsert(
conversation_id: conversationId, {
recipient_device_id: recipientDeviceId, conversation_id: conversationId,
key_version: version, recipient_device_id: recipientDeviceId,
sender_device_id: own.deviceId, key_version: version,
encrypted_key: bytesToPgHex(wrapped.ciphertext), sender_device_id: own.deviceId,
nonce: bytesToPgHex(wrapped.nonce), encrypted_key: bytesToPgHex(wrapped.ciphertext),
}); nonce: bytesToPgHex(wrapped.nonce),
if (error && !String(error.message ?? '').includes('duplicate')) throw error; },
{
onConflict: 'conversation_id,recipient_device_id,key_version',
ignoreDuplicates: true,
},
);
if (error) throw error;
} }
// Re-exports for convenience. // Re-exports for convenience.