From eff167cb6a52451d23dbe830fb529dfc313b081f Mon Sep 17 00:00:00 2001 From: "Khokon M." <50947615+khokonm@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:58:16 +0600 Subject: [PATCH] fix(sync): stop connect() bailing on a wiped sync code (stuck on Connecting) + fix device-role classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the perpetual 'Connecting...': the vault-encrypted sync code only lives in memory (cachedSyncCode). enableSync() sets it but persists async; the immediately-following loadSyncCode() read localStorage before that write landed and overwrote the good code with '', so connect() hit 'if (!syncCode) return' and never opened the WebSocket — no socket, no retry, no join. Hardened: loadSyncCode never clobbers an in-memory code (and swallows no errors silently), getSyncCode never returns ciphertext, and connect() reloads the code from the vault and retries instead of dead-ending. Also folds in the role fix (was PR #9, now superseded): clients send hasData on join so the notes-holding device is classified as source/approver instead of a transfer requester — otherwise both devices showed 'Choose a source device' and the approval prompt appeared nowhere. --- server/src/index.ts | 14 ++++++-- src/utils/sync.ts | 81 ++++++++++++++++++++++++++++++--------------- 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 600c5a7..9074072 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -16,7 +16,7 @@ * * WebSocket protocol (JSON messages): * Client → Server: - * { type: 'join', roomId, token, deviceId, deviceName } + * { type: 'join', roomId, token, deviceId, deviceName, hasData } * { type: 'push-op', payload (base64) } * { type: 'ack', cursor } * { type: 'request-transfer' } @@ -641,10 +641,18 @@ function handleMessage(client: Client, msg: Record, ip: string) const room = getRoom(roomId) room.add(client) - // Check if this is a new device (cursor = 0 and other devices exist) + // A device "needs a transfer" only when it has NO notes of its own yet and + // there is another device in the chain to copy from. The client reports + // whether it already holds local notes via `hasData` — the server can't + // see the (E2E-encrypted) data itself. Relying on `cursor === 0` alone was + // wrong: the device that generated the sync code owns all the notes but + // hasn't pushed any ops, so its cursor is still 0. That misclassified the + // notes-holding device as a transfer requester, so both devices showed + // "Choose a source device" and the approval prompt never appeared anywhere. + const hasData = msg.hasData === true const allDevices = getDevices(roomId) const otherDevices = allDevices.filter(d => d.deviceId !== deviceId) - const isNewDevice = device.cursor === 0 && otherDevices.length > 0 + const isNewDevice = !hasData && device.cursor === 0 && otherDevices.length > 0 const maxSeq = getMaxSeq(roomId) const hasPendingOps = device.cursor < maxSeq diff --git a/src/utils/sync.ts b/src/utils/sync.ts index 2014945..049aa7c 100644 --- a/src/utils/sync.ts +++ b/src/utils/sync.ts @@ -435,14 +435,42 @@ async function refreshJoinToken(): Promise<{ roomId: string; token: string } | n return getJoinToken() } +// Build the `join` payload. `hasData` tells the server whether this device +// already holds notes locally. The server can't inspect our (E2E-encrypted) +// data, so without this hint it inferred "new device" purely from cursor === 0 +// — which wrongly flagged the device that *generated* the sync code (owns all +// the notes but hasn't pushed any ops, so cursor is still 0) as a device +// needing a transfer. That left both devices stuck on "Choose a source device" +// with neither ever showing the approval prompt. A device that has local notes +// is a source/approver, never a transfer requester. +function buildJoinPayload(roomId: string, token: string): string { + return JSON.stringify({ + type: 'join', + roomId, + token, + deviceId: getDeviceId(), + deviceName: getDeviceName(), + hasData: (callbacks?.getNotes()?.length ?? 0) > 0, + }) +} + async function connect(): Promise { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return - const syncCode = getSyncCode() - if (!syncCode) return - updateState({ status: 'connecting', error: null }) + // The sync code lives encrypted in the vault; the plaintext is only held in + // `cachedSyncCode`. Right after enableSync() the in-memory value is set but + // its async persist may not have landed yet. If we don't have it in memory, + // (re)load it from the vault before giving up — and if it's still missing, + // schedule a retry instead of silently dead-ending on "Connecting…" forever. + let syncCode = getSyncCode() + if (!syncCode) syncCode = await loadSyncCode() + if (!syncCode) { + scheduleReconnect() + return + } + const joinInfo = await getJoinToken() if (!joinInfo) { updateState({ status: 'error', error: 'Failed to authenticate sync code' }) @@ -461,13 +489,7 @@ async function connect(): Promise { } ws.onopen = () => { - ws!.send(JSON.stringify({ - type: 'join', - roomId, - token, - deviceId: getDeviceId(), - deviceName: getDeviceName(), - })) + ws!.send(buildJoinPayload(roomId, token)) } ws.onmessage = async (event) => { @@ -785,13 +807,7 @@ async function handleServerMessage(msg: Record): Promise console.log('[sync] Token expired, refreshing…') const newJoin = await refreshJoinToken() if (newJoin && ws?.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - type: 'join', - roomId: newJoin.roomId, - token: newJoin.token, - deviceId: getDeviceId(), - deviceName: getDeviceName(), - })) + ws.send(buildJoinPayload(newJoin.roomId, newJoin.token)) } } break @@ -1032,21 +1048,32 @@ export async function validateSyncCode(syncCode: string): Promise<{ roomId: stri let cachedSyncCode: string | null = null export function getSyncCode(): string { - if (cachedSyncCode !== null) return cachedSyncCode - return localStorage.getItem(SYNC_CODE_KEY) - ?? localStorage.getItem(SYNC_PASSWORD_KEY) - ?? '' + // The plaintext code is only ever held in memory. The on-disk value + // (SYNC_CODE_KEY / SYNC_PASSWORD_KEY) is vault-encrypted ciphertext, so it + // must NOT be returned here — doing so would hand callers a "v1:…" blob and + // derive the wrong room/key. Callers needing it before the vault has been + // read should await loadSyncCode(). + return cachedSyncCode ?? '' } export async function loadSyncCode(): Promise { - let val = await secureGet(SYNC_CODE_KEY) - if (!val) { - val = await secureGet(SYNC_PASSWORD_KEY) - if (val) { - await secureSet(SYNC_CODE_KEY, val) + try { + let val = await secureGet(SYNC_CODE_KEY) + if (!val) { + val = await secureGet(SYNC_PASSWORD_KEY) + if (val) { + await secureSet(SYNC_CODE_KEY, val) + } } + // Only adopt a value we actually decrypted. Crucially, do NOT clobber a + // code already held in memory (e.g. one just set by enableSync() whose + // async vault write hasn't landed yet) with an empty read — that race was + // what left connect() with no code and the UI stuck on "Connecting…". + if (val) cachedSyncCode = val + } catch (e) { + console.warn('[sync] Failed to load sync code from vault:', e) } - cachedSyncCode = val ?? '' + if (cachedSyncCode === null) cachedSyncCode = '' return cachedSyncCode }