Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58fa9487e3 | |||
| d9b08592da | |||
| 4a80bf1c0e | |||
| 05c962d46f | |||
| cf3fef6936 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ChatApp",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.7",
|
||||
"identifier": "com.meinname.chatapp",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm vite:dev",
|
||||
|
||||
@@ -61,7 +61,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status === 401 || status === 403) {
|
||||
// Token genuinely invalid — wipe.
|
||||
await supabase.auth.signOut().catch(() => {
|
||||
await supabase.auth.signOut({ scope: 'local' }).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
setSession(null);
|
||||
|
||||
@@ -39,11 +39,21 @@ export async function setSecretStoreUser(userId: string | null): Promise<void> {
|
||||
|
||||
if (userId && isTauriRuntime()) {
|
||||
const stronghold = makeStrongholdStore(userId);
|
||||
activeBackend = stronghold;
|
||||
try {
|
||||
await migrateLocalStorageToStronghold(userId, PREFIX);
|
||||
// Force a tiny round-trip to verify Stronghold can actually open the
|
||||
// vault on this machine. If not (broken vault file, bundled rust crate
|
||||
// mismatch, etc.) we fall back to localStorage so the rest of the app
|
||||
// remains usable instead of bricking device registration.
|
||||
await stronghold.getSecret('__probe');
|
||||
activeBackend = stronghold;
|
||||
try {
|
||||
await migrateLocalStorageToStronghold(userId, PREFIX);
|
||||
} catch (err: unknown) {
|
||||
console.warn('stronghold migration failed', err);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('stronghold migration failed', err);
|
||||
console.warn('stronghold init failed — falling back to localStorage', err);
|
||||
activeBackend = localStore;
|
||||
}
|
||||
} else {
|
||||
activeBackend = localStore;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type MessageWithCipher,
|
||||
sendEncryptedMessage,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { bytesToPgHex, pgHexToBytes } from '@chat-app/shared/supabase';
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
@@ -44,8 +44,8 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
||||
editedAt: row.edited_at ? String(row.edited_at) : null,
|
||||
deletedAt: row.deleted_at ? String(row.deleted_at) : null,
|
||||
createdAt: String(row.created_at),
|
||||
ciphertext: pgHexToBytes(String(row.ciphertext ?? '\\x')),
|
||||
nonce: pgHexToBytes(String(row.nonce ?? '\\x')),
|
||||
ciphertext: pgBytesToBytes(String(row.ciphertext ?? '\\x')),
|
||||
nonce: pgBytesToBytes(String(row.nonce ?? '\\x')),
|
||||
keyVersion: typeof row.key_version === 'number' ? row.key_version : 1,
|
||||
};
|
||||
}
|
||||
@@ -98,26 +98,81 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
}, [conversationId, decryptBatch]);
|
||||
|
||||
// Realtime INSERT handler — decrypt + append (with retry for envelope race).
|
||||
// Realtime INSERT handler — refetches the row via REST so we get the
|
||||
// canonical bytea encoding (postgres_changes payloads serialize bytea
|
||||
// differently and decoding them inline is brittle). Then decrypt + append.
|
||||
// Skips if the message is already in state (e.g. optimistic insert from our
|
||||
// own send), so the sender's cached copy isn't overwritten with a flicker.
|
||||
const handleInsert = useCallback(
|
||||
async (row: Record<string, unknown>) => {
|
||||
if (!deviceId) return;
|
||||
const msg = rowToMessage(row);
|
||||
let decrypted: DecryptedMessage = { ...msg, plaintext: null };
|
||||
if (!conversationId || !deviceId) return;
|
||||
const id = String(row.id);
|
||||
|
||||
let alreadyHave = false;
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === id)) alreadyHave = true;
|
||||
return prev;
|
||||
});
|
||||
if (alreadyHave) return;
|
||||
|
||||
let decrypted: DecryptedMessage | null = null;
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select(
|
||||
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version',
|
||||
)
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
console.warn('handleInsert refetch failed', error);
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
// db-types snapshot predates the sender-key columns; cast to bypass.
|
||||
const r = data as unknown as {
|
||||
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;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
key_version: number;
|
||||
};
|
||||
const msg: MessageWithCipher = {
|
||||
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,
|
||||
ciphertext: pgBytesToBytes(String(r.ciphertext)),
|
||||
nonce: pgBytesToBytes(String(r.nonce)),
|
||||
keyVersion: r.key_version,
|
||||
};
|
||||
const [d] = await decryptBatch([msg]);
|
||||
if (d) {
|
||||
decrypted = d;
|
||||
if (d.plaintext !== null) break;
|
||||
}
|
||||
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
|
||||
await new Promise((r) => window.setTimeout(r, 200 * (attempt + 1)));
|
||||
}
|
||||
if (!decrypted) return;
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === decrypted.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted] };
|
||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted!] };
|
||||
});
|
||||
},
|
||||
[deviceId, decryptBatch],
|
||||
[conversationId, deviceId, decryptBatch],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
@@ -219,7 +274,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||
}
|
||||
|
||||
// 2. Send message (inserts messages + envelopes in one helper).
|
||||
// 2. Send message (inserts messages + per-conversation key bundles).
|
||||
const msg = await sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId,
|
||||
@@ -230,7 +285,28 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
|
||||
});
|
||||
|
||||
// 3. Insert public attachment metadata rows pointing at the new message.
|
||||
// 3. Optimistic insert — we already have the plaintext in hand and the
|
||||
// server returned the row id, so add the message to local state
|
||||
// immediately. Realtime will then no-op (handleInsert dedupes by id).
|
||||
const attachmentsPayload =
|
||||
handles.length === 0
|
||||
? trimmed
|
||||
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{
|
||||
...msg,
|
||||
plaintext: attachmentsPayload,
|
||||
} as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// 4. Insert public attachment metadata rows pointing at the new message.
|
||||
for (const h of handles) {
|
||||
const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x';
|
||||
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
|
||||
|
||||
@@ -86,7 +86,11 @@ export async function completeSessionFromUrl(
|
||||
}
|
||||
|
||||
export async function signOut(client: AppSupabaseClient): Promise<void> {
|
||||
const { error } = await client.auth.signOut();
|
||||
// `scope: 'local'` only ends the session in THIS client. Without it Supabase
|
||||
// defaults to 'global', which invalidates the user's refresh tokens
|
||||
// everywhere — meaning a logout in the browser would also kick the desktop
|
||||
// app (and vice versa) the next time it tries to refresh its token.
|
||||
const { error } = await client.auth.signOut({ scope: 'local' });
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,3 +23,20 @@ export function pgHexToBytes(hex: string): Uint8Array {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Accepts either:
|
||||
// - PostgREST/REST `\x<hex>` strings (what the .from('table').select() path
|
||||
// returns for bytea), or
|
||||
// - Realtime `postgres_changes` payloads, which encode bytea as plain
|
||||
// base64 (no `\x` prefix).
|
||||
// Useful when the same row can arrive through both paths in the same UI.
|
||||
export function pgBytesToBytes(value: string): Uint8Array {
|
||||
if (value.startsWith('\\x')) {
|
||||
return pgHexToBytes(value);
|
||||
}
|
||||
// Assume base64 (the realtime serializer's default for bytea).
|
||||
const bin = atob(value);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,20 @@ import type { Database, SupabaseConfig } from './types.js';
|
||||
// Typed client alias used throughout the app.
|
||||
export type AppSupabaseClient = SupabaseClient<Database>;
|
||||
|
||||
// Inline serial lock — replaces Supabase's default `navigator.locks` based
|
||||
// lock that occasionally throws "Lock was stolen by another request" when
|
||||
// the same origin opens multiple tabs / Tauri windows / HMR-reloaded
|
||||
// modules. We only have one client instance per process so a simple promise
|
||||
// chain serialises token-refresh fine without cross-tab coordination.
|
||||
const acquireLock = (() => {
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
return async <R>(_name: string, _acquireTimeout: number, fn: () => Promise<R>): Promise<R> => {
|
||||
const next = chain.then(() => fn(), () => fn());
|
||||
chain = next.catch(() => undefined);
|
||||
return next;
|
||||
};
|
||||
})();
|
||||
|
||||
export function createClient(config: SupabaseConfig): AppSupabaseClient {
|
||||
return createSupabaseClient<Database>(config.url, config.anonKey, {
|
||||
auth: {
|
||||
@@ -12,6 +26,7 @@ export function createClient(config: SupabaseConfig): AppSupabaseClient {
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: config.detectSessionInUrl ?? false,
|
||||
lock: acquireLock,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user