Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -641,10 +641,18 @@ function handleMessage(client: Client, msg: Record<string, unknown>, 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

Expand Down
35 changes: 21 additions & 14 deletions src/utils/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return

Expand All @@ -461,13 +480,7 @@ async function connect(): Promise<void> {
}

ws.onopen = () => {
ws!.send(JSON.stringify({
type: 'join',
roomId,
token,
deviceId: getDeviceId(),
deviceName: getDeviceName(),
}))
ws!.send(buildJoinPayload(roomId, token))
}

ws.onmessage = async (event) => {
Expand Down Expand Up @@ -785,13 +798,7 @@ async function handleServerMessage(msg: Record<string, unknown>): Promise<void>
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
Expand Down
Loading