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..7f931eb 100644 --- a/src/utils/sync.ts +++ b/src/utils/sync.ts @@ -435,6 +435,25 @@ 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 (and owns +// all the notes, but hasn't pushed any ops yet, 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 @@ -461,13 +480,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 +798,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