From 8b58a38f5fce36f4a7de92980f4616e0b4e087ad Mon Sep 17 00:00:00 2001 From: CharlesHsu Date: Fri, 30 Oct 2026 21:47:55 +0800 Subject: [PATCH 1/5] feat(sync): add causal event merge and explicit conflict resolution --- src/sync/merge.test.ts | 516 +++++++++++++++++++++++++++++++++++++++++ src/sync/merge.ts | 388 +++++++++++++++++++++++++++++++ src/sync/projection.ts | 269 +++++++++++++++++++++ src/sync/protocol.ts | 255 ++++++++++++++++++++ 4 files changed, 1428 insertions(+) create mode 100644 src/sync/merge.test.ts create mode 100644 src/sync/merge.ts create mode 100644 src/sync/projection.ts create mode 100644 src/sync/protocol.ts diff --git a/src/sync/merge.test.ts b/src/sync/merge.test.ts new file mode 100644 index 0000000..ff0b157 --- /dev/null +++ b/src/sync/merge.test.ts @@ -0,0 +1,516 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { fixture, now } from '../core/test-fixtures' +import { emptyLibrary, recordSchemas, saveNote, type Library } from '../core/model' +import { reviewCard, undoReview } from '../core/scheduler' +import { planSync } from './merge' +import { + canonicalSyncValue, + parseSyncEvents, + SYNC_LIMITS, + syncFingerprint, + type SyncChange, + type SyncClock, + type SyncEvent, + type SyncSnapshot, +} from './protocol' +import { + cloneSyncSnapshot, + libraryFromSnapshot, + snapshotFromLibrary, + snapshotToRetired, +} from './projection' + +const clean = (value: T): T => JSON.parse(JSON.stringify(value)) as T +function event( + deviceId: string, + sequence: number, + changes: SyncChange[], + clock: SyncClock = {}, +): SyncEvent { + return clean({ + protocol: 1, + groupId: 'test-group', + id: `${deviceId}:${sequence}`, + deviceId, + sequence, + clock: { ...clock, [deviceId]: sequence - 1 }, + changes, + }) +} +function difference(before: Library, after: Library): SyncChange[] { + const changes: SyncChange[] = [] + for (const store of Object.keys(recordSchemas) as (keyof typeof recordSchemas)[]) { + const previous = new Map(before[store].map((value) => [value.id, clean(value)])) + for (const [position, value] of after[store].entries()) { + const old = previous.get(value.id) ?? null, + next = clean(value) + if (canonicalSyncValue(old) !== canonicalSyncValue(next)) + changes.push({ store, id: value.id, before: old, after: next, position }) + previous.delete(value.id) + } + for (const [id, value] of previous) changes.push({ store, id, before: value, after: null }) + } + return changes +} +const run = (snapshot: SyncSnapshot, events: SyncEvent[], appliedClock: SyncClock = {}) => + planSync({ snapshot, events, appliedClock, groupId: 'test-group' }) +const changeNote = (library: Library, changes: Partial): Library => ({ + ...library, + notes: library.notes.map((note) => ({ ...note, ...changes })), +}) + +describe('sync envelope validation', () => { + it('uses canonical SHA-256 independent of object order and Unicode', () => { + const value = { b: ['中文', 5], a: 1 } + expect(syncFingerprint(value)).toBe( + createHash('sha256').update(canonicalSyncValue(value)).digest('hex'), + ) + expect(syncFingerprint(value)).toBe(syncFingerprint({ a: 1, b: ['中文', 5] })) + }) + it('deduplicates equivalent events and rejects equivocation within a batch', () => { + const first = event('a', 1, []) + expect(parseSyncEvents([first, clean(first)])).toHaveLength(1) + expect(() => + parseSyncEvents([ + first, + { + ...first, + changes: [{ store: 'values:settings', id: 'plugin:x', before: null, after: 1 }], + }, + ]), + ).toThrow('sync-event-identity-conflict') + }) + it.each([ + { protocol: 2 }, + { id: 'a:2' }, + { sequence: 0 }, + { clock: { a: 2 } }, + { clock: { a: -1 } }, + { extra: true }, + { deviceId: 'device:with:colon' }, + ])('rejects invalid envelope metadata %j', (patch) => { + expect(() => parseSyncEvents([{ ...event('a', 1, []), ...patch }])).toThrow() + }) + it('enforces group, count, causal device and per-transaction identity boundaries', () => { + expect(() => parseSyncEvents([event('a', 1, [])], 'other')).toThrow('sync-wrong-group') + expect(() => + parseSyncEvents(Array.from({ length: SYNC_LIMITS.events + 1 }, () => event('a', 1, []))), + ).toThrow('sync-batch-limit') + const change = { store: 'values:settings', id: 'plugin:theme', before: null, after: 'green' } + expect(() => parseSyncEvents([event('a', 1, [change, change])])).toThrow( + 'sync-duplicate-change', + ) + expect(() => + parseSyncEvents([event('a', 1, [{ ...change, store: 'values:leetcode' }])]), + ).toThrow('sync-unknown-store') + expect(() => parseSyncEvents([event('a', 1, [{ ...change, after: undefined }])])).toThrow() + expect(() => + parseSyncEvents([event('a', 1, [{ ...change, after: null, position: 0 }])]), + ).toThrow('sync-invalid-change') + }) + it('rejects cycles and non JSON payloads before projection', () => { + const cyclic: Record = {} + cyclic.self = cyclic + expect(() => canonicalSyncValue(cyclic)).toThrow('sync-invalid-json') + expect(() => canonicalSyncValue({ value: Number.NaN })).toThrow('sync-invalid-json') + expect(() => canonicalSyncValue(new Date())).toThrow('sync-invalid-json') + }) + it('requires an explicit own-device predecessor and rejects prototype device clocks', () => { + expect(() => parseSyncEvents([{ ...event('a', 1, []), clock: {} }])).toThrow( + 'sync-invalid-event-clock', + ) + for (const deviceId of ['__proto__', 'constructor', 'prototype']) { + const clock = JSON.parse(`{"${deviceId}":0}`) as SyncClock + expect(() => + parseSyncEvents([{ ...event('a', 1, []), deviceId, id: `${deviceId}:1`, clock }]), + ).toThrow() + } + }) + it('treats inherited clock property names as absent rather than already applied', () => { + const result = run(snapshotFromLibrary(fixture()), [event('toString', 1, [])]) + expect(result.appliedIds).toEqual(['toString:1']) + expect(result.clock.toString).toBe(1) + }) + it('rejects device preferences in the settings namespace while permitting plugin configuration', () => { + expect(() => + parseSyncEvents([ + event('a', 1, [{ store: 'values:settings', id: 'theme', before: null, after: 'green' }]), + ]), + ).toThrow('sync-nonportable-value') + const snapshot = snapshotFromLibrary(fixture()) + snapshot.values.settings = { theme: { position: 0, value: 'green' } } + expect(() => run(snapshot, [])).toThrow('sync-nonportable-value') + }) + it('handles prototype-looking record IDs as inert own properties without polluting objects', () => { + const base = fixture() + base.decks[0]!.id = '__proto__' + base.notes[0]!.id = 'constructor' + base.notes[0]!.deckId = '__proto__' + base.cards[0]!.id = 'prototype' + base.cards[0]!.noteId = 'constructor' + const edited = changeNote(base, { front: 'safe change' }) + const changes = difference(base, edited) + for (const id of ['__proto__', 'constructor', 'prototype']) + changes.push({ + store: 'values:plugin:sample', + id, + before: null, + after: JSON.parse( + '{"__proto__":{"polluted":true},"constructor":{"prototype":{"polluted":true}}}', + ), + }) + const plan = run(snapshotFromLibrary(base), [event('safe-device', 1, changes)]) + expect(plan.blocked).toBe(false) + expect(libraryFromSnapshot(plan.snapshot).notes[0]!.front).toBe('safe change') + for (const id of ['__proto__', 'constructor', 'prototype']) + expect(Object.hasOwn(plan.snapshot.values['plugin:sample']!, id)).toBe(true) + expect(({} as Record).polluted).toBeUndefined() + expect(Object.getPrototypeOf(plan.snapshot.values['plugin:sample']!)).toBe(Object.prototype) + }) + it('does not renumber unordered native values and retirement tables or invent resolution events', () => { + const snapshot = snapshotFromLibrary(fixture()) + snapshot.values['plugin:sample'] = { + a: { position: 0, value: 1 }, + b: { position: 0, value: 2 }, + } + const result = run(snapshot, []) + expect(result.resolutionChanges).toEqual([]) + expect(result.snapshot.values).toEqual(snapshot.values) + }) +}) + +describe('causal planning and content conflict resolution', () => { + it('applies unordered dependencies and duplicate delivery exactly once without mutating inputs', () => { + const base = fixture(), + edited = changeNote(base, { front: 'first' }), + again = changeNote(edited, { front: 'second' }) + const a = event('a', 1, difference(base, edited)), + b = event('a', 2, difference(edited, again)) + const snapshot = snapshotFromLibrary(base), + saved = clean({ snapshot, events: [b, a] }) + const plan = run(snapshot, [b, a, a]) + expect(plan.blocked).toBe(false) + expect(plan.appliedIds).toEqual(['a:1', 'a:2']) + expect(libraryFromSnapshot(plan.snapshot).notes[0]!.front).toBe('second') + expect({ snapshot, events: [b, a] }).toEqual(saved) + expect(run(plan.snapshot, [a, b], plan.clock).appliedIds).toEqual([]) + }) + it('returns missing dependencies and cyclic dependencies explicitly without advancing their clocks', () => { + const snapshot = snapshotFromLibrary(fixture()) + const pending = run(snapshot, [event('a', 2, [], { b: 3 })]) + expect(pending.pending).toEqual([{ eventId: 'a:2', missing: { a: 1, b: 3 } }]) + expect(pending.clock).toEqual({}) + const cycle = run(snapshot, [event('a', 1, [], { b: 1 }), event('b', 1, [], { a: 1 })]) + expect(cycle.pending).toHaveLength(2) + expect(cycle.appliedIds).toEqual([]) + }) + it('merges independent fields in either delivery direction, including changed metadata', () => { + const base = fixture() + const left = changeNote(base, { front: 'left', updatedAt: '2026-10-18T00:00:00.000Z' }) + const right = changeNote(base, { back: 'right', updatedAt: '2026-10-19T00:00:00.000Z' }) + const a = event('a', 1, difference(base, left)), + b = event('b', 1, difference(base, right)) + const fromA = run(snapshotFromLibrary(left), [b], { a: 1 }) + const fromB = run(snapshotFromLibrary(right), [a], { b: 1 }) + expect(fromA.blocked).toBe(false) + expect(fromA.snapshot).toEqual(fromB.snapshot) + expect(libraryFromSnapshot(fromA.snapshot).notes[0]).toMatchObject({ + front: 'left', + back: 'right', + }) + }) + it('produces direction-independent conflict identity and blocks the entire apply until chosen', () => { + const base = fixture(), + left = changeNote(base, { front: 'left' }), + right = changeNote(base, { front: 'right' }) + const a = event('a', 1, difference(base, left)), + b = event('b', 1, difference(base, right)) + const snapshot = snapshotFromLibrary(left) + const input = { snapshot, events: [b], appliedClock: { a: 1 } } + const plan = planSync(input) + const reverse = run(snapshotFromLibrary(right), [a], { b: 1 }) + expect(plan.conflicts[0]!.id).toBe(reverse.conflicts[0]!.id) + expect(plan.blocked).toBe(true) + expect(plan.snapshot).toEqual(snapshot) + expect(plan.clock).toEqual({ a: 1 }) + expect(plan.appliedIds).toEqual([]) + const resolved = planSync(input, { [plan.conflicts[0]!.id]: 'remote' }) + expect(resolved.blocked).toBe(false) + expect(resolved.clock).toEqual({ a: 1, b: 1 }) + expect(libraryFromSnapshot(resolved.snapshot).notes[0]!.front).toBe('right') + expect(() => planSync(input, { obsolete: 'remote' })).toThrow('sync-stale-choice') + }) + it.each(['local', 'remote'] as const)( + 'propagates a %s choice through a causal resolution event to the other branch', + (choice) => { + const base = fixture(), + left = changeNote(base, { front: 'left' }), + right = changeNote(base, { front: 'right' }) + const a = event('a', 1, difference(base, left)), + b = event('b', 1, difference(base, right)) + const input = { snapshot: snapshotFromLibrary(left), events: [b], appliedClock: { a: 1 } } + const conflict = planSync(input).conflicts[0]! + const selected = planSync(input, { [conflict.id]: choice }) + expect(selected.resolutionChanges.some((change) => change.store === 'notes')).toBe(true) + const resolution = event('a', 2, selected.resolutionChanges, selected.clock) + const other = run(snapshotFromLibrary(right), [resolution, a], { b: 1 }) + expect(other.blocked).toBe(false) + expect(other.conflicts).toEqual([]) + expect(other.snapshot).toEqual(selected.snapshot) + expect(other.clock).toEqual({ a: 2, b: 1 }) + }, + ) + it('does not use a resolution to overwrite local edits outside its observed clock', () => { + const base = fixture(), + left = changeNote(base, { front: 'left' }), + right = changeNote(base, { front: 'right' }) + const a = event('a', 1, difference(base, left)), + b = event('b', 1, difference(base, right)) + const input = { snapshot: snapshotFromLibrary(left), events: [b], appliedClock: { a: 1 } } + const conflict = planSync(input).conflicts[0]! + const selected = planSync(input, { [conflict.id]: 'local' }) + const resolution = event('a', 2, selected.resolutionChanges, selected.clock) + const newer = changeNote(right, { front: 'unseen newer edit' }) + const other = run(snapshotFromLibrary(newer), [resolution, a], { b: 2 }) + expect(other.blocked).toBe(true) + expect(libraryFromSnapshot(other.snapshot).notes[0]!.front).toBe('unseen newer edit') + }) + it('emits a merged record relative to the last remote value and can relay it without repeating a local edit', () => { + const base = fixture(), + left = changeNote(base, { front: 'left' }), + right = changeNote(base, { back: 'right' }) + const a = event('a', 1, difference(base, left)), + b = event('b', 1, difference(base, right)) + const merged = run(snapshotFromLibrary(left), [b], { a: 1 }) + const resolution = event('a', 2, merged.resolutionChanges, merged.clock) + const other = run(snapshotFromLibrary(right), [resolution, a], { b: 1 }) + expect(other.blocked).toBe(false) + expect(other.snapshot).toEqual(merged.snapshot) + const note = merged.resolutionChanges.find((change) => change.store === 'notes')! + expect(note.before).toEqual(clean(right.notes[0])) + expect(note.after).toMatchObject({ front: 'left', back: 'right' }) + }) + it('treats plugin data and opaque values as atomic documents, not recursive JSON merges', () => { + const base = fixture({ + type: 'plugin', + plugin: { id: 'example', version: 1, data: { a: 0, b: 0 } }, + }) + const left = changeNote(base, { plugin: { id: 'example', version: 1, data: { a: 1, b: 0 } } }) + const right = changeNote(base, { plugin: { id: 'example', version: 1, data: { a: 0, b: 1 } } }) + expect( + run(snapshotFromLibrary(left), [event('b', 1, difference(base, right))], { a: 1 }) + .conflicts[0], + ).toMatchObject({ path: 'plugin', kind: 'content' }) + const snapshot = snapshotFromLibrary(fixture()) + snapshot.values['plugin:example'] = { data: { position: 0, value: { a: 1, b: 0 } } } + const result = run(snapshot, [ + event('b', 1, [ + { + store: 'values:plugin:example', + id: 'data', + before: { a: 0, b: 0 }, + after: { a: 0, b: 1 }, + }, + ]), + ]) + expect(result.blocked).toBe(true) + expect(result.conflicts[0]!.path).toBe('') + }) + it('blocks unsafe note variant unions and does not pretend a record-level choice repairs structure', () => { + const base = fixture(), + id = base.notes[0]!.id + const left = saveNote(base, { ...base.notes[0]!, type: 'reversed' }, id, false, now) + const right = saveNote(base, { ...base.notes[0]!, type: 'reversed' }, id, false, now) + const result = run(snapshotFromLibrary(left), [event('b', 1, difference(base, right))], { + a: 1, + }) + expect(result.blocked).toBe(true) + expect( + result.conflicts.some((conflict) => conflict.kind === 'structure' && !conflict.resolvable), + ).toBe(true) + expect(result.snapshot).toEqual(snapshotFromLibrary(left)) + }) + it('blocks delete/edit conflicts and dangling references atomically', () => { + const base = fixture(), + edited = changeNote(base, { front: 'preserve me' }) + const removal = event('b', 1, [ + { store: 'notes', id: base.notes[0]!.id, before: clean(base.notes[0]!), after: null }, + ]) + const input = { + snapshot: snapshotFromLibrary(edited), + events: [removal], + appliedClock: { a: 1 }, + } + const plan = planSync(input) + expect(plan.conflicts[0]!.kind).toBe('delete-edit') + const resolve = planSync(input, { [plan.conflicts[0]!.id]: 'remote' }) + expect(resolve.blocked).toBe(true) + expect(resolve.conflicts.some((conflict) => conflict.kind === 'structure')).toBe(true) + }) +}) + +describe('ratings, undo, retirement and complete history', () => { + it('preserves both independent same-time scores and selects an actual FSRS schedule explicitly', () => { + const base = fixture(), + cardId = base.cards[0]!.id + const left = reviewCard(base, cardId, 1, now), + right = reviewCard(base, cardId, 4, now) + const input = { + snapshot: snapshotFromLibrary(left), + events: [event('b', 1, difference(base, right))], + appliedClock: { a: 1 }, + } + const plan = planSync(input) + expect(plan.blocked).toBe(true) + const schedule = plan.conflicts.find((conflict) => conflict.kind === 'schedule')! + expect(schedule).toBeDefined() + expect(libraryFromSnapshot(plan.proposedSnapshot).reviews).toHaveLength(2) + const resolved = planSync(input, { [schedule.id]: 'remote' }) + expect(resolved.blocked).toBe(false) + const library = libraryFromSnapshot(resolved.snapshot) + expect(library.reviews.map((review) => review.id).sort()).toEqual( + [left.reviews[0]!.id, right.reviews[0]!.id].sort(), + ) + expect(library.cards[0]!.schedule).toEqual(right.cards[0]!.schedule) + }) + it('never coalesces two identical ratings with different review IDs', () => { + const base = fixture(), + cardId = base.cards[0]!.id + const left = reviewCard(base, cardId, 3, now), + right = reviewCard(base, cardId, 3, now) + const result = run(snapshotFromLibrary(left), [event('b', 1, difference(base, right))], { + a: 1, + }) + expect(result.blocked).toBe(false) + expect(libraryFromSnapshot(result.snapshot).reviews).toHaveLength(2) + }) + it('applies out-of-order undo and redo without replaying FSRS or resurrecting an old delivery', () => { + const base = fixture(), + scored = reviewCard(base, base.cards[0]!.id, 3, now), + undone = undoReview(scored) + const a = event('a', 1, difference(base, scored)), + undo = event('a', 2, difference(scored, undone)), + redo = event('a', 3, difference(undone, scored)) + const saved = clean([a, undo, redo]) + const result = run(snapshotFromLibrary(base), [undo, a]) + expect(result.blocked).toBe(false) + expect(libraryFromSnapshot(result.snapshot).reviews).toHaveLength(0) + expect(run(result.snapshot, [a], result.clock).snapshot).toEqual(result.snapshot) + const restored = run(result.snapshot, [redo], result.clock) + expect(libraryFromSnapshot(restored.snapshot).cards[0]!.schedule).toEqual( + scored.cards[0]!.schedule, + ) + expect([a, undo, redo]).toEqual(saved) + }) + it('keeps another device score when one device undoes its own score', () => { + const base = fixture(), + cardId = base.cards[0]!.id + const left = reviewCard(base, cardId, 1, now), + right = reviewCard(base, cardId, 4, now) + const first = run(snapshotFromLibrary(base), [event('a', 1, difference(base, left))]) + const undone = run( + first.snapshot, + [event('a', 2, difference(left, undoReview(left)))], + first.clock, + ) + const merged = run(undone.snapshot, [event('b', 1, difference(base, right))], undone.clock) + expect(merged.blocked).toBe(false) + expect(libraryFromSnapshot(merged.snapshot).reviews.map((review) => review.id)).toEqual([ + right.reviews[0]!.id, + ]) + }) + it('does not recompute schedule or historical studyDay under another timezone', () => { + const original = process.env.TZ + try { + const base = fixture(), + scored = reviewCard(base, base.cards[0]!.id, 3, now) + const input = [event('a', 1, difference(base, scored))] + process.env.TZ = 'America/New_York' + const a = run(snapshotFromLibrary(base), input) + process.env.TZ = 'Asia/Shanghai' + const b = run(snapshotFromLibrary(base), input) + expect(a.snapshot).toEqual(b.snapshot) + expect(libraryFromSnapshot(a.snapshot).cards[0]!.schedule).toEqual(scored.cards[0]!.schedule) + } finally { + if (original === undefined) delete process.env.TZ + else process.env.TZ = original + } + }) + it('routes ratings of retired cards into retained history without resurrecting the note', () => { + const base = fixture(), + card = base.cards[0]!, + note = base.notes[0]! + const snapshot = snapshotFromLibrary({ ...base, notes: [], cards: [] }) + snapshot.retired = { + notes: { [note.id]: { position: 0, value: clean(note) } }, + cards: { [card.id]: { position: 0, value: clean(card) } }, + } + const scored = reviewCard(base, card.id, 3, now) + const result = run(snapshot, [event('b', 1, difference(base, scored))]) + expect(result.blocked).toBe(false) + expect(libraryFromSnapshot(result.snapshot).notes).toHaveLength(0) + const retired = snapshotToRetired(result.snapshot) + expect(retired[0]!.reviews[0]!.id).toBe(scored.reviews[0]!.id) + expect(retired[0]!.cards[0]!.schedule).toEqual(scored.cards[0]!.schedule) + }) + it('requires document and code bodies for portable retirement and preserves full histories', () => { + const base = fixture(), + note = base.notes[0]!, + card = base.cards[0]! + const snapshot = snapshotFromLibrary({ ...base, notes: [], cards: [] }) + const hash = 'a'.repeat(64) + const practice = { + id: 'practice', + cardId: card.id, + contentRevision: 'v1', + startedAt: now.toISOString(), + question: { + front: 'Question', + back: 'Answer', + format: 'plain', + plugin: { id: 'example', version: 1, data: {} }, + }, + } + const attempt = { + id: 'attempt', + practiceId: 'practice', + submittedAt: now.toISOString(), + updatedAt: now.toISOString(), + language: 'python3', + code: 'print("完整代码")', + elapsedMs: 5, + status: 'failed', + } + snapshot.retired = { + notes: { [note.id]: { position: 0, value: clean(note) } }, + cards: { [card.id]: { position: 0, value: clean(card) } }, + documents: { + [note.id]: { position: 0, value: { id: note.id, kind: 'note', path: 'notes/a.md', hash } }, + }, + practices: { practice: { position: 0, value: practice } }, + attempts: { attempt: { position: 0, value: attempt } }, + } + expect(() => snapshotToRetired(snapshot)).toThrow('sync-retired-document-body-required') + const retired = snapshotToRetired(snapshot, { [hash]: 'original Markdown' }) + expect(retired[0]!.attempts[0]).toMatchObject({ code: attempt.code }) + expect(retired[0]!.document!.raw).toBe('original Markdown') + const compact = cloneSyncSnapshot(snapshot) + delete (compact.retired.attempts!.attempt!.value as Record).code + expect(() => snapshotToRetired(compact, { [hash]: 'original Markdown' })).toThrow( + 'sync-retired-attempt-body-required', + ) + }) + it('can apply a dependency-complete baseline made of multiple events atomically', () => { + const base = fixture(), + all = difference(emptyLibrary(), base) + const notes = all.filter((change) => change.store !== 'decks'), + decks = all.filter((change) => change.store === 'decks') + const a = event('a', 1, notes), + b = event('a', 2, decks) + expect(run(snapshotFromLibrary(emptyLibrary()), [a]).blocked).toBe(true) + const complete = run(snapshotFromLibrary(emptyLibrary()), [b, a]) + expect(complete.blocked).toBe(false) + expect(libraryFromSnapshot(complete.snapshot)).toEqual(clean(base)) + }) +}) diff --git a/src/sync/merge.ts b/src/sync/merge.ts new file mode 100644 index 0000000..82cbd0b --- /dev/null +++ b/src/sync/merge.ts @@ -0,0 +1,388 @@ +import { + canonicalSyncValue, + parseSyncClock, + parseSyncEvents, + sameSyncValue, + syncFailure, + syncFingerprint, + syncStore, + type SyncClock, + type SyncChange, + type SyncEvent, + type SyncSnapshot, +} from './protocol' +import { + cloneSyncSnapshot, + normalizeSyncPositions, + syncRow, + validateSyncSnapshot, + libraryFromSnapshot, +} from './projection' +import { validateMaterialChanges } from '../core/material-data' +import { validateAutomationReceipts } from '../core/automation-data' + +export type SyncChoice = 'local' | 'remote' +export interface SyncConflict { + id: string + eventId: string + store: string + recordId: string + path: string + kind: 'content' | 'schedule' | 'delete-edit' | 'history' | 'structure' + base: unknown + local: unknown + remote: unknown + resolvable: boolean + choice?: SyncChoice + supersededBy?: string +} +export interface SyncPending { + eventId: string + missing: SyncClock +} +export interface SyncPlan { + snapshot: SyncSnapshot + /** Preview only. Never persist this while blocked is true. */ + proposedSnapshot: SyncSnapshot + conflicts: SyncConflict[] + clock: SyncClock + appliedIds: string[] + pending: SyncPending[] + blocked: boolean + /** Local causal resolution transaction; broadcast only after a successful apply. */ + resolutionChanges: SyncChange[] +} +export interface SyncPlanInput { + snapshot: SyncSnapshot + events: unknown + appliedClock: SyncClock + groupId?: string +} + +const absent = Symbol('absent') +type Field = unknown | typeof absent +const equal = (a: Field, b: Field) => (a === absent || b === absent ? a === b : sameSyncValue(a, b)) +const exposed = (value: Field) => (value === absent ? { present: false } : { present: true, value }) +const plain = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value) +const historyStores = new Set(['reviews', 'practices', 'attempts', 'studyEvents', 'studyActivity']) +const field = (record: Record, key: string): Field => + Object.hasOwn(record, key) ? record[key] : absent +const sequenceAt = (clock: SyncClock, device: string) => + Object.hasOwn(clock, device) ? clock[device]! : 0 + +/** + * Plans an atomic application of complete causal transactions. Receiving an event is + * not applying it: unresolved conflicts and invalid structure leave both clock and + * snapshot unchanged. The caller retains every envelope, including undo tombstones. + */ +export function planSync(input: SyncPlanInput, choices: Record = {}): SyncPlan { + validateSyncSnapshot(input.snapshot) + const originalClock = parseSyncClock(input.appliedClock) + const events = parseSyncEvents(input.events, input.groupId) + const clock = { ...originalClock } + const snapshot = cloneSyncSnapshot(input.snapshot) + const remoteProjection = cloneSyncSnapshot(input.snapshot) + const conflicts: SyncConflict[] = [], + appliedIds: string[] = [] + const conflictContents = new Map() + const waiting = new Map( + events + .filter((event) => event.sequence > sequenceAt(originalClock, event.deviceId)) + .map((event) => [event.id, event]), + ) + const missing = (event: SyncEvent): SyncClock => + Object.fromEntries( + Object.entries(event.clock).filter( + ([device, sequence]) => sequenceAt(clock, device) < sequence, + ), + ) + const dominates = (left: SyncClock, right: SyncClock) => + Object.entries(right).every(([device, sequence]) => sequenceAt(left, device) >= sequence) + + function conflict( + event: SyncEvent, + store: string, + recordId: string, + path: string, + kind: SyncConflict['kind'], + base: Field, + local: Field, + remote: Field, + resolvable = true, + ): Field { + const alternatives = [exposed(local), exposed(remote)].sort((a, b) => { + const x = canonicalSyncValue(a), + y = canonicalSyncValue(b) + return x < y ? -1 : x > y ? 1 : 0 + }) + const identity = { store, recordId, path, kind, base: exposed(base), alternatives } + const content = canonicalSyncValue(identity) + const id = `sync-conflict:${syncFingerprint(identity)}` + if (conflictContents.has(id) && conflictContents.get(id) !== content) + return syncFailure('sync-conflict-identity-collision') + conflictContents.set(id, content) + const choice = Object.hasOwn(choices, id) ? choices[id] : undefined + if (choice !== undefined && choice !== 'local' && choice !== 'remote') + return syncFailure('sync-invalid-choice') + conflicts.push({ + id, + eventId: event.id, + store, + recordId, + path, + kind, + base: exposed(base), + local: exposed(local), + remote: exposed(remote), + resolvable, + ...(choice && resolvable ? { choice } : {}), + }) + return choice === 'remote' && resolvable ? remote : local + } + + function merge( + event: SyncEvent, + store: string, + id: string, + before: unknown, + current: unknown, + after: unknown, + ): unknown { + if (sameSyncValue(current, before)) return after + if (sameSyncValue(current, after) || sameSyncValue(before, after)) return current + if (current === null || after === null || before === null) + return conflict( + event, + store, + id, + '', + current === null || after === null ? 'delete-edit' : 'content', + before, + current, + after, + ) + const { section, store: actualStore } = syncStore(store) + if (section === 'values' || !plain(before) || !plain(current) || !plain(after)) + return conflict(event, store, id, '', 'content', before, current, after) + const result: Record = Object.create(null) + const keys = [ + ...new Set([...Object.keys(before), ...Object.keys(current), ...Object.keys(after)]), + ].sort() + for (const key of keys) { + const base = field(before, key), + local = field(current, key), + remote = field(after, key) + let value: Field + if (equal(local, base)) value = remote + else if (equal(local, remote) || equal(remote, base)) value = local + else if ( + key === 'updatedAt' && + actualStore === 'cards' && + !sameSyncValue(current.schedule, after.schedule) && + !sameSyncValue(before.schedule, current.schedule) && + !sameSyncValue(before.schedule, after.schedule) + ) { + // A selected real schedule keeps its original timestamp as one unit. + value = equal(result.schedule, after.schedule) ? remote : local + } else if (key === 'updatedAt' && typeof local === 'string' && typeof remote === 'string') { + // This is bookkeeping only: preserve a deterministic observed timestamp, + // never use it to select content, a schedule, or another user's changes. + value = syncFingerprint(local) < syncFingerprint(remote) ? local : remote + } else + value = conflict( + event, + store, + id, + key, + key === 'schedule' ? 'schedule' : historyStores.has(actualStore) ? 'history' : 'content', + base, + local, + remote, + ) + if (value !== absent) result[key] = value + } + return result + } + + while (waiting.size) { + const ready = [...waiting.values()] + .filter((event) => !Object.keys(missing(event)).length) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + if (!ready.length) break + // Re-evaluate after every event: causally eligible does not imply arrival order. + const event = ready[0]! + for (const change of event.changes) { + const rawTarget = syncStore(change.store) + const rawRows = { ...(remoteProjection[rawTarget.section][rawTarget.store] ?? {}) } + if (change.after === null) delete rawRows[change.id] + else + Object.defineProperty(rawRows, change.id, { + value: { + value: change.after, + position: + change.position ?? rawRows[change.id]?.position ?? Object.keys(rawRows).length, + }, + enumerable: true, + writable: true, + configurable: true, + }) + Object.defineProperty(remoteProjection[rawTarget.section], rawTarget.store, { + value: rawRows, + enumerable: true, + writable: true, + configurable: true, + }) + let target = change.store + // A rating on another device does not resurrect a retired card. Its exact + // generated schedule can still be reconciled inside the retained record. + if ( + target === 'cards' && + !syncRow(snapshot, target, change.id) && + syncRow(snapshot, 'retired:cards', change.id) && + plain(change.before) && + plain(change.after) + ) { + const scheduling = new Set(['schedule', 'updatedAt', 'leech', 'autoBurial']) + const identity = (record: Record) => + Object.fromEntries(Object.entries(record).filter(([key]) => !scheduling.has(key))) + if (sameSyncValue(identity(change.before), identity(change.after))) target = 'retired:cards' + } + const { section, store } = syncStore(target) + const prior = syncRow(snapshot, target, change.id) + const unresolved = conflicts.filter( + (entry) => entry.store === target && entry.recordId === change.id && !entry.supersededBy, + ) + const resolves = + unresolved.length > 0 && + dominates(event.clock, originalClock) && + unresolved.every((entry) => { + const cause = events.find((candidate) => candidate.id === entry.eventId)! + return sequenceAt(event.clock, cause.deviceId) >= cause.sequence + }) + if (resolves) for (const entry of unresolved) entry.supersededBy = event.id + const value = resolves + ? change.after + : merge(event, target, change.id, change.before, prior?.value ?? null, change.after) + const rows = Object.hasOwn(snapshot[section], store) ? snapshot[section][store]! : {} + const nextRows = { ...rows } + if (value === null) delete nextRows[change.id] + else + Object.defineProperty(nextRows, change.id, { + value: { + value, + position: change.position ?? prior?.position ?? Object.keys(rows).length, + }, + enumerable: true, + writable: true, + configurable: true, + }) + Object.defineProperty(snapshot[section], store, { + value: nextRows, + enumerable: true, + writable: true, + configurable: true, + }) + } + clock[event.deviceId] = event.sequence + appliedIds.push(event.id) + waiting.delete(event.id) + } + // New history for an already retired owner stays readable in retirement, + // rather than violating the active projection or silently disappearing. + for (const [store, ownerStore, key] of [ + ['reviews', 'cards', 'cardId'], + ['practices', 'cards', 'cardId'], + ['attempts', 'practices', 'practiceId'], + ] as const) { + for (const [id, row] of Object.entries(snapshot.records[store] ?? {})) { + const owner = (row.value as Record)[key] + if ( + typeof owner !== 'string' || + syncRow(snapshot, ownerStore, owner) || + !syncRow(snapshot, `retired:${ownerStore}`, owner) + ) + continue + const prior = syncRow(snapshot, `retired:${store}`, id) + if (prior && !sameSyncValue(prior.value, row.value)) continue + snapshot.retired[store] = { ...(snapshot.retired[store] ?? {}), [id]: row } + delete snapshot.records[store]![id] + } + } + normalizeSyncPositions(snapshot) + try { + validateSyncSnapshot(snapshot) + const before = libraryFromSnapshot(input.snapshot), + next = libraryFromSnapshot(snapshot) + validateMaterialChanges(before, next) + validateAutomationReceipts(before.automationReceipts, next.automationReceipts, () => + syncFailure('sync-immutable-receipt'), + ) + } catch { + const event = events.find((entry) => entry.id === appliedIds.at(-1)) + if (event) + conflict( + event, + 'library', + '', + '', + 'structure', + null, + { eventIds: appliedIds.slice().sort(), state: syncFingerprint(input.snapshot) }, + { state: syncFingerprint(snapshot) }, + false, + ) + else return syncFailure('sync-invalid-snapshot') + } + for (const id of Object.keys(choices)) + if (!conflictContents.has(id)) return syncFailure('sync-stale-choice') + const pending = [...waiting.values()] + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .map((event) => ({ eventId: event.id, missing: missing(event) })) + const activeConflicts = conflicts.filter((entry) => !entry.supersededBy) + const blocked = activeConflicts.some((entry) => !entry.resolvable || !entry.choice) + const resolutionChanges: SyncChange[] = [] + const selected = new Set( + activeConflicts + .filter((entry) => entry.choice) + .map((entry) => JSON.stringify([entry.store, entry.recordId])), + ) + if (!blocked) + for (const section of ['records', 'values', 'retired'] as const) { + for (const store of new Set([ + ...Object.keys(remoteProjection[section]), + ...Object.keys(snapshot[section]), + ])) { + const encoded = section === 'records' ? store : `${section}:${store}` + const beforeRows = remoteProjection[section][store] ?? {}, + afterRows = snapshot[section][store] ?? {} + for (const id of new Set([...Object.keys(beforeRows), ...Object.keys(afterRows)])) { + const before = Object.hasOwn(beforeRows, id) ? beforeRows[id] : undefined + const after = Object.hasOwn(afterRows, id) ? afterRows[id] : undefined + if ( + sameSyncValue(before?.value ?? null, after?.value ?? null) && + before?.position === after?.position && + !selected.has(JSON.stringify([encoded, id])) + ) + continue + resolutionChanges.push({ + store: encoded, + id, + before: before?.value ?? null, + after: after?.value ?? null, + ...(after ? { position: after.position } : {}), + }) + } + } + } + return { + snapshot: blocked ? cloneSyncSnapshot(input.snapshot) : snapshot, + proposedSnapshot: snapshot, + conflicts: activeConflicts, + clock: blocked ? originalClock : clock, + appliedIds: blocked ? [] : appliedIds, + pending, + blocked, + resolutionChanges, + } +} diff --git a/src/sync/projection.ts b/src/sync/projection.ts new file mode 100644 index 0000000..6a2a4a1 --- /dev/null +++ b/src/sync/projection.ts @@ -0,0 +1,269 @@ +import { emptyLibrary, recordSchemas, workspaceLibrarySchema, type Library } from '../core/model' +import { attemptSchema, attemptSummarySchema } from '../core/practice-data' +import { z } from 'zod' +import { retiredNoteSchema, type RetiredNote } from '../desktop/retirement' +import { referencedSource } from '../core/source-data' +import { materialLocatorMatches } from '../core/material-data' +import { + canonicalSyncValue, + retiredStores, + syncFailure, + syncStore, + type SyncSnapshot, + type SyncRow, +} from './protocol' + +export function emptySyncSnapshot(): SyncSnapshot { + return { records: {}, values: {}, retired: {} } +} + +export function snapshotFromLibrary(library: Library): SyncSnapshot { + const snapshot = emptySyncSnapshot() + for (const store of Object.keys(recordSchemas) as (keyof typeof recordSchemas)[]) { + const rows: Record = Object.create(null) + for (const [position, value] of library[store].entries()) + rows[value.id] = { position, value: JSON.parse(JSON.stringify(value)) } + snapshot.records[store] = rows + } + return snapshot +} + +export function syncRows(snapshot: SyncSnapshot, encodedStore: string): Record { + const { section, store } = syncStore(encodedStore) + return Object.hasOwn(snapshot[section], store) ? snapshot[section][store]! : {} +} + +export function syncRow(snapshot: SyncSnapshot, store: string, id: string): SyncRow | undefined { + const rows = syncRows(snapshot, store) + return Object.hasOwn(rows, id) ? rows[id] : undefined +} + +export function orderedSyncRows(rows: Record): SyncRow[] { + return Object.entries(rows) + .sort(([idA, a], [idB, b]) => a.position - b.position || (idA < idB ? -1 : idA > idB ? 1 : 0)) + .map(([, row]) => row) +} + +export function libraryFromSnapshot(snapshot: SyncSnapshot): Library { + const library: Record = { ...emptyLibrary() } + for (const store of Object.keys(recordSchemas)) + library[store] = orderedSyncRows(snapshot.records[store] ?? {}).map((row) => row.value) + return workspaceLibrarySchema.parse(library) +} + +const rowSchema = z.strictObject({ + position: z.number().int().min(0).max(1_600_000), + value: z.unknown(), +}) +const documentSchema = z.object({ + id: z.string().min(1), + kind: z.literal('note').optional(), + path: z.string().min(1), + hash: z.string().regex(/^[a-f0-9]{64}$/), + raw: z.string().optional(), +}) +const attemptRecordSchema = z.union([attemptSchema, attemptSummarySchema]) + +/** Checks the active projection and references into retained history, without reading any files. */ +export function validateSyncSnapshot(snapshot: SyncSnapshot): void { + if ( + !snapshot || + typeof snapshot !== 'object' || + Object.keys(snapshot).some((key) => !['records', 'values', 'retired'].includes(key)) + ) + return syncFailure('sync-invalid-snapshot') + canonicalSyncValue(snapshot) + for (const section of ['records', 'values', 'retired'] as const) { + const stores = snapshot[section] + if (!stores || Array.isArray(stores) || typeof stores !== 'object') + return syncFailure('sync-invalid-snapshot') + for (const [store, rows] of Object.entries(stores)) { + syncStore(section === 'records' ? store : `${section}:${store}`) + if (!rows || Array.isArray(rows) || typeof rows !== 'object') + return syncFailure('sync-invalid-snapshot') + for (const [id, raw] of Object.entries(rows)) { + if (section === 'values' && store === 'settings' && !id.startsWith('plugin:')) + return syncFailure('sync-nonportable-value') + const row = rowSchema.safeParse(raw) + if (!id || id.length > 512 || !row.success || raw.value === null || raw.value === undefined) + return syncFailure('sync-invalid-snapshot') + if (section === 'values') continue + const schema = + store === 'documents' + ? documentSchema + : store === 'attempts' + ? attemptRecordSchema + : recordSchemas[store as keyof typeof recordSchemas] + const value = schema?.safeParse(raw.value) + if (!value?.success || (value.data as { id: string }).id !== id) + return syncFailure('sync-invalid-record') + } + } + } + try { + libraryFromSnapshot(snapshot) + } catch { + return syncFailure('sync-invalid-references') + } + const exists = (store: string, id: unknown) => + typeof id === 'string' && + Boolean(syncRow(snapshot, store, id) || syncRow(snapshot, `retired:${store}`, id)) + const retiredExists = (store: string, id: unknown) => + typeof id === 'string' && Boolean(syncRow(snapshot, `retired:${store}`, id)) + const variants = new Set( + Object.values(snapshot.records.cards ?? {}).map(({ value }) => { + const card = value as Library['cards'][number] + return JSON.stringify([card.noteId, card.variant]) + }), + ) + for (const store of retiredStores) { + for (const { value } of Object.values(snapshot.retired[store] ?? {})) { + const row = value as Record + if ( + (store === 'cards' && !exists('notes', row.noteId)) || + (['reviews', 'practices'].includes(store) && !retiredExists('cards', row.cardId)) || + (store === 'attempts' && !retiredExists('practices', row.practiceId)) || + (store === 'documents' && !exists('notes', row.id)) + ) + return syncFailure('sync-invalid-retired-references') + if ( + ['cards', 'reviews', 'practices', 'attempts'].includes(store) && + syncRow(snapshot, store, String(row.id)) + ) + return syncFailure('sync-duplicate-retired-record') + if (store === 'cards') { + const variant = JSON.stringify([row.noteId, row.variant]) + if (variants.has(variant)) return syncFailure('sync-duplicate-retired-variant') + variants.add(variant) + } + if (store === 'reviews' && row.practiceId) { + const practice = syncRow(snapshot, 'retired:practices', String(row.practiceId))?.value as + Library['practices'][number] | undefined + if (!practice || practice.cardId !== row.cardId) + return syncFailure('sync-invalid-retired-references') + } + if (store === 'practices' && row.reviewId) { + const review = syncRow(snapshot, 'retired:reviews', String(row.reviewId))?.value as + Library['reviews'][number] | undefined + if ( + !review || + review.practiceId !== row.id || + review.cardId !== row.cardId || + review.rating !== row.rating + ) + return syncFailure('sync-invalid-retired-references') + } + if (store === 'notes') { + const note = value as Library['notes'][number] + for (const mediaId of [ + ...note.frontMedia, + ...note.backMedia, + ...(note.inlineMedia ?? []).map((ref) => ref.mediaId), + ]) + if (!exists('media', mediaId)) return syncFailure('sync-invalid-retired-references') + if (note.excerpt) { + const source = syncRow(snapshot, 'sources', note.excerpt.sourceId)?.value as + Library['sources'][number] | undefined + const version = source && referencedSource(source, note.excerpt) + if ( + !version || + note.excerpt.end > version.characters || + (note.excerpt.locator && !materialLocatorMatches(version, note.excerpt.locator)) || + Object.values(note.plugin?.variantLocators ?? {}).some( + (locator) => !materialLocatorMatches(version, locator), + ) + ) + return syncFailure('sync-invalid-retired-references') + } + } + } + } +} + +/** Positions are a local dense projection; simultaneous appends use stable record IDs. */ +export function normalizeSyncPositions(snapshot: SyncSnapshot): void { + // Native values and retirement tables have no dense ordering contract. + // Renumbering their zero positions would emit another event after every reload. + const stores = snapshot.records + for (const [store, rows] of Object.entries(stores)) { + const ordered = Object.entries(rows).sort( + ([idA, a], [idB, b]) => a.position - b.position || (idA < idB ? -1 : idA > idB ? 1 : 0), + ) + stores[store] = Object.fromEntries( + ordered.map(([id, row], position) => [id, { ...row, position }]), + ) + } +} + +export function cloneSyncSnapshot(snapshot: SyncSnapshot): SyncSnapshot { + return JSON.parse(canonicalSyncValue(snapshot)) as SyncSnapshot +} + +/** Portable retirement groups keep full code; document objects are fetched explicitly. */ +export function snapshotToRetired( + snapshot: SyncSnapshot, + documentBodies: Record = {}, +): RetiredNote[] { + validateSyncSnapshot(snapshot) + const rows = (store: string) => + orderedSyncRows(snapshot.retired[store] ?? {}).map( + ({ value }) => value as Record, + ) + const cards = rows('cards'), + reviews = rows('reviews'), + practices = rows('practices'), + attempts = rows('attempts') + const noteIds = new Set([ + ...rows('notes').map((note) => String(note.id)), + ...cards.map((card) => String(card.noteId)), + ]) + const assignedMedia = new Set() + const result = [...noteIds].sort().map((id) => { + const note = + syncRow(snapshot, 'retired:notes', id)?.value ?? syncRow(snapshot, 'notes', id)?.value ?? null + const owned = cards.filter((card) => card.noteId === id) + const cardIds = new Set(owned.map((card) => card.id)) + const ownedPractices = practices.filter((practice) => cardIds.has(practice.cardId)) + const practiceIds = new Set(ownedPractices.map((practice) => practice.id)) + const ownedAttempts = attempts.filter((attempt) => practiceIds.has(attempt.practiceId)) + // A compact active index is not a portable history body. + if (ownedAttempts.some((attempt) => !attemptSchema.safeParse(attempt).success)) + return syncFailure('sync-retired-attempt-body-required') + const document = syncRow(snapshot, 'retired:documents', id)?.value as + { id: string; path: string; hash: string; raw?: string } | undefined + const raw = document && (document.raw ?? documentBodies[document.hash]) + if (document && raw === undefined) return syncFailure('sync-retired-document-body-required') + const mediaIds = new Set() + if (note) { + const value = note as Library['notes'][number] + for (const mediaId of [...value.frontMedia, ...value.backMedia]) mediaIds.add(mediaId) + for (const reference of value.inlineMedia ?? []) mediaIds.add(reference.mediaId) + } + const media = [...mediaIds] + .sort() + .map( + (mediaId) => + syncRow(snapshot, 'retired:media', mediaId)?.value ?? + syncRow(snapshot, 'media', mediaId)?.value ?? + syncFailure('sync-invalid-retired-references'), + ) + for (const mediaId of mediaIds) assignedMedia.add(mediaId) + return retiredNoteSchema.parse({ + revision: 0, + note, + document: document ? { ...document, kind: 'note', raw } : null, + cards: owned, + reviews: reviews.filter((review) => cardIds.has(review.cardId)), + practices: ownedPractices, + attempts: ownedAttempts, + media, + }) + }) + if (result.length) + for (const media of rows('media')) + if (!assignedMedia.has(String(media.id))) + result[0]!.media.push(media as Library['media'][number]) + if (rows('documents').some((document) => !noteIds.has(String(document.id)))) + return syncFailure('sync-unassigned-retired-document') + return result +} diff --git a/src/sync/protocol.ts b/src/sync/protocol.ts new file mode 100644 index 0000000..491e22d --- /dev/null +++ b/src/sync/protocol.ts @@ -0,0 +1,255 @@ +import { z } from 'zod' +import { recordSchemas } from '../core/model' + +export type SyncClock = Record +export interface SyncChange { + store: string + id: string + before: unknown | null + after: unknown | null + position?: number +} +export interface SyncEvent { + protocol: 1 + groupId: string + id: string + deviceId: string + sequence: number + /** The applied frontier immediately before this transaction; never a received frontier. */ + clock: SyncClock + changes: SyncChange[] +} +export interface SyncRow { + value: unknown + position: number +} +export type SyncRows = Record> +export interface SyncSnapshot { + records: SyncRows + values: SyncRows + retired: SyncRows +} +export const SYNC_LIMITS = Object.freeze({ + events: 2048, + devices: 128, + changes: 100_000, + bytes: 64 * 1024 * 1024, + depth: 64, +}) +export class SyncProtocolError extends Error { + constructor(public readonly code: string) { + super(code) + this.name = 'SyncProtocolError' + } +} +export const syncFailure = (code: string): never => { + throw new SyncProtocolError(code) +} +const device = z + .string() + .regex(/^[A-Za-z0-9_-]{1,100}$/) + .refine((value) => !['__proto__', 'constructor', 'prototype'].includes(value)) +const identifier = z.string().min(1).max(512) +const counter = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) +const changeSchema = z.strictObject({ + store: z.string().min(1).max(150), + id: identifier, + before: z.unknown(), + after: z.unknown(), + position: z.number().int().min(0).max(1_600_000).optional(), +}) +const eventSchema = z.strictObject({ + protocol: z.literal(1), + groupId: identifier, + id: identifier, + deviceId: device, + sequence: counter.min(1), + clock: z.record(device, counter), + changes: z.array(changeSchema).max(SYNC_LIMITS.changes), +}) +export const retiredStores = new Set([ + 'notes', + 'cards', + 'reviews', + 'practices', + 'attempts', + 'media', + 'documents', +]) + +export function syncStore(store: string): { section: keyof SyncSnapshot; store: string } { + if (Object.hasOwn(recordSchemas, store)) return { section: 'records', store } + if (store.startsWith('values:')) { + const namespace = store.slice(7) + // Device credentials and legacy authentication namespaces are never portable. + if (namespace === 'settings' || /^plugin:[a-z][a-z0-9-]{0,63}$/.test(namespace)) + return { section: 'values', store: namespace } + } + if (store.startsWith('retired:') && retiredStores.has(store.slice(8))) + return { section: 'retired', store: store.slice(8) } + return syncFailure('sync-unknown-store') +} + +/** JSON-only canonical form; identities do not depend on property insertion order or locale. */ +export function canonicalSyncValue(value: unknown): string { + const ancestors = new Set() + function encode(item: unknown, depth: number): string { + if (depth > SYNC_LIMITS.depth) return syncFailure('sync-depth-limit') + if (item === null) return 'null' + if (typeof item === 'string' || typeof item === 'boolean') return JSON.stringify(item) + if (typeof item === 'number' && Number.isFinite(item)) return JSON.stringify(item) + if (typeof item !== 'object') return syncFailure('sync-invalid-json') + if (ancestors.has(item)) return syncFailure('sync-invalid-json') + ancestors.add(item) + let result: string + if (Array.isArray(item)) + result = '[' + item.map((entry) => encode(entry, depth + 1)).join(',') + ']' + else { + const prototype = Object.getPrototypeOf(item) + if (prototype !== Object.prototype && prototype !== null) + return syncFailure('sync-invalid-json') + const record = item as Record + result = + '{' + + Object.keys(record) + .sort() + .map((key) => JSON.stringify(key) + ':' + encode(record[key], depth + 1)) + .join(',') + + '}' + } + ancestors.delete(item) + return result + } + return encode(value, 0) +} +export const sameSyncValue = (left: unknown, right: unknown) => + canonicalSyncValue(left) === canonicalSyncValue(right) + +export function parseSyncClock(input: unknown): SyncClock { + const result = z.record(device, counter).safeParse(input) + if (!result.success || Object.keys(result.data).length > SYNC_LIMITS.devices) + return syncFailure('sync-invalid-clock') + return result.data +} + +export function parseSyncEvents(input: unknown, groupId?: string): SyncEvent[] { + if (!Array.isArray(input) || input.length > SYNC_LIMITS.events) + return syncFailure('sync-batch-limit') + const events = new Map() + let bytes = 0, + changes = 0, + group = groupId + for (const raw of input) { + const parsed = eventSchema.safeParse(raw) + if (!parsed.success) return syncFailure('sync-invalid-event') + const event = parsed.data as SyncEvent + const clock = parseSyncClock(event.clock) + if ( + event.id !== `${event.deviceId}:${event.sequence}` || + !Object.hasOwn(clock, event.deviceId) || + (clock[event.deviceId] ?? 0) !== event.sequence - 1 + ) + return syncFailure('sync-invalid-event-clock') + if (group === undefined) group = event.groupId + if (event.groupId !== group) return syncFailure('sync-wrong-group') + const keys = new Set() + for (const change of event.changes) { + syncStore(change.store) + if (change.store === 'values:settings' && !change.id.startsWith('plugin:')) + return syncFailure('sync-nonportable-value') + if ( + !Object.hasOwn(change, 'before') || + !Object.hasOwn(change, 'after') || + change.before === undefined || + change.after === undefined || + (change.before === null && change.after === null) + ) + return syncFailure('sync-invalid-change') + if (change.after === null && change.position !== undefined) + return syncFailure('sync-invalid-change') + const key = JSON.stringify([change.store, change.id]) + if (keys.has(key)) return syncFailure('sync-duplicate-change') + keys.add(key) + } + const content = canonicalSyncValue(event) + bytes += new TextEncoder().encode(content).byteLength + changes += event.changes.length + if (bytes > SYNC_LIMITS.bytes || changes > SYNC_LIMITS.changes) + return syncFailure('sync-batch-limit') + const prior = events.get(event.id) + if (prior && prior.content !== content) return syncFailure('sync-event-identity-conflict') + events.set(event.id, { event: JSON.parse(content) as SyncEvent, content }) + } + return [...events.values()].map(({ event }) => event) +} + +/** SHA-256 is used only for stable conflict identities, never as transport authentication. */ +export function syncFingerprint(value: unknown): string { + const bytes = new TextEncoder().encode(canonicalSyncValue(value)) + const length = Math.ceil((bytes.length + 9) / 64) * 64 + const data = new Uint8Array(length) + data.set(bytes) + data[bytes.length] = 128 + const view = new DataView(data.buffer) + view.setUint32(length - 8, Math.floor(bytes.length / 0x20000000)) + view.setUint32(length - 4, bytes.length * 8) + const constants = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ] + const state = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ] + const rotate = (n: number, bits: number) => (n >>> bits) | (n << (32 - bits)) + for (let offset = 0; offset < length; offset += 64) { + const words = new Uint32Array(64) + for (let index = 0; index < 16; index++) words[index] = view.getUint32(offset + index * 4) + for (let index = 16; index < 64; index++) { + const x = words[index - 15]!, + y = words[index - 2]! + words[index] = + words[index - 16]! + + (rotate(x, 7) ^ rotate(x, 18) ^ (x >>> 3)) + + words[index - 7]! + + (rotate(y, 17) ^ rotate(y, 19) ^ (y >>> 10)) + } + let [a, b, c, d, e, f, g, h] = state as [ + number, + number, + number, + number, + number, + number, + number, + number, + ] + for (let index = 0; index < 64; index++) { + const t1 = + (h + + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + + ((e & f) ^ (~e & g)) + + constants[index]! + + words[index]!) | + 0 + const t2 = + ((rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0 + h = g + g = f + f = e + e = (d + t1) | 0 + d = c + c = b + b = a + a = (t1 + t2) | 0 + } + for (const [index, value] of [a, b, c, d, e, f, g, h].entries()) + state[index] = (state[index]! + value) >>> 0 + } + return state.map((word) => word.toString(16).padStart(8, '0')).join('') +} From 2159b689853a29a897f0ab32906d2a0e8118b6b4 Mon Sep 17 00:00:00 2001 From: CharlesHsu Date: Fri, 30 Oct 2026 22:11:22 +0800 Subject: [PATCH 2/5] feat(sync): persist scoped LAN pairing, journals and resumable objects --- crates/workspace-store/src/backup.rs | 2 +- crates/workspace-store/src/lib.rs | 2 + crates/workspace-store/src/library.rs | 28 +- crates/workspace-store/src/materials.rs | 2 +- crates/workspace-store/src/p6_tests.rs | 2 +- crates/workspace-store/src/records.rs | 6 +- crates/workspace-store/src/sessions.rs | 266 ++++- crates/workspace-store/src/sync.rs | 837 +++++++++++++++ crates/workspace-store/src/sync_objects.rs | 764 ++++++++++++++ .../workspace-store/src/sync_objects/tests.rs | 314 ++++++ src-tauri/Cargo.lock | 158 ++- src-tauri/Cargo.toml | 4 + src-tauri/build.rs | 1 + src-tauri/capabilities/main.json | 1 + src-tauri/src/device_sync.rs | 991 ++++++++++++++++++ src-tauri/src/device_sync/identity.rs | 194 ++++ src-tauri/src/device_sync/tests.rs | 548 ++++++++++ src-tauri/src/device_sync/transport.rs | 147 +++ .../src/device_sync/windows_permissions.rs | 91 ++ src-tauri/src/main.rs | 12 + src-tauri/src/sync_host.rs | 171 +++ src-tauri/src/workspaces.rs | 68 +- src-tauri/tests/device_sync.rs | 4 + 23 files changed, 4601 insertions(+), 12 deletions(-) create mode 100644 crates/workspace-store/src/sync.rs create mode 100644 crates/workspace-store/src/sync_objects.rs create mode 100644 crates/workspace-store/src/sync_objects/tests.rs create mode 100644 src-tauri/src/device_sync.rs create mode 100644 src-tauri/src/device_sync/identity.rs create mode 100644 src-tauri/src/device_sync/tests.rs create mode 100644 src-tauri/src/device_sync/transport.rs create mode 100644 src-tauri/src/device_sync/windows_permissions.rs create mode 100644 src-tauri/src/sync_host.rs create mode 100644 src-tauri/tests/device_sync.rs diff --git a/crates/workspace-store/src/backup.rs b/crates/workspace-store/src/backup.rs index f71fc54..aef4241 100644 --- a/crates/workspace-store/src/backup.rs +++ b/crates/workspace-store/src/backup.rs @@ -657,7 +657,7 @@ fn validate_database_at(root: &Path, database_path: &Path, manifest: &Manifest) db.pragma_update(None, "trusted_schema", "OFF")?; let app: i64 = db.pragma_query_value(None, "application_id", |r| r.get(0))?; let version: u32 = db.pragma_query_value(None, "user_version", |r| r.get(0))?; - if app != 0x52505459 || ![10, 11, 12, 13, 14, 15, 16, 17].contains(&version) { + if app != 0x52505459 || ![10, 11, 12, 13, 14, 15, 16, 17, 18].contains(&version) { return Err(Error("backup-incompatible")); } let identity: String = db.query_row("SELECT id FROM workspace_identity", [], |r| r.get(0))?; diff --git a/crates/workspace-store/src/lib.rs b/crates/workspace-store/src/lib.rs index 1d0744c..6d6d124 100644 --- a/crates/workspace-store/src/lib.rs +++ b/crates/workspace-store/src/lib.rs @@ -15,6 +15,7 @@ pub mod library; pub mod recovery_catalog; pub mod sessions; +pub mod sync; pub mod watch; mod retirement; @@ -32,3 +33,4 @@ mod p6_tests; #[cfg(test)] mod p7_tests; mod study; +mod sync_objects; diff --git a/crates/workspace-store/src/library.rs b/crates/workspace-store/src/library.rs index 28f6b3a..b61d874 100644 --- a/crates/workspace-store/src/library.rs +++ b/crates/workspace-store/src/library.rs @@ -53,6 +53,8 @@ pub struct Save { pub operation_history: Option, #[serde(default)] pub retired: Option>, + #[serde(default)] + pub sync: Option, pub consume_card_draft: Option, #[serde(default)] pub consume_source_draft: Option, @@ -194,7 +196,7 @@ impl Library { .query_row("SELECT id FROM workspace_identity", [], |r| r.get(0)) .map_err(|_| fail("workspace-incompatible"))?; if app != APP_ID - || ![10, 11, 12, 13, 14, 15, 16, 17].contains(&version) + || ![10, 11, 12, 13, 14, 15, 16, 17, 18].contains(&version) || identity != marker.id { return Err(fail("workspace-incompatible")); @@ -203,6 +205,7 @@ impl Library { let anchor = Arc::new(Dir::open_ambient_dir(&root, cap_std::ambient_authority())?); let identity = same_file::Handle::from_file(anchor.try_clone()?.into_std_file())?; let db = Database::open(&db_path, Arc::new(anchor.open_dir(".repeaty/media")?))?; + crate::sync::schema(&db.connection)?; let members = [ ".repeaty", ".repeaty/state.sqlite3", @@ -456,6 +459,16 @@ impl Library { return Err(fail("working-file-changed")); } working_media::verify(&self.anchor, &request.asset_observations)?; + if let Some(sync) = &request.sync { + let mut ids = request + .patches + .iter() + .filter(|patch| patch.store == "media" && patch.value.is_some()) + .map(|patch| patch.id.clone()) + .collect::>(); + ids.extend(sync.retired_media.keys().cloned()); + self.publish_sync_assets(&ids)?; + } if let Some(entries) = &request.retired { for entry in entries { if let Some(raw) = entry["document"]["raw"].as_str() @@ -593,9 +606,12 @@ impl Library { let anchor = &self.anchor; let observations = &request.asset_observations; let reactivate = &metadata; + let sync = request.sync; + let sync_device = self.db.sync_device.clone(); let revision=self.db.commit_callbacks(Commit {expected_revision:request.expected_revision,patches,reason:request.reason,operation_history:request.operation_history,retired:request.retired},|tx| { working_media::verify(anchor, observations)?; retirement::capture(tx,capture) },|tx| { working_media::verify(anchor, observations)?; retirement::reactivate(tx,reactivate)?; + if let Some(sync) = &sync { crate::sync::apply(tx,sync,sync_device.as_deref())?; } if let Some(id)=consume && tx.execute("DELETE FROM card_drafts WHERE id=?1",[id])?!=1 { return Err(Error::conflict()); } if let Some(id)=consume_source && tx.execute("DELETE FROM source_drafts WHERE json_extract(data,'$.id')=?1",[id])?!=1 { return Err(Error::conflict()); } for (doc,hash) in &documents { @@ -1157,12 +1173,18 @@ impl Library { return Err(Error::invalid()); } if request["value"].is_null() { - self.db.connection.execute( + let tx = self.db.connection.transaction()?; + tx.execute( "DELETE FROM values_store WHERE namespace=?1 AND id=?2", params![ns, id], )?; + crate::sync::flush(&tx, self.db.sync_device.as_deref())?; + tx.commit()?; } else { - self.db.connection.execute("INSERT INTO values_store VALUES(?1,?2,?3) ON CONFLICT(namespace,id) DO UPDATE SET data=excluded.data",params![ns,id,request["value"].to_string()])?; + let tx = self.db.connection.transaction()?; + tx.execute("INSERT INTO values_store VALUES(?1,?2,?3) ON CONFLICT(namespace,id) DO UPDATE SET data=excluded.data",params![ns,id,request["value"].to_string()])?; + crate::sync::flush(&tx, self.db.sync_device.as_deref())?; + tx.commit()?; } Ok(Value::Null) } diff --git a/crates/workspace-store/src/materials.rs b/crates/workspace-store/src/materials.rs index 22d7a21..8099dcb 100644 --- a/crates/workspace-store/src/materials.rs +++ b/crates/workspace-store/src/materials.rs @@ -424,7 +424,7 @@ mod tests { ); assert_eq!(db.load().unwrap()["revision"], 2); db.connection - .pragma_update(None, "user_version", 18) + .pragma_update(None, "user_version", 19) .unwrap(); drop(db); assert!(Database::open(&path, assets).is_err()); diff --git a/crates/workspace-store/src/p6_tests.rs b/crates/workspace-store/src/p6_tests.rs index dc9ac7f..fe60170 100644 --- a/crates/workspace-store/src/p6_tests.rs +++ b/crates/workspace-store/src/p6_tests.rs @@ -281,7 +281,7 @@ fn future_reader_gate_is_rejected_by_library_open_and_complete_backup() { library .db .connection - .pragma_update(None, "user_version", 18) + .pragma_update(None, "user_version", 19) .unwrap(); let archive = directory.path().join("future.repeaty-workspace"); assert!(backup::create(&library, &archive, &AtomicBool::new(false)).is_err()); diff --git a/crates/workspace-store/src/records.rs b/crates/workspace-store/src/records.rs index 013cfe7..cf639ae 100644 --- a/crates/workspace-store/src/records.rs +++ b/crates/workspace-store/src/records.rs @@ -79,6 +79,7 @@ pub enum ReadAttempts { } pub struct Database { pub(crate) connection: Connection, + pub(crate) sync_device: Option, assets: Arc, validators: HashMap, } @@ -103,7 +104,7 @@ impl Database { connection.pragma_update(None, "foreign_keys", "ON")?; connection.pragma_update(None, "journal_mode", "WAL")?; let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; - if version > 17 { + if version > 18 { return Err(Error::invalid()); } let tx = connection.transaction()?; @@ -170,8 +171,10 @@ impl Database { .collect::>()?; crate::attachments::schema(&connection)?; crate::retirement::history_schema(&connection)?; + crate::sync::schema(&connection)?; Ok(Self { connection, + sync_device: None, validators, assets, }) @@ -606,6 +609,7 @@ impl Database { } tx.execute("UPDATE workspace SET revision=?1 WHERE id=1", [current + 1])?; Self::collect_assets(&tx)?; + crate::sync::flush(&tx, self.sync_device.as_deref())?; tx.commit()?; Ok(current + 1) } diff --git a/crates/workspace-store/src/sessions.rs b/crates/workspace-store/src/sessions.rs index 0578f46..0bcb189 100644 --- a/crates/workspace-store/src/sessions.rs +++ b/crates/workspace-store/src/sessions.rs @@ -27,8 +27,96 @@ pub struct Sessions { epoch: String, selected: Option<(String, String)>, restore_error: Option, + sync_device: Option, } impl Sessions { + pub fn sync_bind_device(&mut self, device: &str) -> Result<()> { + self.sync_bind_for_group(device, None) + } + fn sync_bind_for_group(&mut self, device: &str, joining: Option<&str>) -> Result<()> { + self.sync_device = Some(device.to_owned()); + if let Some(active) = self.active.as_mut() { + active.library.sync_bind_device(device)?; + let base = active + .library + .db + .sync_device + .clone() + .ok_or(Error("sync-device-required"))?; + let state: Option<(String, String)> = active + .library + .db + .connection + .query_row( + "SELECT group_id,clock FROM sync_state WHERE id=1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + let state = state.or_else(|| joining.map(|group| (group.to_owned(), "{}".into()))); + let Some((group, raw_clock)) = state else { + return Ok(()); + }; + let clock: crate::sync::Clock = serde_json::from_str(&raw_clock)?; + let tx = self.index.transaction()?; + let previous:Option<(String,i64)>=tx.query_row("SELECT writer,highwater FROM sync_writer_frontiers WHERE base=?1 AND group_id=?2",params![base,group],|r|Ok((r.get(0)?,r.get(1)?))).optional()?; + let mut writer = previous + .as_ref() + .map(|(writer, _)| writer.clone()) + .unwrap_or_else(|| base.clone()); + let applied = clock.get(&writer).copied().unwrap_or(0); + if previous + .as_ref() + .is_some_and(|(_, water)| applied < *water as u64) + { + // An older library was copied over this path. Retain that branch, but + // never reuse a transaction identity already exposed by this device. + writer = crate::files::digest(format!("{base}\n{}", Uuid::new_v4()).as_bytes()); + } + let stored: i64 = active.library.db.connection.query_row( + "SELECT COALESCE(MAX(sequence),0) FROM sync_events WHERE device=?1", + [&writer], + |r| r.get(0), + )?; + let highwater = stored.max( + i64::try_from(clock.get(&writer).copied().unwrap_or(0)) + .map_err(|_| Error::invalid())?, + ); + if previous.as_ref() != Some(&(writer.clone(), highwater)) { + tx.execute("INSERT INTO sync_writer_frontiers(base,group_id,writer,highwater) VALUES(?1,?2,?3,?4) ON CONFLICT(base,group_id) DO UPDATE SET writer=excluded.writer,highwater=excluded.highwater",params![base,group,writer,highwater])?; + } + tx.commit()?; + active.library.db.sync_device = Some(writer); + } + Ok(()) + } + pub fn sync_request(&mut self, token: &str, method: &str, payload: Value) -> Result { + self.active + .as_ref() + .ok_or(Error("workspace-not-open"))? + .library + .guard(token)?; + // Persist the high-water mark before a journal batch can leave the device. + // This closes the crash window between a library commit and index bookkeeping. + if let Some(device) = self.sync_device.clone() { + if method == "start" { + let group = payload["groupId"] + .as_str() + .filter(|group| Uuid::parse_str(group).is_ok()) + .ok_or_else(Error::invalid)?; + self.sync_bind_for_group(&device, Some(group))?; + } else { + self.sync_bind_device(&device)?; + } + } + let active = self.active.as_mut().ok_or(Error("workspace-not-open"))?; + active.library.guard(token)?; + let result = active.library.sync_request(method, payload); + if let Some(device) = self.sync_device.clone() { + self.sync_bind_device(&device)?; + } + result + } /// Identity only: native peripheral services must not scan library content to bind a session. pub fn active_identity(&self) -> Option<(&str, &str)> { self.active @@ -40,7 +128,8 @@ impl Sessions { let index = Connection::open(directory.join("workspaces.sqlite3"))?; index.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS recent(path TEXT PRIMARY KEY,id TEXT NOT NULL,used INTEGER NOT NULL); - CREATE TABLE IF NOT EXISTS selected(slot INTEGER PRIMARY KEY CHECK(slot=1),path TEXT NOT NULL,id TEXT NOT NULL);")?; + CREATE TABLE IF NOT EXISTS selected(slot INTEGER PRIMARY KEY CHECK(slot=1),path TEXT NOT NULL,id TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS sync_writer_frontiers(base TEXT NOT NULL,group_id TEXT NOT NULL,writer TEXT NOT NULL,highwater INTEGER NOT NULL CHECK(highwater>=0),PRIMARY KEY(base,group_id));")?; let selected = index .query_row("SELECT path,id FROM selected WHERE slot=1", [], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) @@ -53,6 +142,7 @@ impl Sessions { epoch: Uuid::new_v4().to_string(), selected, restore_error: None, + sync_device: None, }; if let Some((path, id)) = sessions.selected.clone() { match sessions.open_library(Path::new(&path), Some(&id), false) { @@ -340,3 +430,177 @@ impl Sessions { ) } } + +#[cfg(test)] +mod sync_writer_tests { + use super::*; + fn sessions() -> ( + tempfile::TempDir, + tempfile::TempDir, + Sessions, + String, + String, + ) { + let device = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let mut sessions = Sessions::open(device.path()).unwrap(); + let epoch = sessions.status().unwrap()["epoch"] + .as_str() + .unwrap() + .to_owned(); + sessions.choose(&epoch, workspace.path(), true).unwrap(); + sessions.sync_bind_device("test-device").unwrap(); + let token = sessions.active_identity().unwrap().1.to_owned(); + let group = Uuid::new_v4().to_string(); + sessions + .sync_request(&token, "start", json!({"groupId":group})) + .unwrap(); + (device, workspace, sessions, token, group) + } + fn write(sessions: &mut Sessions, token: &str, value: &str) { + sessions + .write( + token, + json!({"type":"value","namespace":"plugin:sample","id":"data","value":value}), + ) + .unwrap(); + } + fn list(sessions: &mut Sessions, token: &str) -> Value { + sessions + .sync_request(token, "journal/list", json!({"clock":{},"limit":128})) + .unwrap() + } + #[test] + fn old_database_replaced_at_same_path_rotates_writer_before_next_local_event() { + let (device, workspace, mut sessions, token, group) = sessions(); + write(&mut sessions, &token, "first"); + let first = list(&mut sessions, &token); + let old_writer = first[0]["deviceId"].as_str().unwrap().to_owned(); + let snapshot = device.path().join("older.sqlite3"); + sessions + .active + .as_ref() + .unwrap() + .library + .db + .connection + .execute("VACUUM INTO ?1", [snapshot.to_str().unwrap()]) + .unwrap(); + write(&mut sessions, &token, "second"); + // No explicit bind after the write: journal/list itself must persist exposure. + let exposed = list(&mut sessions, &token); + assert_eq!(exposed[1]["sequence"], 2); + drop(sessions); + let database = workspace.path().join(".repeaty/state.sqlite3"); + assert!(!workspace.path().join(".repeaty/state.sqlite3-wal").exists()); + // Only fixture-owned files are replaced, with no live SQLite connection. + fs::copy(&snapshot, &database).unwrap(); + let mut reopened = Sessions::open(device.path()).unwrap(); + reopened.sync_bind_device("test-device").unwrap(); + let token = reopened.active_identity().unwrap().1.to_owned(); + write(&mut reopened, &token, "restored branch"); + let branch = list(&mut reopened, &token); + let event = branch + .as_array() + .unwrap() + .iter() + .find(|event| event["deviceId"] != old_writer) + .unwrap(); + assert_eq!(event["sequence"], 1); + assert_eq!(event["clock"][&old_writer], 1); + let writer = event["deviceId"].as_str().unwrap().to_owned(); + drop(reopened); + let mut reopened = Sessions::open(device.path()).unwrap(); + reopened.sync_bind_device("test-device").unwrap(); + assert_eq!( + reopened + .active + .as_ref() + .unwrap() + .library + .db + .sync_device + .as_ref(), + Some(&writer) + ); + let remote_dir = tempfile::tempdir().unwrap(); + let mut remote = Library::create(remote_dir.path()).unwrap(); + remote.sync_bind_device("other-device").unwrap(); + remote + .sync_request("start", json!({"groupId":group})) + .unwrap(); + remote + .sync_request("journal/receive", json!({"events":exposed})) + .unwrap(); + remote + .sync_request("journal/receive", json!({"events":branch})) + .unwrap(); + assert_eq!( + remote + .sync_request("journal/list", json!({"clock":{},"limit":128})) + .unwrap() + .as_array() + .unwrap() + .len(), + 3 + ); + } + #[test] + fn highwater_persistence_failure_blocks_journal_exposure() { + let (_device, _workspace, mut sessions, token, _group) = sessions(); + write(&mut sessions, &token, "first"); + list(&mut sessions, &token); + sessions.index.execute_batch("CREATE TRIGGER fail_frontier BEFORE UPDATE ON sync_writer_frontiers BEGIN SELECT RAISE(ABORT,'injected failure'); END;").unwrap(); + write(&mut sessions, &token, "second"); + assert!( + sessions + .sync_request(&token, "journal/list", json!({"clock":{},"limit":128})) + .is_err() + ); + sessions + .index + .execute_batch("DROP TRIGGER fail_frontier") + .unwrap(); + assert_eq!(list(&mut sessions, &token).as_array().unwrap().len(), 2); + } + #[test] + fn restoring_a_pre_group_library_chooses_a_fresh_writer_before_new_baseline() { + let device = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let mut sessions = Sessions::open(device.path()).unwrap(); + let epoch = sessions.status().unwrap()["epoch"] + .as_str() + .unwrap() + .to_owned(); + sessions.choose(&epoch, workspace.path(), true).unwrap(); + sessions.sync_bind_device("device").unwrap(); + let token = sessions.active_identity().unwrap().1.to_owned(); + write(&mut sessions, &token, "before group"); + let snapshot = device.path().join("pre-group.sqlite3"); + sessions + .active + .as_ref() + .unwrap() + .library + .db + .connection + .execute("VACUUM INTO ?1", [snapshot.to_str().unwrap()]) + .unwrap(); + let group = Uuid::new_v4().to_string(); + sessions + .sync_request(&token, "start", json!({"groupId":group})) + .unwrap(); + let first = list(&mut sessions, &token); + drop(sessions); + fs::copy(snapshot, workspace.path().join(".repeaty/state.sqlite3")).unwrap(); + let mut sessions = Sessions::open(device.path()).unwrap(); + sessions.sync_bind_device("device").unwrap(); + let token = sessions.active_identity().unwrap().1.to_owned(); + sessions + .sync_request(&token, "start", json!({"groupId":group})) + .unwrap(); + let fresh = list(&mut sessions, &token); + assert_ne!(first[0]["deviceId"], fresh[0]["deviceId"]); + assert_eq!(fresh[0]["sequence"], 1); + } +} diff --git a/crates/workspace-store/src/sync.rs b/crates/workspace-store/src/sync.rs new file mode 100644 index 0000000..21b82b7 --- /dev/null +++ b/crates/workspace-store/src/sync.rs @@ -0,0 +1,837 @@ +//! Immutable transaction journal. Receiving does not modify the accepted learning projection. +use crate::{ + error::{Error, Result}, + records::STORES, +}; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +pub type Clock = BTreeMap; +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Apply { + pub expected_clock: Clock, + pub clock: Clock, + pub applied_ids: Vec, + pub values: Value, + pub resolutions: Vec, + pub retired_media: BTreeMap, +} +fn fail(code: &'static str) -> Error { + Error(code) +} +pub fn schema(db: &Connection) -> Result<()> { + crate::sync_objects::schema(db)?; + db.execute_batch("CREATE TABLE IF NOT EXISTS sync_state(id INTEGER PRIMARY KEY CHECK(id=1),group_id TEXT NOT NULL,clock TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS sync_events(cursor INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,device TEXT NOT NULL,sequence INTEGER NOT NULL,data TEXT NOT NULL,UNIQUE(device,sequence)); + CREATE TABLE IF NOT EXISTS sync_archives(group_id TEXT NOT NULL,id TEXT NOT NULL,data TEXT NOT NULL,PRIMARY KEY(group_id,id)); + CREATE TABLE IF NOT EXISTS sync_changes(store TEXT NOT NULL,id TEXT NOT NULL,before TEXT,after TEXT,position INTEGER,before_position INTEGER,PRIMARY KEY(store,id));")?; + if !db + .prepare("SELECT name FROM pragma_table_info('sync_changes') WHERE name='before_position'")? + .exists([])? + { + db.execute_batch("ALTER TABLE sync_changes ADD COLUMN before_position INTEGER;")?; + } + for store in STORES { + trigger( + db, + store, + &format!("'{store}'"), + "id", + "data", + "position", + "1", + )?; + } + for (table, store, data, position) in [ + ("retired_notes", "'retired:notes'", "data", "0"), + ("retired_cards", "'retired:cards'", "data", "0"), + ("retired_records", "'retired:'||$store", "data", "position"), + ("retired_media", "'retired:media'", "data", "0"), + ( + "retired_documents", + "'retired:documents'", + "json_object('id',$id,'kind','note','path',$path,'hash',$hash)", + "0", + ), + ("values_store", "'values:'||$namespace", "data", "0"), + ] { + let exists: bool = db.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [table], + |r| r.get(0), + )?; + if exists { + trigger( + db, + table, + store, + "id", + data, + position, + if table == "values_store" { + "($namespace LIKE 'plugin:%' OR ($namespace='settings' AND $id LIKE 'plugin:%'))" + } else { + "1" + }, + )?; + } + } + Ok(()) +} +fn expression(raw: &str, prefix: &str) -> String { + if raw.contains('$') { + raw.replace('$', &format!("{prefix}.")) + } else if matches!(raw, "id" | "data" | "position") { + format!("{prefix}.{raw}") + } else { + raw.into() + } +} +fn trigger( + db: &Connection, + table: &str, + store: &str, + id: &str, + data: &str, + position: &str, + filter: &str, +) -> Result<()> { + for action in ["INSERT", "UPDATE", "DELETE"] { + let prefix = if action == "DELETE" { "OLD" } else { "NEW" }; + let before = if action == "INSERT" { + "NULL".into() + } else { + expression(data, "OLD") + }; + let after = if action == "DELETE" { + "NULL".into() + } else { + expression(data, "NEW") + }; + let pos = if action == "DELETE" { + "NULL".into() + } else { + expression(position, "NEW") + }; + let before_pos = if action == "INSERT" { + "NULL".into() + } else { + expression(position, "OLD") + }; + db.execute_batch(&format!("DROP TRIGGER IF EXISTS {table}_sync_{action}; CREATE TRIGGER {table}_sync_{action} AFTER {action} ON {table} + WHEN EXISTS(SELECT 1 FROM sync_state) AND {} BEGIN + INSERT INTO sync_changes(store,id,before,after,position,before_position) VALUES({},{},{before},{after},{pos},{before_pos}) + ON CONFLICT(store,id) DO UPDATE SET after=excluded.after,position=excluded.position; END;", + expression(filter,prefix),expression(store,prefix),expression(id,prefix)))?; + } + Ok(()) +} +pub fn status(db: &Connection) -> Result { + let state: Option<(String, String)> = db + .query_row( + "SELECT group_id,clock FROM sync_state WHERE id=1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + match state { + None => Ok(json!({"groupId":null,"clock":{},"pending":0})), + Some((group, raw)) => { + let clock: Clock = serde_json::from_str(&raw)?; + let pending: i64=db.query_row("SELECT count(*) FROM sync_events WHERE sequence>COALESCE((SELECT value FROM json_each(?1) WHERE key=device),0)",[&raw],|r|r.get(0))?; + Ok(json!({"groupId":group,"clock":clock,"pending":pending})) + } + } +} +pub fn snapshot(db: &Connection) -> Result { + let mut result = json!({"records":{},"values":{},"retired":{}}); + for store in STORES { + let mut stmt = db.prepare(&format!( + "SELECT id,position,data FROM {store} ORDER BY position,id" + ))?; + result["records"][store] = json!({}); + for row in stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, String>(2)?, + )) + })? { + let (id, position, raw) = row?; + result["records"][store][id] = + json!({"position":position,"value":serde_json::from_str::(&raw)?}); + } + } + let mut stmt=db.prepare("SELECT namespace,id,data FROM values_store WHERE namespace LIKE 'plugin:%' OR (namespace='settings' AND id LIKE 'plugin:%')")?; + for row in stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + )) + })? { + let (ns, id, raw) = row?; + if result["values"].get(&ns).is_none() { + result["values"][&ns] = json!({}); + } + result["values"][ns][id] = + json!({"position":0,"value":serde_json::from_str::(&raw)?}); + } + for (table, store, data, pos) in [ + ("retired_notes", "'notes'", "data", "0"), + ("retired_cards", "'cards'", "data", "0"), + ("retired_records", "store", "data", "position"), + ("retired_media", "'media'", "data", "0"), + ( + "retired_documents", + "'documents'", + "json_object('id',id,'kind','note','path',path,'hash',hash)", + "0", + ), + ] { + let mut stmt = db.prepare(&format!("SELECT {store},id,{pos},{data} FROM {table}"))?; + for row in stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + r.get::<_, String>(3)?, + )) + })? { + let (store, id, position, raw) = row?; + if result["retired"].get(&store).is_none() { + result["retired"][&store] = json!({}); + } + result["retired"][store][id] = + json!({"position":position,"value":serde_json::from_str::(&raw)?}); + } + } + Ok(result) +} +fn valid_device(value: &str) -> bool { + !value.is_empty() + && value.len() <= 100 + && !["__proto__", "constructor", "prototype"].contains(&value) + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} +fn valid_change(change: &Value) -> bool { + let Some(fields) = change.as_object() else { + return false; + }; + if fields + .keys() + .any(|key| !["store", "id", "before", "after", "position"].contains(&key.as_str())) + || !fields.contains_key("before") + || !fields.contains_key("after") + { + return false; + } + let Some(store) = change["store"].as_str() else { + return false; + }; + let Some(id) = change["id"].as_str() else { + return false; + }; + if id.is_empty() || id.len() > 2048 || change["before"].is_null() && change["after"].is_null() { + return false; + } + let valid_store = STORES.contains(&store) + || store.strip_prefix("retired:").is_some_and(|s| { + [ + "notes", + "cards", + "reviews", + "practices", + "attempts", + "media", + "documents", + ] + .contains(&s) + }) + || store.strip_prefix("values:plugin:").is_some_and(|s| { + !s.is_empty() + && s.len() <= 64 + && s.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }) + || (store == "values:settings" && id.starts_with("plugin:")); + valid_store + && (change.get("position").is_none() + || !change["after"].is_null() + && change["position"].as_u64().is_some_and(|n| n <= 1_600_000)) +} +pub fn start(db: &mut Connection, group: &str, device: &str) -> Result { + if uuid::Uuid::parse_str(group).is_err() || !valid_device(device) { + return Err(Error::invalid()); + } + let current = status(db)?; + if let Some(existing) = current["groupId"].as_str() { + return if existing == group { + Ok(current) + } else { + Err(fail("sync-already-grouped")) + }; + } + let tx = db.transaction()?; + let state = snapshot(&tx)?; + tx.execute("INSERT INTO sync_state VALUES(1,?1,'{}')", [group])?; + let mut changes = Vec::new(); + for section in ["records", "values", "retired"] { + for (store, rows) in state[section].as_object().ok_or_else(Error::invalid)? { + for (id, row) in rows.as_object().ok_or_else(Error::invalid)? { + changes.push(json!({"store":if section=="records" {store.clone()} else {format!("{section}:{store}")},"id":id,"before":null,"after":row["value"],"position":row["position"]})); + } + } + } + // Baselines are split by complete records, within the same transaction. + let mut chunk = Vec::new(); + let mut bytes = 0; + for change in changes { + let size = change.to_string().len(); + if size > 32 * 1024 * 1024 { + return Err(fail("sync-event-limit")); + } + if !chunk.is_empty() && (bytes + size > 32 * 1024 * 1024 || chunk.len() >= 10_000) { + append(&tx, device, std::mem::take(&mut chunk))?; + bytes = 0; + } + bytes += size; + chunk.push(change); + } + if !chunk.is_empty() { + append(&tx, device, chunk)?; + } + tx.pragma_update(None, "user_version", 18)?; + tx.commit()?; + status(db) +} +fn append(tx: &Transaction<'_>, device: &str, changes: Vec) -> Result<()> { + if changes.is_empty() { + return Ok(()); + } + if !valid_device(device) { + return Err(fail("sync-device-required")); + } + if changes.len() > 100_000 || changes.iter().any(|change| !valid_change(change)) { + return Err(fail("sync-invalid-event")); + } + let (group, raw): (String, String) = tx.query_row( + "SELECT group_id,clock FROM sync_state WHERE id=1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + let mut clock: Clock = serde_json::from_str(&raw)?; + let sequence = clock + .entry(device.into()) + .or_insert(0) + .checked_add(1) + .ok_or_else(Error::invalid)?; + let id = format!("{device}:{sequence}"); + let event = json!({"protocol":1,"groupId":group,"id":id,"deviceId":device,"sequence":sequence,"clock":clock,"changes":changes}); + if event.to_string().len() > 64 * 1024 * 1024 { + return Err(fail("sync-event-limit")); + } + tx.execute( + "INSERT INTO sync_events(id,device,sequence,data) VALUES(?1,?2,?3,?4)", + params![id, device, sequence as i64, event.to_string()], + )?; + clock.insert(device.into(), sequence); + tx.execute( + "UPDATE sync_state SET clock=?1 WHERE id=1", + [serde_json::to_string(&clock)?], + )?; + Ok(()) +} +pub fn flush(tx: &Transaction<'_>, device: Option<&str>) -> Result<()> { + let mut changes = Vec::new(); + let mut stmt=tx.prepare("SELECT store,id,before,after,position FROM sync_changes WHERE before IS NOT after OR (after IS NOT NULL AND position IS NOT before_position) ORDER BY store,id")?; + for row in stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, Option>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, + )) + })? { + let (store, id, before, after, pos) = row?; + let mut change = json!({"store":store,"id":id,"before":before.map(|raw|serde_json::from_str::(&raw)).transpose()?,"after":after.map(|raw|serde_json::from_str::(&raw)).transpose()?}); + if !change["after"].is_null() { + change["position"] = json!(pos.unwrap_or(0)); + } + changes.push(change); + } + drop(stmt); + if !changes.is_empty() { + append(tx, device.ok_or(fail("sync-device-required"))?, changes)?; + } + tx.execute("DELETE FROM sync_changes", [])?; + Ok(()) +} +pub fn list(db: &Connection, clock: &Clock, limit: usize) -> Result { + if clock.len() > 128 || limit == 0 || limit > 256 { + return Err(Error::invalid()); + } + let mut stmt=db.prepare("SELECT data FROM sync_events WHERE sequence>COALESCE((SELECT value FROM json_each(?1) WHERE key=device),0) ORDER BY device,sequence LIMIT ?2")?; + let rows = stmt.query_map(params![serde_json::to_string(clock)?, limit as i64], |r| { + r.get::<_, String>(0) + })?; + let mut events = Vec::new(); + let mut bytes = 0; + for row in rows { + let raw = row?; + if bytes + raw.len() > 48 * 1024 * 1024 && !events.is_empty() { + break; + } + bytes += raw.len(); + events.push(serde_json::from_str::(&raw)?); + } + Ok(json!(events)) +} +pub fn received_clock(db: &Connection) -> Result { + // Only contiguous sequences are acknowledged; out-of-order delivery cannot hide a gap. + let mut clock = Clock::new(); + let mut stmt = + db.prepare("SELECT device,sequence FROM sync_events ORDER BY device,sequence")?; + for row in stmt.query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? as u64)) + })? { + let (device, seq) = row?; + let value = clock.entry(device).or_default(); + if seq == *value + 1 { + *value = seq; + } + } + Ok(clock) +} +pub fn receive(db: &mut Connection, events: &[Value]) -> Result { + if events.len() > 256 || serde_json::to_vec(events)?.len() > 64 * 1024 * 1024 { + return Err(fail("sync-event-limit")); + } + let tx = db.transaction()?; + let state = status(&tx)?; + if state["groupId"].is_null() { + return Err(fail("sync-not-enabled")); + } + for event in events { + let device = event["deviceId"].as_str().ok_or_else(Error::invalid)?; + let seq = event["sequence"] + .as_u64() + .filter(|n| *n > 0 && *n < 9_007_199_254_740_991) + .ok_or_else(Error::invalid)?; + let id = format!("{device}:{seq}"); + let clock: Clock = serde_json::from_value(event["clock"].clone())?; + if !valid_device(device) + || event["id"] != id + || event["protocol"] != 1 + || event["groupId"] != state["groupId"] + || clock.len() > 128 + || clock.get(device).copied() != Some(seq - 1) + || clock + .iter() + .any(|(key, value)| !valid_device(key) || *value > 9_007_199_254_740_991) + || event["changes"] + .as_array() + .is_none_or(|c| c.len() > 100_000 || c.iter().any(|change| !valid_change(change))) + || event.as_object().is_none_or(|fields| fields.len() != 7) + { + return Err(fail("sync-invalid-event")); + } + let mut keys = std::collections::HashSet::new(); + if event["changes"] + .as_array() + .unwrap() + .iter() + .any(|change| !keys.insert((change["store"].as_str(), change["id"].as_str()))) + { + return Err(fail("sync-invalid-event")); + } + let raw = serde_json::to_string(event)?; + let prior: Option = tx + .query_row("SELECT data FROM sync_events WHERE id=?1", [&id], |r| { + r.get(0) + }) + .optional()?; + if let Some(prior) = prior { + if serde_json::from_str::(&prior)? != *event { + return Err(fail("sync-event-identity-conflict")); + } + } else { + tx.execute( + "INSERT INTO sync_events(id,device,sequence,data) VALUES(?1,?2,?3,?4)", + params![id, device, seq as i64, raw], + )?; + } + } + tx.commit()?; + Ok(json!({"receivedClock":received_clock(db)?})) +} +pub fn apply(tx: &Transaction<'_>, request: &Apply, device: Option<&str>) -> Result<()> { + let current: Clock = serde_json::from_value(status(tx)?["clock"].clone())?; + if current != request.expected_clock { + return Err(fail("sync-preview-stale")); + } + let mut next = current.clone(); + for id in &request.applied_ids { + let raw: String = tx.query_row("SELECT data FROM sync_events WHERE id=?1", [id], |r| { + r.get(0) + })?; + let event: Value = serde_json::from_str(&raw)?; + let deps: Clock = serde_json::from_value(event["clock"].clone())?; + if deps + .iter() + .any(|(device, seq)| next.get(device).copied().unwrap_or(0) < *seq) + { + return Err(fail("sync-missing-dependency")); + } + let device = event["deviceId"].as_str().ok_or_else(Error::invalid)?; + let seq = event["sequence"].as_u64().ok_or_else(Error::invalid)?; + if seq != next.get(device).copied().unwrap_or(0) + 1 { + return Err(fail("sync-invalid-clock")); + } + next.insert(device.into(), seq); + } + if next != request.clock { + return Err(fail("sync-invalid-clock")); + } + for (id, value) in &request.retired_media { + if value["id"]!=*id || !tx.query_row("SELECT EXISTS(SELECT 1 FROM assets WHERE id=?1 AND mime=json_extract(?2,'$.type') AND size=json_extract(?2,'$.size'))",params![id,value.to_string()],|r|r.get::<_,bool>(0))? {return Err(Error::invalid());} + tx.execute("INSERT INTO retired_media VALUES(?1,?2) ON CONFLICT(id) DO UPDATE SET data=excluded.data",params![id,value.to_string()])?; + } + for (namespace, rows) in request.values.as_object().ok_or_else(Error::invalid)? { + if namespace != "settings" && !namespace.starts_with("plugin:") { + return Err(Error::invalid()); + } + for (id, row) in rows.as_object().ok_or_else(Error::invalid)? { + if namespace == "settings" && !id.starts_with("plugin:") { + return Err(Error::invalid()); + } + if row.is_null() { + tx.execute( + "DELETE FROM values_store WHERE namespace=?1 AND id=?2", + params![namespace, id], + )?; + } else { + tx.execute("INSERT INTO values_store VALUES(?1,?2,?3) ON CONFLICT(namespace,id) DO UPDATE SET data=excluded.data",params![namespace,id,row.to_string()])?; + } + } + } + tx.execute( + "UPDATE sync_state SET clock=?1 WHERE id=1", + [serde_json::to_string(&next)?], + )?; + tx.execute("DELETE FROM sync_changes", [])?; + // Imported projection writes are not new local edits. Only the explicit causal + // resolution is authored here, including a no-op when the user chose the remote branch. + if !request.resolutions.is_empty() { + append( + tx, + device.ok_or(fail("sync-device-required"))?, + request.resolutions.clone(), + )?; + } + Ok(()) +} + +impl crate::library::Library { + pub fn sync_bind_device(&mut self, device: &str) -> Result<()> { + if !valid_device(device) { + return Err(Error::invalid()); + } + // A restored library has a new workspace identity. Two separate copies on + // the same device must not author the same (writer, sequence) identities. + let writer = crate::files::digest( + format!("{device}\n{}\n{}", self.id, self.root.to_string_lossy()).as_bytes(), + ); + self.db.sync_device = Some(writer); + Ok(()) + } + pub fn sync_request(&mut self, method: &str, payload: Value) -> Result { + self.guard(&self.token.clone())?; + match method { + "leave" => { + let tx = self.db.connection.transaction()?; + tx.execute("INSERT INTO sync_archives SELECT s.group_id,e.id,e.data FROM sync_events e CROSS JOIN sync_state s WHERE 1 ON CONFLICT(group_id,id) DO NOTHING",[])?; + tx.execute_batch( + "DELETE FROM sync_changes;DELETE FROM sync_events;DELETE FROM sync_state;", + )?; + tx.commit()?; + status(&self.db.connection) + } + "start" => start( + &mut self.db.connection, + payload["groupId"].as_str().ok_or_else(Error::invalid)?, + self.db + .sync_device + .as_deref() + .ok_or(fail("sync-device-required"))?, + ), + "journal/status" => { + let mut value = status(&self.db.connection)?; + value["receivedClock"] = json!(received_clock(&self.db.connection)?); + Ok(value) + } + "journal/list" => list( + &self.db.connection, + &serde_json::from_value(payload["clock"].clone())?, + payload["limit"].as_u64().unwrap_or(128) as usize, + ), + "journal/receive" => receive( + &mut self.db.connection, + payload["events"].as_array().ok_or_else(Error::invalid)?, + ), + "objects/info" | "objects/read" | "objects/write" | "objects/finish" => { + self.sync_object_request(method, payload) + } + "snapshot" => Ok( + json!({"snapshot":snapshot(&self.db.connection)?,"status":status(&self.db.connection)?,"revision":self.db.connection.query_row("SELECT revision FROM workspace WHERE id=1",[],|r|r.get::<_,i64>(0))?}), + ), + _ => Err(Error::invalid()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + library::Library, + records::{Commit, Patch}, + }; + fn library() -> (tempfile::TempDir, Library) { + let dir = tempfile::tempdir().unwrap(); + let mut lib = Library::create(dir.path()).unwrap(); + lib.sync_bind_device("device-a").unwrap(); + (dir, lib) + } + fn begin(lib: &mut Library, group: &str) { + lib.sync_request("start", json!({"groupId":group})).unwrap(); + } + fn value(lib: &mut Library, id: &str, value: Value) { + lib.write(json!({"type":"value","namespace":"plugin:sample","id":id,"value":value})) + .unwrap(); + } + #[test] + fn durable_values_baseline_and_atomic_local_events_exclude_device_preferences() { + let (dir, mut lib) = library(); + value(&mut lib, "entry", json!({"data":"before"})); + lib.write( + json!({"type":"value","namespace":"settings","id":"repeaty.plugins","value":"private"}), + ) + .unwrap(); + let group = uuid::Uuid::new_v4().to_string(); + begin(&mut lib, &group); + let writer = lib.db.sync_device.clone().unwrap(); + let initial = list(&lib.db.connection, &Clock::new(), 128).unwrap(); + assert_eq!(initial.as_array().unwrap().len(), 1); + assert_eq!(initial[0]["changes"].as_array().unwrap().len(), 1); + value(&mut lib, "entry", json!({"data":"after"})); + let events = list(&lib.db.connection, &Clock::new(), 128).unwrap(); + assert_eq!(events[1]["clock"][&writer], 1); + assert_eq!(events[1]["changes"][0]["before"]["data"], "before"); + assert_eq!(events[1]["changes"][0]["after"]["data"], "after"); + assert_eq!(status(&lib.db.connection).unwrap()["pending"], 0); + drop(lib); + let mut reopened = Library::open(dir.path()).unwrap(); + reopened.sync_bind_device("device-a").unwrap(); + assert_eq!(reopened.db.sync_device.as_ref(), Some(&writer)); + assert_eq!( + list(&reopened.db.connection, &Clock::new(), 128).unwrap(), + events + ); + } + #[test] + fn receive_is_staging_idempotent_and_rejects_equivocation_and_wrong_group() { + let (_dir, mut lib) = library(); + let group = uuid::Uuid::new_v4().to_string(); + begin(&mut lib, &group); + let event = json!({"protocol":1,"groupId":group,"id":"remote:1","deviceId":"remote","sequence":1,"clock":{"remote":0},"changes":[{"store":"values:plugin:sample","id":"a","before":null,"after":"remote","position":0}]}); + receive(&mut lib.db.connection, &[event.clone()]).unwrap(); + receive(&mut lib.db.connection, &[event.clone()]).unwrap(); + assert_eq!(status(&lib.db.connection).unwrap()["pending"], 1); + assert!( + snapshot(&lib.db.connection).unwrap()["values"] + .as_object() + .unwrap() + .is_empty() + ); + assert!( + status(&lib.db.connection).unwrap()["clock"] + .as_object() + .unwrap() + .is_empty() + ); + let mut bad = event.clone(); + bad["changes"][0]["after"] = json!("forged"); + assert_eq!( + receive(&mut lib.db.connection, &[bad]).unwrap_err().0, + "sync-event-identity-conflict" + ); + let mut wrong = event; + wrong["groupId"] = json!(uuid::Uuid::new_v4().to_string()); + assert!(receive(&mut lib.db.connection, &[wrong]).is_err()); + } + #[test] + fn gaps_are_not_acknowledged_and_apply_is_causal_and_transactional() { + let (_dir, mut lib) = library(); + let group = uuid::Uuid::new_v4().to_string(); + begin(&mut lib, &group); + let event = |seq: u64| json!({"protocol":1,"groupId":group,"id":format!("remote:{seq}"),"deviceId":"remote","sequence":seq,"clock":{"remote":seq-1},"changes":[]}); + receive(&mut lib.db.connection, &[event(2)]).unwrap(); + assert_eq!( + received_clock(&lib.db.connection).unwrap().get("remote"), + Some(&0) + ); + receive(&mut lib.db.connection, &[event(1)]).unwrap(); + assert_eq!( + received_clock(&lib.db.connection).unwrap().get("remote"), + Some(&2) + ); + let mut request = Apply { + expected_clock: Clock::new(), + clock: Clock::from([("remote".into(), 2)]), + applied_ids: vec!["remote:2".into(), "remote:1".into()], + values: json!({"plugin:sample":{"a":"data"}}), + resolutions: vec![], + retired_media: BTreeMap::new(), + }; + { + let tx = lib.db.connection.transaction().unwrap(); + assert!(apply(&tx, &request, lib.db.sync_device.as_deref()).is_err()); + } + assert_eq!(status(&lib.db.connection).unwrap()["pending"], 2); + request.applied_ids.reverse(); + { + let tx = lib.db.connection.transaction().unwrap(); + apply(&tx, &request, lib.db.sync_device.as_deref()).unwrap(); + flush(&tx, lib.db.sync_device.as_deref()).unwrap(); + tx.commit().unwrap(); + } + assert_eq!(status(&lib.db.connection).unwrap()["pending"], 0); + assert_eq!( + snapshot(&lib.db.connection).unwrap()["values"]["plugin:sample"]["a"]["value"], + "data" + ); + assert_eq!( + list(&lib.db.connection, &Clock::new(), 128) + .unwrap() + .as_array() + .unwrap() + .len(), + 2 + ); + } + #[test] + fn grading_journal_reads_only_changed_rows_and_failed_commit_rolls_back() { + let (_dir, mut lib) = library(); + let group = uuid::Uuid::new_v4().to_string(); + begin(&mut lib, &group); + let patch = Patch { + store: "savedSearches".into(), + id: "search".into(), + position: Some(0), + value: Some( + json!({"id":"search","name":"Search","query":"tag:test","deckId":"","state":"all"}), + ), + position_only: false, + }; + let commit = Commit { + expected_revision: 0, + patches: vec![patch], + reason: None, + operation_history: None, + retired: None, + }; + lib.db.commit(commit.clone()).unwrap(); + let prior = list(&lib.db.connection, &Clock::new(), 128).unwrap(); + assert!(lib.db.commit(commit).is_err()); + assert_eq!(list(&lib.db.connection, &Clock::new(), 128).unwrap(), prior); + assert_eq!( + lib.db + .connection + .query_row("SELECT count(*) FROM sync_changes", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + } + #[test] + fn position_only_edits_are_logged_and_leaving_archives_events_without_deleting_data() { + let (_dir, mut lib) = library(); + let group = uuid::Uuid::new_v4().to_string(); + begin(&mut lib, &group); + let patches = ["a", "b"] + .into_iter() + .enumerate() + .map(|(position, id)| Patch { + store: "savedSearches".into(), + id: id.into(), + position: Some(position as i64), + value: Some(json!({"id":id,"name":id,"query":"","deckId":"","state":"all"})), + position_only: false, + }) + .collect(); + lib.db + .commit(Commit { + expected_revision: 0, + patches, + reason: None, + operation_history: None, + retired: None, + }) + .unwrap(); + let patches = ["b", "a"] + .into_iter() + .enumerate() + .map(|(position, id)| Patch { + store: "savedSearches".into(), + id: id.into(), + position: Some(position as i64), + value: None, + position_only: true, + }) + .collect(); + lib.db + .commit(Commit { + expected_revision: 1, + patches, + reason: None, + operation_history: None, + retired: None, + }) + .unwrap(); + let events = list(&lib.db.connection, &Clock::new(), 128).unwrap(); + assert_eq!(events[1]["changes"][0]["id"], "a"); + assert_eq!(events[1]["changes"][0]["position"], 1); + assert_eq!( + events[1]["changes"][0]["before"], + events[1]["changes"][0]["after"] + ); + let before = lib.db.load().unwrap(); + let left = lib.sync_request("leave", Value::Null).unwrap(); + assert!(left["groupId"].is_null()); + assert_eq!(lib.db.load().unwrap(), before); + assert_eq!( + lib.db + .connection + .query_row("SELECT count(*) FROM sync_archives", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 2 + ); + begin(&mut lib, &uuid::Uuid::new_v4().to_string()); + assert_eq!( + list(&lib.db.connection, &Clock::new(), 128) + .unwrap() + .as_array() + .unwrap() + .len(), + 1 + ); + } +} diff --git a/crates/workspace-store/src/sync_objects.rs b/crates/workspace-store/src/sync_objects.rs new file mode 100644 index 0000000..915c443 --- /dev/null +++ b/crates/workspace-store/src/sync_objects.rs @@ -0,0 +1,764 @@ +//! Content-addressed, resumable objects. Partial bytes never become library references. +use crate::{ + atomic, + error::{Error, Result}, + files, + library::Library, + records::{MAX_DOCUMENT, MAX_MEDIA}, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use cap_std::fs::{Dir, OpenOptions}; +use rusqlite::{OptionalExtension, params}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeSet, + fs::File, + io::{self, Read, Seek, SeekFrom, Write}, + path::Path, +}; + +const CHUNK: usize = 256 * 1024; +const MAX_OBJECTS: usize = 50_000; +const MAX_STAGED: usize = 1024; +pub(crate) fn schema(db: &rusqlite::Connection) -> Result<()> { + db.execute_batch("CREATE TABLE IF NOT EXISTS sync_object_documents(hash TEXT PRIMARY KEY,size INTEGER NOT NULL);")?; + Ok(()) +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct Object { + kind: String, + id: String, + mime: String, + hash: String, + size: u64, +} +#[derive(Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct Manifest { + kind: String, + hash: String, + size: u64, +} +fn fail(code: &'static str) -> Error { + Error(code) +} +fn disk(error: io::Error) -> Error { + if error.kind() == io::ErrorKind::StorageFull + || (cfg!(windows) && matches!(error.raw_os_error(), Some(39 | 112))) + { + fail("sync-disk-full") + } else { + fail("sync-storage-error") + } +} +fn hash_valid(hash: &str) -> bool { + hash.len() == 64 + && hash + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +fn extension(mime: &str) -> Result<&'static str> { + Ok(match mime { + "image/png" => "png", + "image/jpeg" => "jpg", + "image/webp" => "webp", + "image/gif" => "gif", + "text/markdown" => "md", + "text/plain" => "txt", + "text/html" => "html", + "application/pdf" => "pdf", + "application/epub+zip" => "epub", + "application/json" => "json", + "application/octet-stream" => "bin", + "audio/mpeg" => "mp3", + "audio/ogg" => "ogg", + "audio/mp4" => "m4a", + "audio/webm" => "webm", + "audio/flac" => "flac", + "audio/aac" => "aac", + "audio/wav" | "audio/x-wav" => "wav", + _ => return Err(fail("sync-invalid-object")), + }) +} +impl Object { + fn validate(&self) -> Result<()> { + if !hash_valid(&self.hash) || self.id.is_empty() || self.id.encode_utf16().count() > 150 { + return Err(fail("sync-invalid-object")); + } + let limit = match self.kind.as_str() { + "document" if self.mime == "text/markdown" && self.id == self.hash => { + files::MAX_TEXT_BYTES + } + "media" => { + extension(&self.mime)?; + if matches!( + self.mime.as_str(), + "application/pdf" + | "application/epub+zip" + | "text/html" + | "application/octet-stream" + ) { + MAX_DOCUMENT as u64 + } else { + MAX_MEDIA as u64 + } + } + _ => return Err(fail("sync-invalid-object")), + }; + if self.size > limit || (self.kind == "media" && self.size == 0) { + return Err(fail("sync-invalid-object")); + } + if self.kind == "media" + && self.mime.starts_with("text/") + && self.mime != "text/html" + && self.size > 1024 * 1024 + { + return Err(fail("sync-invalid-object")); + } + Ok(()) + } + fn directory(&self) -> &str { + if self.kind == "document" { + "objects" + } else { + "media" + } + } + fn target(&self) -> Result { + Ok(if self.kind == "document" { + self.hash.clone() + } else { + format!("{}.{}", self.hash, extension(&self.mime)?) + }) + } + fn stage(&self) -> String { + format!("sync-{}-{}", self.kind, self.hash) + } + fn manifest(&self) -> Manifest { + Manifest { + kind: self.kind.clone(), + hash: self.hash.clone(), + size: self.size, + } + } + fn chunks(&self) -> u64 { + self.size.div_ceil(CHUNK as u64) + } +} +fn directory(parent: &Dir, name: &str) -> Result { + let parent = parent.try_clone().map_err(disk)?.into_std_file(); + let file = cap_primitives::fs::open_dir_nofollow(&parent, Path::new(name)).map_err(disk)?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if file.metadata().map_err(disk)?.file_attributes() & 0x400 != 0 { + return Err(fail("sync-unsafe-object")); + } + } + Ok(Dir::from_std_file(file)) +} +fn regular(file: File) -> Result { + let metadata = file.metadata().map_err(disk)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(fail("sync-unsafe-object")); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(fail("sync-unsafe-object")); + } + } + #[cfg(windows)] + { + use std::os::windows::{fs::MetadataExt, io::AsRawHandle}; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + if metadata.file_attributes() & 0x400 != 0 { + return Err(fail("sync-unsafe-object")); + } + // SAFETY: the file owns its handle and the initialized result buffer is writable. + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 + || info.nNumberOfLinks != 1 + { + return Err(fail("sync-unsafe-object")); + } + } + Ok(file) +} +fn open(dir: &Dir, name: &str) -> Result> { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use cap_std::fs::OpenOptionsExt; + options.custom_flags(0x00200000); + } + match dir.open_with(name, &options) { + Ok(file) => regular(file.into_std()).map(Some), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(disk(error)), + } +} +fn create(dir: &Dir, name: &str) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + options.mode(0o600); + } + dir.open_with(name, &options) + .map(|f| f.into_std()) + .map_err(disk) +} +#[cfg(test)] +thread_local! {static FAIL_WRITE:std::cell::Cell=const{std::cell::Cell::new(false)};} +fn write_bytes(file: &mut File, content: &[u8]) -> io::Result<()> { + #[cfg(test)] + if FAIL_WRITE.with(|fault| fault.replace(false)) { + file.write_all(&content[..content.len() / 2])?; + return Err(io::ErrorKind::StorageFull.into()); + } + file.write_all(content) +} +fn sync_dir(dir: &Dir) -> Result<()> { + #[cfg(unix)] + dir.try_clone() + .map_err(disk)? + .into_std_file() + .sync_all() + .map_err(disk)?; + #[cfg(not(unix))] + let _ = dir; + Ok(()) +} +fn bytes(mut file: File, limit: usize) -> Result> { + if file.metadata().map_err(disk)?.len() > limit as u64 { + return Err(fail("sync-object-limit")); + } + let mut bytes = Vec::new(); + Read::by_ref(&mut file) + .take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(disk)?; + if bytes.len() > limit { + return Err(fail("sync-object-limit")); + } + Ok(bytes) +} +fn verify(mut file: File, object: &Object) -> Result<()> { + let before = file.metadata().map_err(disk)?; + if before.len() != object.size { + return Err(fail("sync-hash-mismatch")); + } + let mut hash = Sha256::new(); + let mut buffer = [0u8; CHUNK]; + let mut read = 0; + loop { + let count = file.read(&mut buffer).map_err(disk)?; + if count == 0 { + break; + } + read += count as u64; + if read > object.size { + return Err(fail("sync-hash-mismatch")); + } + hash.update(&buffer[..count]); + } + let after = file.metadata().map_err(disk)?; + if read != object.size + || format!("{:x}", hash.finalize()) != object.hash + || before.len() != after.len() + || before.modified().map_err(disk)? != after.modified().map_err(disk)? + { + return Err(fail("sync-hash-mismatch")); + } + Ok(()) +} +fn stage(root: &Dir, object: &Object, create_stage: bool) -> Result> { + let private = directory(root, ".repeaty")?; + let staging = directory(&private, "staging")?; + let name = object.stage(); + if create_stage { + if !staging.try_exists(&name).map_err(disk)? { + let count = staging + .entries() + .map_err(disk)? + .filter_map(std::result::Result::ok) + .filter(|e| e.file_name().to_string_lossy().starts_with("sync-")) + .count(); + if count >= MAX_STAGED { + return Err(fail("sync-object-limit")); + } + } + match staging.create_dir(&name) { + Ok(()) => sync_dir(&staging)?, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(disk(error)), + } + } else if !staging.try_exists(&name).map_err(disk)? { + return Ok(None); + } + let dir = directory(&staging, &name)?; + if let Some(file) = open(&dir, "manifest.json")? { + let manifest: Manifest = serde_json::from_slice(&bytes(file, 4096)?)?; + if manifest != object.manifest() { + return Err(fail("sync-object-conflict")); + } + } else if create_stage { + let temp = format!("{}.manifest", uuid::Uuid::new_v4()); + let mut file = create(&dir, &temp)?; + file.write_all(&serde_json::to_vec(&object.manifest())?) + .map_err(disk)?; + file.sync_all().map_err(disk)?; + drop(file); + // This is only disposable transfer metadata, never an existing library file. + dir.rename(&temp, &dir, "manifest.json").map_err(disk)?; + sync_dir(&dir)?; + } else { + return Err(fail("sync-object-conflict")); + } + Ok(Some(dir)) +} +fn chunk_name(index: u64, hash: &str) -> String { + format!("{index:06}-{hash}.chunk") +} +fn chunks(dir: &Dir, object: &Object, verify_hash: bool) -> Result> { + let mut found = Vec::new(); + let mut seen = BTreeSet::new(); + for entry in dir.entries().map_err(disk)? { + let entry = entry.map_err(disk)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| fail("sync-unsafe-object"))?; + if !name.ends_with(".chunk") { + continue; + } + let Some((index, hash)) = name.trim_end_matches(".chunk").split_once('-') else { + return Err(fail("sync-unsafe-object")); + }; + let index = index + .parse::() + .map_err(|_| fail("sync-unsafe-object"))?; + if index >= object.chunks() || !hash_valid(hash) || !seen.insert(index) { + return Err(fail("sync-object-conflict")); + } + let file = open(dir, &name)?.ok_or(fail("sync-object-missing"))?; + let expected = (object.size - index * CHUNK as u64).min(CHUNK as u64) as usize; + if file.metadata().map_err(disk)?.len() != expected as u64 { + return Err(fail("sync-hash-mismatch")); + } + if verify_hash && files::digest(&bytes(file, CHUNK)?) != hash { + return Err(fail("sync-hash-mismatch")); + } + found.push((index, name)); + } + found.sort_by_key(|(index, _)| *index); + Ok(found) +} +fn cleanup_stage(root: &Dir, object: &Object) -> Result<()> { + let Some(dir) = stage(root, object, false)? else { + return Ok(()); + }; + for entry in dir.entries().map_err(disk)? { + let name = entry + .map_err(disk)? + .file_name() + .into_string() + .map_err(|_| fail("sync-unsafe-object"))?; + let disposable = name == "manifest.json" + || name.ends_with(".chunk") + || [".partial", ".assembled", ".manifest"] + .iter() + .any(|suffix| { + name.strip_suffix(suffix) + .is_some_and(|stem| uuid::Uuid::parse_str(stem).is_ok()) + }); + if !disposable { + return Ok(()); + } + // Never follow a link or recursively remove anything placed here by another writer. + if open(&dir, &name)?.is_some() { + dir.remove_file(&name).map_err(disk)?; + } + } + let private = directory(root, ".repeaty")?; + let staging = directory(&private, "staging")?; + staging.remove_dir(object.stage()).map_err(disk)?; + sync_dir(&staging) +} +impl Library { + /// Called only by a confirmed synchronous Save, never by inventory or incoming RPC. + /// As with local media import, a failed later SQLite commit can leave a complete, + /// unreferenced public file; existing working content is never overwritten. + pub(crate) fn publish_sync_assets(&self, ids: &[String]) -> Result<()> { + if ids.len() > MAX_OBJECTS { + return Err(fail("sync-object-limit")); + } + let private = directory(&self.anchor, ".repeaty")?; + let media = directory(&private, "media")?; + let staging = directory(&private, "staging")?; + let working = directory(&self.anchor, "assets")?; + for id in ids.iter().collect::>() { + let (mime, path, size): (String, String, i64) = self + .db + .connection + .query_row("SELECT mime,path,size FROM assets WHERE id=?1", [id], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + }) + .optional()? + .ok_or(fail("sync-object-missing"))?; + let object = Object { + kind: "media".into(), + id: id.clone(), + hash: path + .split('.') + .next() + .ok_or(fail("sync-invalid-object"))? + .into(), + mime, + size: u64::try_from(size).map_err(|_| fail("sync-invalid-object"))?, + }; + object.validate()?; + if object.target()? != path { + return Err(fail("sync-invalid-object")); + } + if let Some(file) = open(&working, &path)? { + verify(file, &object).map_err(|_| fail("working-file-changed"))?; + continue; + } + let mut source = open(&media, &path)?.ok_or(fail("sync-object-missing"))?; + let temp = format!("sync-working-{}", uuid::Uuid::new_v4()); + let mut output = create(&staging, &temp)?; + let copied = (|| -> Result<()> { + let mut hash = Sha256::new(); + let mut size = 0; + let mut buffer = [0u8; CHUNK]; + loop { + let count = source.read(&mut buffer).map_err(disk)?; + if count == 0 { + break; + } + size += count as u64; + if size > object.size { + return Err(fail("sync-hash-mismatch")); + } + hash.update(&buffer[..count]); + write_bytes(&mut output, &buffer[..count]).map_err(disk)?; + } + if size != object.size || format!("{:x}", hash.finalize()) != object.hash { + return Err(fail("sync-hash-mismatch")); + } + output.sync_all().map_err(disk)?; + Ok(()) + })(); + drop(output); + if let Err(error) = copied { + let _ = staging.remove_file(&temp); + return Err(error); + } + self.guard(&self.token.clone())?; + let candidate = self.root.join(".repeaty/staging").join(&temp); + let target = self.root.join("assets").join(&path); + match atomic::publish_new(&candidate, &target) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let file = open(&working, &path)?.ok_or(fail("working-file-changed"))?; + verify(file, &object).map_err(|_| fail("working-file-changed"))?; + let _ = staging.remove_file(&temp); + } + Err(error) => { + let _ = staging.remove_file(&temp); + return Err(disk(error)); + } + } + sync_dir(&working)?; + } + Ok(()) + } + fn object_complete(&self, object: &Object) -> Result { + let private = directory(&self.anchor, ".repeaty")?; + let target = directory(&private, object.directory())?; + let Some(file) = open(&target, &object.target()?)? else { + return Ok(false); + }; + verify(file, object)?; + Ok(true) + } + fn object_info(&self, object: &Object) -> Result { + let complete = self.object_complete(object)?; + let received = if complete { + (0..object.chunks()).collect::>() + } else if let Some(dir) = stage(&self.anchor, object, false)? { + chunks(&dir, object, true)? + .into_iter() + .map(|(i, _)| i) + .collect() + } else { + vec![] + }; + Ok(json!({"complete":complete,"received":received,"chunkSize":CHUNK})) + } + fn object_inventory(&self) -> Result { + let mut objects = Vec::new(); + let mut stmt = self + .db + .connection + .prepare("SELECT id,mime,path,size FROM assets ORDER BY id LIMIT 50001")?; + for row in stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, i64>(3)?, + )) + })? { + let (id, mime, path, size) = row?; + let hash = path + .split('.') + .next() + .ok_or(fail("sync-invalid-object"))? + .to_owned(); + let object = Object { + kind: "media".into(), + id, + mime, + hash, + size: u64::try_from(size).map_err(|_| fail("sync-invalid-object"))?, + }; + object.validate()?; + if object.target()? != path { + return Err(fail("sync-invalid-object")); + } + objects.push(object); + if objects.len() > MAX_OBJECTS { + return Err(fail("sync-object-limit")); + } + } + let mut stmt=self.db.connection.prepare("SELECT hash FROM documents UNION SELECT hash FROM retired_documents UNION SELECT hash FROM sync_object_documents ORDER BY hash LIMIT 50001")?; + let private = directory(&self.anchor, ".repeaty")?; + let documents = directory(&private, "objects")?; + for row in stmt.query_map([], |r| r.get::<_, String>(0))? { + let hash = row?; + if !hash_valid(&hash) { + return Err(fail("sync-invalid-object")); + } + let file = open(&documents, &hash)?.ok_or(fail("sync-object-missing"))?; + let object = Object { + kind: "document".into(), + id: hash.clone(), + mime: "text/markdown".into(), + hash, + size: file.metadata().map_err(disk)?.len(), + }; + object.validate()?; + objects.push(object); + if objects.len() > MAX_OBJECTS { + return Err(fail("sync-object-limit")); + } + } + Ok(json!({"objects":objects,"chunkSize":CHUNK})) + } + pub fn sync_object_request(&mut self, method: &str, payload: Value) -> Result { + self.guard(&self.token.clone())?; + if method == "objects/info" && payload.get("object").is_none() { + return self.object_inventory(); + } + let object: Object = serde_json::from_value(payload["object"].clone())?; + object.validate()?; + match method { + "objects/info" => self.object_info(&object), + "objects/read" => { + let offset = payload["offset"].as_u64().ok_or_else(Error::invalid)?; + if offset % CHUNK as u64 != 0 || offset >= object.size { + return Err(fail("sync-invalid-object")); + } + let private = directory(&self.anchor, ".repeaty")?; + let dir = directory(&private, object.directory())?; + let mut file = open(&dir, &object.target()?)?.ok_or(fail("sync-object-missing"))?; + if file.metadata().map_err(disk)?.len() != object.size { + return Err(fail("sync-hash-mismatch")); + } + file.seek(SeekFrom::Start(offset)).map_err(disk)?; + let mut bytes = vec![0; (object.size - offset).min(CHUNK as u64) as usize]; + file.read_exact(&mut bytes).map_err(disk)?; + Ok( + json!({"offset":offset,"chunkHash":files::digest(&bytes),"data":STANDARD.encode(bytes)}), + ) + } + "objects/write" => self.write_object_chunk(&object, &payload), + "objects/finish" => self.finish_object(&object), + _ => Err(Error::invalid()), + } + } + fn write_object_chunk(&self, object: &Object, payload: &Value) -> Result { + let offset = payload["offset"].as_u64().ok_or_else(Error::invalid)?; + let hash = payload["chunkHash"] + .as_str() + .filter(|h| hash_valid(h)) + .ok_or_else(Error::invalid)?; + let encoded = payload["data"].as_str().ok_or_else(Error::invalid)?; + if encoded.len() > CHUNK.div_ceil(3) * 4 + || offset % CHUNK as u64 != 0 + || offset >= object.size + { + return Err(fail("sync-invalid-object")); + } + let content = STANDARD + .decode(encoded) + .map_err(|_| fail("sync-invalid-object"))?; + if content.len() != (object.size - offset).min(CHUNK as u64) as usize + || files::digest(&content) != hash + { + return Err(fail("sync-hash-mismatch")); + } + if self.object_complete(object)? { + return self.object_info(object); + } + let dir = stage(&self.anchor, object, true)?.ok_or(fail("sync-object-missing"))?; + let index = offset / CHUNK as u64; + let mut found = chunks(&dir, object, false)?; + if let Some((_, name)) = found.iter().find(|(i, _)| *i == index) { + if name != &chunk_name(index, hash) { + return Err(fail("sync-object-conflict")); + } + if files::digest(&bytes( + open(&dir, name)?.ok_or(fail("sync-object-missing"))?, + CHUNK, + )?) != hash + { + return Err(fail("sync-hash-mismatch")); + } + return Ok( + json!({"complete":false,"received":found.iter().map(|(i,_)|i).collect::>(),"chunkSize":CHUNK}), + ); + } + let name = chunk_name(index, hash); + let temp = format!("{}.partial", uuid::Uuid::new_v4()); + let mut file = create(&dir, &temp)?; + if let Err(error) = write_bytes(&mut file, &content).and_then(|_| file.sync_all()) { + drop(file); + let _ = dir.remove_file(&temp); + return Err(disk(error)); + } + drop(file); + self.guard(&self.token.clone())?; + let stage_path = self.root.join(".repeaty/staging").join(object.stage()); + atomic::publish_new(&stage_path.join(&temp), &stage_path.join(&name)).map_err(disk)?; + sync_dir(&dir)?; + found.push((index, name)); + found.sort_by_key(|(i, _)| *i); + Ok( + json!({"complete":false,"received":found.iter().map(|(i,_)|i).collect::>(),"chunkSize":CHUNK}), + ) + } + fn finish_object(&mut self, object: &Object) -> Result { + if !self.object_complete(object)? { + let dir = stage(&self.anchor, object, true)?.ok_or(fail("sync-object-missing"))?; + let found = chunks(&dir, object, true)?; + if found.len() as u64 != object.chunks() { + return Err(fail("sync-object-incomplete")); + } + let temp = format!("{}.assembled", uuid::Uuid::new_v4()); + let mut file = create(&dir, &temp)?; + let mut hash = Sha256::new(); + let result = (|| -> Result<()> { + for (_, name) in found { + let content = bytes( + open(&dir, &name)?.ok_or(fail("sync-object-missing"))?, + CHUNK, + )?; + hash.update(&content); + write_bytes(&mut file, &content).map_err(disk)?; + } + if format!("{:x}", hash.finalize()) != object.hash { + return Err(fail("sync-hash-mismatch")); + } + file.sync_all().map_err(disk)?; + Ok(()) + })(); + drop(file); + if let Err(error) = result { + let _ = dir.remove_file(&temp); + return Err(error); + } + // Text validation remains equivalent to put_media; all temporary bytes stay private. + if object.kind == "document" || object.mime.starts_with("text/") { + let content = bytes( + open(&dir, &temp)?.ok_or(fail("sync-object-missing"))?, + object.size as usize, + )?; + if std::str::from_utf8(&content).is_err() { + let _ = dir.remove_file(&temp); + return Err(fail("sync-invalid-object")); + } + } + self.guard(&self.token.clone())?; + let source = self + .root + .join(".repeaty/staging") + .join(object.stage()) + .join(&temp); + let target = self + .root + .join(".repeaty") + .join(object.directory()) + .join(object.target()?); + match atomic::publish_new(&source, &target) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + self.object_complete(object)?; + let _ = dir.remove_file(&temp); + } + Err(error) => return Err(disk(error)), + } + let private = directory(&self.anchor, ".repeaty")?; + sync_dir(&directory(&private, object.directory())?)?; + if !self.object_complete(object)? { + return Err(fail("sync-object-missing")); + } + } + if object.kind == "media" { + let tx = self.db.connection.transaction()?; + let prior: Option<(String, String, i64)> = tx + .query_row( + "SELECT mime,path,size FROM assets WHERE id=?1", + [&object.id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()?; + let expected = (object.mime.clone(), object.target()?, object.size as i64); + if prior.as_ref().is_some_and(|p| p != &expected) { + return Err(fail("sync-object-conflict")); + } + if prior.is_none() { + tx.execute( + "INSERT INTO assets VALUES(?1,?2,?3,?4,unixepoch())", + params![object.id, object.mime, expected.1, expected.2], + )?; + } + tx.commit()?; + } else { + self.db.connection.execute("INSERT INTO sync_object_documents(hash,size) VALUES(?1,?2) ON CONFLICT(hash) DO UPDATE SET size=excluded.size",params![object.hash,object.size as i64])?; + } + // A successful publication and asset transaction make the verified cache disposable. + // Cleanup failure cannot turn a complete object back into an incomplete transfer. + let _ = cleanup_stage(&self.anchor, object); + Ok(json!({"complete":true})) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/workspace-store/src/sync_objects/tests.rs b/crates/workspace-store/src/sync_objects/tests.rs new file mode 100644 index 0000000..e8116b5 --- /dev/null +++ b/crates/workspace-store/src/sync_objects/tests.rs @@ -0,0 +1,314 @@ +use super::*; + +fn object(bytes: &[u8], id: &str) -> Object { + Object { + kind: "media".into(), + id: id.into(), + mime: "application/octet-stream".into(), + hash: files::digest(bytes), + size: bytes.len() as u64, + } +} +fn write(library: &mut Library, object: &Object, bytes: &[u8], index: usize) -> Result { + let start = index * CHUNK; + let content = &bytes[start..bytes.len().min(start + CHUNK)]; + library.sync_object_request("objects/write",json!({"object":object,"offset":start,"data":STANDARD.encode(content),"chunkHash":files::digest(content)})) +} +fn count(library: &Library) -> i64 { + library + .db + .connection + .query_row("SELECT count(*) FROM assets", [], |r| r.get(0)) + .unwrap() +} + +#[test] +fn chunks_are_out_of_order_idempotent_and_resume_after_reopen_without_partial_references() { + let directory = tempfile::tempdir().unwrap(); + let mut library = Library::create(directory.path()).unwrap(); + let content = vec![42; CHUNK * 2 + 19]; + let item = object(&content, "asset-a"); + assert_eq!( + write(&mut library, &item, &content, 2).unwrap()["received"], + json!([2]) + ); + assert_eq!( + write(&mut library, &item, &content, 0).unwrap()["received"], + json!([0, 2]) + ); + assert_eq!( + write(&mut library, &item, &content, 0).unwrap()["received"], + json!([0, 2]) + ); + assert_eq!(count(&library), 0); + assert_eq!( + library + .sync_object_request("objects/finish", json!({"object":item})) + .unwrap_err() + .0, + "sync-object-incomplete" + ); + assert!( + !directory + .path() + .join(".repeaty/media") + .join(item.target().unwrap()) + .exists() + ); + drop(library); + let mut library = Library::open(directory.path()).unwrap(); + assert_eq!( + library + .sync_object_request("objects/info", json!({"object":item})) + .unwrap()["received"], + json!([0, 2]) + ); + write(&mut library, &item, &content, 1).unwrap(); + assert_eq!( + library + .sync_object_request("objects/finish", json!({"object":item})) + .unwrap()["complete"], + true + ); + assert_eq!(count(&library), 1); + let read = library + .sync_object_request("objects/read", json!({"object":item,"offset":CHUNK*2})) + .unwrap(); + assert_eq!( + STANDARD.decode(read["data"].as_str().unwrap()).unwrap(), + vec![42; 19] + ); + let alias = Object { + id: "asset-b".into(), + ..item.clone() + }; + library + .sync_object_request("objects/finish", json!({"object":alias})) + .unwrap(); + assert_eq!(count(&library), 2); + let inventory = library + .sync_object_request("objects/info", json!({})) + .unwrap(); + assert_eq!(inventory["objects"].as_array().unwrap().len(), 2); + assert!( + !inventory + .to_string() + .contains(directory.path().to_str().unwrap()) + ); + let different = object(b"different", "asset-a"); + write(&mut library, &different, b"different", 0).unwrap(); + assert_eq!( + library + .sync_object_request("objects/finish", json!({"object":different})) + .unwrap_err() + .0, + "sync-object-conflict" + ); + assert_eq!(library.db.media("asset-a").unwrap().1, content); +} + +#[test] +fn hash_and_injected_disk_full_failures_keep_verified_chunks_and_can_retry() { + let directory = tempfile::tempdir().unwrap(); + let mut library = Library::create(directory.path()).unwrap(); + let content = vec![7; CHUNK + 29]; + let item = object(&content, "asset"); + let wrong=library.sync_object_request("objects/write",json!({"object":item,"offset":0,"data":STANDARD.encode(&content[..CHUNK]),"chunkHash":files::digest(b"wrong")})); + assert_eq!(wrong.unwrap_err().0, "sync-hash-mismatch"); + assert_eq!(count(&library), 0); + write(&mut library, &item, &content, 0).unwrap(); + FAIL_WRITE.with(|f| f.set(true)); + assert_eq!( + write(&mut library, &item, &content, 1).unwrap_err().0, + "sync-disk-full" + ); + assert_eq!( + library + .sync_object_request("objects/info", json!({"object":item})) + .unwrap()["received"], + json!([0]) + ); + write(&mut library, &item, &content, 1).unwrap(); + FAIL_WRITE.with(|f| f.set(true)); + assert_eq!( + library + .sync_object_request("objects/finish", json!({"object":item})) + .unwrap_err() + .0, + "sync-disk-full" + ); + assert_eq!(count(&library), 0); + assert_eq!( + library + .sync_object_request("objects/info", json!({"object":item})) + .unwrap()["received"], + json!([0, 1]) + ); + library + .sync_object_request("objects/finish", json!({"object":item})) + .unwrap(); + assert_eq!(library.db.media("asset").unwrap().1, content); +} + +#[test] +fn document_objects_transfer_separately_and_empty_documents_are_valid() { + let directory = tempfile::tempdir().unwrap(); + let mut library = Library::create(directory.path()).unwrap(); + let content = "# 原文\n\n保留正文".as_bytes(); + let hash = files::digest(content); + let item = Object { + kind: "document".into(), + id: hash.clone(), + mime: "text/markdown".into(), + hash: hash.clone(), + size: content.len() as u64, + }; + write(&mut library, &item, content, 0).unwrap(); + library + .sync_object_request("objects/finish", json!({"object":item})) + .unwrap(); + assert_eq!(count(&library), 0); + assert_eq!( + std::fs::read(directory.path().join(".repeaty/objects").join(hash)).unwrap(), + content + ); + assert_eq!( + library + .sync_object_request("objects/info", json!({})) + .unwrap()["objects"][0]["hash"], + item.hash + ); + drop(library); + let mut library = Library::open(directory.path()).unwrap(); + assert_eq!( + library + .sync_object_request("objects/info", json!({})) + .unwrap()["objects"][0]["hash"], + item.hash + ); + library + .db + .connection + .execute( + "INSERT INTO retired_documents VALUES('retired','notes/retired.md',?1)", + [&item.hash], + ) + .unwrap(); + assert_eq!( + library + .sync_object_request("objects/info", json!({})) + .unwrap()["objects"][0]["kind"], + "document" + ); + let hash = files::digest(b""); + let empty = Object { + kind: "document".into(), + id: hash.clone(), + mime: "text/markdown".into(), + hash, + size: 0, + }; + library + .sync_object_request("objects/finish", json!({"object":empty})) + .unwrap(); + assert_eq!( + library + .sync_object_request("objects/info", json!({"object":empty})) + .unwrap()["complete"], + true + ); +} + +#[test] +fn bad_metadata_hash_paths_corrupt_complete_objects_and_hard_links_are_rejected() { + let directory = tempfile::tempdir().unwrap(); + let mut library = Library::create(directory.path()).unwrap(); + let item = object(b"payload", "asset"); + let mut unsafe_item = item.clone(); + unsafe_item.hash = "../../outside".into(); + assert_eq!( + library + .sync_object_request("objects/info", json!({"object":unsafe_item})) + .unwrap_err() + .0, + "sync-invalid-object" + ); + write(&mut library, &item, b"payload", 0).unwrap(); + library + .sync_object_request("objects/finish", json!({"object":item})) + .unwrap(); + let target = directory + .path() + .join(".repeaty/media") + .join(item.target().unwrap()); + std::fs::write(&target, b"corrupt").unwrap(); + assert_eq!( + library + .sync_object_request("objects/info", json!({"object":item})) + .unwrap_err() + .0, + "sync-hash-mismatch" + ); + std::fs::write(&target, b"payload").unwrap(); + std::fs::hard_link(&target, directory.path().join("duplicate")).unwrap(); + assert_eq!( + library + .sync_object_request("objects/info", json!({"object":item})) + .unwrap_err() + .0, + "sync-unsafe-object" + ); +} + +#[test] +fn public_assets_are_created_only_on_confirmed_sync_save_and_never_overwrite_external_bytes() { + let directory = tempfile::tempdir().unwrap(); + let mut library = Library::create(directory.path()).unwrap(); + library.sync_bind_device("test-device").unwrap(); + let group = uuid::Uuid::new_v4().to_string(); + library + .sync_request("start", json!({"groupId":group})) + .unwrap(); + let content = b"confirmed payload"; + let object = object(content, "media"); + write(&mut library, &object, content, 0).unwrap(); + library + .sync_object_request("objects/finish", json!({"object":object})) + .unwrap(); + let working = directory + .path() + .join("assets") + .join(object.target().unwrap()); + assert!(!working.exists()); + let metadata = json!({"id":"media","name":"synced file","type":object.mime,"size":content.len(),"createdAt":"2026-10-30T00:00:00.000Z"}); + library.sync_request("journal/receive",json!({"events":[{"protocol":1,"groupId":group,"deviceId":"remote","sequence":1,"id":"remote:1","clock":{"remote":0},"changes":[{"store":"media","id":"media","before":null,"after":metadata,"position":0}]}]})).unwrap(); + assert!(!working.exists()); + let request:crate::library::Save=serde_json::from_value(json!({"operationId":uuid::Uuid::new_v4().to_string(),"expectedRevision":0,"patches":[{"store":"media","id":"media","value":metadata,"position":0}],"documents":[],"reason":null,"sync":{"expectedClock":{},"clock":{"remote":1},"appliedIds":["remote:1"],"values":{},"resolutions":[],"retiredMedia":{}},"consumeCardDraft":null})).unwrap(); + library.save(request.clone()).unwrap(); + assert_eq!(std::fs::read(&working).unwrap(), content); + std::fs::write(&working, b"external edit").unwrap(); + library.save(request.clone()).unwrap(); + assert_eq!(std::fs::read(&working).unwrap(), b"external edit"); + let mut next = request; + next.operation_id = uuid::Uuid::new_v4().to_string(); + next.expected_revision = 1; + assert_eq!(library.save(next).unwrap_err().0, "working-file-changed"); + assert_eq!(std::fs::read(&working).unwrap(), b"external edit"); +} + +#[cfg(unix)] +#[test] +fn staged_directory_and_file_symlinks_are_rejected() { + use std::os::unix::fs::symlink; + let directory = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let mut library = Library::create(directory.path()).unwrap(); + let item = object(b"payload", "asset"); + symlink( + outside.path(), + directory.path().join(".repeaty/staging").join(item.stage()), + ) + .unwrap(); + assert!(write(&mut library, &item, b"payload", 0).is_err()); + assert!(std::fs::read_dir(outside.path()).unwrap().next().is_none()); +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 87046a4..c98f8d1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -73,6 +73,45 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.21", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -352,7 +391,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -361,6 +400,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -937,6 +985,20 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.9" @@ -2682,6 +2744,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.3.0" @@ -2799,6 +2867,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "notify" version = "8.2.1" @@ -3197,6 +3275,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3694,6 +3781,19 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3788,13 +3888,16 @@ dependencies = [ "clipboard-rs", "libc", "open", + "rcgen", "repeaty-workspace", "reqwest", "rmcp", "rusqlite", + "rustls", "semver", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -3805,6 +3908,7 @@ dependencies = [ "tauri-plugin-window-state", "tempfile", "tokio", + "tokio-rustls", "tokio-util", "uuid", "windows 0.59.0", @@ -3982,6 +4086,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.5" @@ -4689,6 +4802,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -6547,6 +6671,24 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.21", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -6563,6 +6705,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.3" @@ -6583,7 +6735,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.9", - "synstructure", + "synstructure 0.14.0", ] [[package]] @@ -6694,7 +6846,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.9", - "synstructure", + "synstructure 0.14.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ca1fa7..cdc7029 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,6 +17,10 @@ tokio-util = { version = "0.7", features = ["rt"] } axum = "0.8.9" rmcp = { version = "=3.5.1", default-features = false, features = ["server", "transport-streamable-http-server", "client", "transport-streamable-http-client-reqwest", "transport-io"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "json", "multipart"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +rcgen = { version = "0.14", default-features = false, features = ["ring", "crypto"] } +sha2 = "0.10" base64 = "0.22" open = "5" tauri-plugin-dialog = "2" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 7e9b429..c37750a 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -26,6 +26,7 @@ fn main() { "desktop_update_cancel", "desktop_update_install", "desktop_diagnostics", + "device_sync_request", "workspace_status", "workspace_choose", "workspace_recent", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index c03a1f6..755e06a 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -30,6 +30,7 @@ "allow-desktop-update-cancel", "allow-desktop-update-install", "allow-desktop-diagnostics", + "allow-device-sync-request", "allow-workspace-status", "allow-workspace-choose", "allow-workspace-recent", diff --git a/src-tauri/src/device_sync.rs b/src-tauri/src/device_sync.rs new file mode 100644 index 0000000..030176b --- /dev/null +++ b/src-tauri/src/device_sync.rs @@ -0,0 +1,991 @@ +//! Explicit LAN pairing and authenticated, bounded RPC. Library semantics belong to Handler. +//! Device keys and grants live in app_data_dir; the network never opens a learning database. +#[path = "device_sync/identity.rs"] +mod identity; +#[path = "device_sync/transport.rs"] +mod transport; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use identity::{Identity, Store}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{HashMap, VecDeque}, + future::Future, + path::Path, + sync::{Arc, Mutex}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::{ + net::{TcpListener, TcpStream}, + sync::{Semaphore, oneshot}, +}; +use tokio_rustls::TlsAcceptor; +use tokio_util::sync::CancellationToken; +use transport::{IO_TIMEOUT, MAX_FRAME, PAIR_TIMEOUT, SMALL_FRAME}; + +pub type Result = std::result::Result; +pub type Handler = Arc Result + Send + Sync>; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Scope { + pub token: String, + pub group_id: String, +} + +#[derive(Clone)] +struct Peer { + id: String, + name: String, + address: String, + cert: Vec, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Invitation { + pub version: u32, + pub group_id: String, + pub epoch: String, + pub device_id: String, + pub name: String, + pub address: String, + pub certificate: String, + pub ticket: String, + pub expires_at: u64, +} +#[derive(Clone)] +struct Context { + scope: Scope, + epoch: String, + cancel: CancellationToken, +} +struct Pending { + peer: Peer, + expires: u64, + answer: oneshot::Sender, +} +struct PendingGuard { + service: std::sync::Weak, + id: String, +} +impl Drop for PendingGuard { + fn drop(&mut self) { + if let Some(service) = self.service.upgrade() + && let Ok(mut state) = service.lock() + { + state.pending.remove(&self.id); + } + } +} +struct Listener { + address: String, + cancel: CancellationToken, +} +struct State { + store: Store, + scope: Option, + epoch: String, + operations: CancellationToken, + listener: Option, + paused: bool, + invitations: HashMap, + pending: HashMap, + errors: VecDeque, +} +pub struct SyncService { + identity: Identity, + state: Mutex, + handler: Handler, + incoming: Arc, + outgoing: Arc, +} +fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} +fn id() -> String { + uuid::Uuid::new_v4().to_string() +} +fn name_valid(value: &str) -> bool { + !value.trim().is_empty() + && value.len() <= 80 + && !value.chars().any(|c| { + c.is_control() + || ('\u{202a}'..='\u{202e}').contains(&c) + || ('\u{2066}'..='\u{2069}').contains(&c) + }) +} +fn group_valid(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-_:./".contains(&c)) +} +fn method_valid(value: &str) -> bool { + matches!( + value, + "journal/status" + | "journal/list" + | "journal/receive" + | "objects/info" + | "objects/read" + | "objects/write" + | "objects/finish" + ) +} +fn public_error(code: &str) -> String { + match code { + "sync-unavailable" + | "sync-workspace-changed" + | "sync-paused" + | "sync-not-listening" + | "sync-invalid-address" + | "sync-listen-failed" + | "sync-cancelled" + | "sync-timeout" + | "sync-busy" + | "sync-invalid-message" + | "sync-message-limit" + | "sync-incompatible" + | "sync-authentication-failed" + | "sync-unreachable" + | "sync-disconnected" + | "sync-pairing-expired" + | "sync-pairing-rejected" + | "sync-unknown-peer" + | "sync-invalid-invitation" + | "sync-invalid-certificate" + | "sync-peer-limit" + | "sync-storage-error" + | "sync-disk-full" + | "sync-hash-mismatch" + | "sync-invalid-object" + | "sync-object-missing" + | "sync-object-limit" + | "sync-object-incomplete" + | "sync-object-conflict" + | "sync-unsafe-object" + | "sync-conflict" + | "sync-invalid-data" + | "sync-identity-error" + | "sync-tls-error" + | "sync-method-denied" => code.into(), + _ => "sync-operation-failed".into(), + } +} +impl State { + fn current(&self, context: &Context) -> Result<()> { + if self.scope.as_ref() != Some(&context.scope) || self.epoch != context.epoch { + return Err("sync-workspace-changed".into()); + } + if context.cancel.is_cancelled() { + return Err("sync-cancelled".into()); + } + if self.paused { + return Err("sync-paused".into()); + } + Ok(()) + } + fn cancel_operations(&mut self) { + self.operations.cancel(); + self.operations = CancellationToken::new(); + self.invitations.clear(); + self.pending.clear(); + } + fn stop(&mut self) { + self.cancel_operations(); + if let Some(listener) = self.listener.take() { + listener.cancel.cancel(); + } + } +} + +impl SyncService { + pub fn new(directory: &Path, handler: Handler) -> Result> { + let (store, identity) = Store::open(directory)?; + // Validate the persisted identity before exposing an enabled service. + transport::server(&identity, &[])?; + Ok(Arc::new(Self { + identity, + handler, + incoming: Arc::new(Semaphore::new(8)), + outgoing: Arc::new(Semaphore::new(4)), + state: Mutex::new(State { + store, + scope: None, + epoch: id(), + operations: CancellationToken::new(), + listener: None, + paused: false, + invitations: HashMap::new(), + pending: HashMap::new(), + errors: VecDeque::new(), + }), + })) + } + fn lock(&self) -> Result> { + self.state.lock().map_err(|_| "sync-unavailable".into()) + } + fn context(&self) -> Result { + let state = self.lock()?; + if state.paused { + return Err("sync-paused".into()); + } + Ok(Context { + scope: state.scope.clone().ok_or("sync-unavailable")?, + epoch: state.epoch.clone(), + cancel: state.operations.child_token(), + }) + } + fn context_for(&self, expected: &Scope) -> Result { + let state = self.lock()?; + if state.scope.as_ref() != Some(expected) { + return Err("sync-workspace-changed".into()); + } + if state.paused { + return Err("sync-paused".into()); + } + Ok(Context { + scope: expected.clone(), + epoch: state.epoch.clone(), + cancel: state.operations.child_token(), + }) + } + fn current(&self, context: &Context) -> Result<()> { + self.lock()?.current(context) + } + fn peer(&self, context: &Context, peer_id: &str) -> Result { + let state = self.lock()?; + state.current(context)?; + state + .store + .peers(&context.scope.group_id)? + .into_iter() + .find(|p| p.id == peer_id) + .ok_or("sync-unknown-peer".into()) + } + fn record(&self, context: &Context, code: &str) { + if let Ok(mut state) = self.lock() { + if state.scope.as_ref() != Some(&context.scope) || state.epoch != context.epoch { + return; + } + let code = public_error(code); + if state.errors.back() != Some(&code) { + state.errors.push_back(code); + } + while state.errors.len() > 8 { + state.errors.pop_front(); + } + } + } + pub fn activate(&self, scope: Scope) -> Result { + if scope.token.is_empty() || scope.token.len() > 256 || !group_valid(&scope.group_id) { + return Err("sync-invalid-data".into()); + } + { + let mut state = self.lock()?; + if state.scope.as_ref() != Some(&scope) { + state.stop(); + state.scope = Some(scope); + state.epoch = id(); + state.paused = false; + state.errors.clear(); + } + } + self.status() + } + pub fn deactivate(&self) { + if let Ok(mut state) = self.lock() { + state.stop(); + state.scope = None; + state.epoch = id(); + state.errors.clear(); + } + } + pub fn status(&self) -> Result { + let mut state = self.lock()?; + state.pending.retain(|_, p| p.expires > now()); + state.invitations.retain(|_, expiry| *expiry > now()); + let group = state.scope.as_ref().map(|s| s.group_id.clone()); + let peers = match &group { + Some(group) => state.store.peers(group)?, + None => vec![], + }; + Ok( + json!({"deviceId":self.identity.id,"name":self.identity.name,"enabled":state.listener.is_some(),"listenAddress":state.listener.as_ref().map(|l|l.address.clone()),"group":group,"paused":state.paused, + "peers":peers.iter().map(|p|json!({"id":p.id,"name":p.name,"address":p.address,"status":if state.paused {"paused"} else {"paired"}})).collect::>(), + "pending":state.pending.iter().map(|(id,p)|json!({"id":id,"name":p.peer.name,"requestCode":&p.peer.id[..12]})).collect::>(),"errors":state.errors}), + ) + } + /// Diagnostics deliberately omit identity, address, certificate, scope and library content. + pub fn diagnostics(&self) -> Result { + let state = self.lock()?; + let peers = match &state.scope { + Some(scope) => state.store.peers(&scope.group_id)?.len(), + None => 0, + }; + Ok( + json!({"protocol":1,"enabled":state.listener.is_some(),"paused":state.paused,"peerCount":peers,"pendingCount":state.pending.len(),"errors":state.errors}), + ) + } + #[cfg(test)] + pub async fn listen(self: &Arc, address: &str) -> Result { + let context = self.context()?; + self.listen_context(context, address).await + } + pub async fn listen_for(self: &Arc, expected: Scope, address: &str) -> Result { + self.listen_context(self.context_for(&expected)?, address) + .await + } + async fn listen_context(self: &Arc, context: Context, address: &str) -> Result { + let address = transport::address(address)?; + let listener = TcpListener::bind(address) + .await + .map_err(|_| "sync-listen-failed")?; + let actual = listener + .local_addr() + .map_err(|_| "sync-listen-failed")? + .to_string(); + self.current(&context)?; + let cancel = CancellationToken::new(); + { + let mut state = self.lock()?; + state.current(&context)?; + // Rebind closes the old listener and every connection before publishing the new address. + state.stop(); + state.listener = Some(Listener { + address: actual, + cancel: cancel.clone(), + }); + } + let weak = Arc::downgrade(self); + let listener_epoch = context.epoch; + tokio::spawn(async move { + loop { + let accepted = tokio::select! { biased; _ = cancel.cancelled() => break, accepted = listener.accept() => accepted }; + let Ok((socket, _)) = accepted else { + break; + }; + let Some(service) = weak.upgrade() else { + break; + }; + let Ok(permit) = service.incoming.clone().try_acquire_owned() else { + continue; + }; + let Ok(context) = service.context() else { + continue; + }; + if cancel.is_cancelled() || context.epoch != listener_epoch { + continue; + } + tokio::spawn(async move { + let _permit = permit; + let result = bounded( + &context.cancel, + PAIR_TIMEOUT, + service.serve(context.clone(), socket), + ) + .await; + if let Err(error) = result { + service.record(&context, &error); + } + }); + } + }); + self.status() + } + pub fn stop(&self) -> Result { + self.lock()?.stop(); + self.status() + } + pub fn cancel(&self) -> Result { + self.lock()?.cancel_operations(); + self.status() + } + pub fn pause(&self, paused: bool) -> Result { + { + let mut state = self.lock()?; + state.cancel_operations(); + state.paused = paused; + } + self.status() + } + pub fn revoke(&self, peer_id: &str) -> Result { + let mut state = self.lock()?; + let group = state + .scope + .as_ref() + .ok_or("sync-unavailable")? + .group_id + .clone(); + state.store.remove(&group, peer_id)?; + state.cancel_operations(); + drop(state); + self.status() + } + /// Only an explicit host action can obtain a shareable invitation containing a ticket. + pub fn invite(&self) -> Result { + let context = self.context()?; + let mut random = [0u8; 32]; + rustls::crypto::ring::default_provider() + .secure_random + .fill(&mut random) + .map_err(|_| "sync-identity-error")?; + let ticket = URL_SAFE_NO_PAD.encode(random); + let mut state = self.lock()?; + state.current(&context)?; + let address = state + .listener + .as_ref() + .ok_or("sync-not-listening")? + .address + .clone(); + state.invitations.retain(|_, expiry| *expiry > now()); + if state.invitations.len() >= 4 { + return Err("sync-busy".into()); + } + let expires_at = now() + PAIR_TIMEOUT.as_secs(); + state + .invitations + .insert(digest(ticket.as_bytes()), expires_at); + Ok(Invitation { + version: 1, + group_id: context.scope.group_id, + epoch: context.epoch, + device_id: self.identity.id.clone(), + name: self.identity.name.clone(), + address, + certificate: URL_SAFE_NO_PAD.encode(&self.identity.cert), + ticket, + expires_at, + }) + } + pub fn approve(&self, request_id: &str, approve: bool) -> Result { + let context = self.context()?; + let mut state = self.lock()?; + state.current(&context)?; + let pending = state + .pending + .remove(request_id) + .ok_or("sync-pairing-expired")?; + if pending.expires <= now() { + return Err("sync-pairing-expired".into()); + } + if approve { + if state.store.peers(&context.scope.group_id)?.len() >= 32 { + return Err("sync-peer-limit".into()); + } + state.store.save(&context.scope.group_id, &pending.peer)?; + } + let _ = pending.answer.send(approve); + drop(state); + self.status() + } + /// Both peers listen explicitly. The incoming address is never inferred from public services. + #[cfg(test)] + pub async fn join(self: &Arc, invitation: Invitation) -> Result { + let context = self.context()?; + self.join_context(context, invitation).await + } + pub async fn join_for( + self: &Arc, + expected: Scope, + invitation: Invitation, + ) -> Result { + self.join_context(self.context_for(&expected)?, invitation) + .await + } + async fn join_context( + self: &Arc, + context: Context, + invitation: Invitation, + ) -> Result { + let result = bounded( + &context.cancel, + PAIR_TIMEOUT, + self.join_inner(&context, invitation), + ) + .await; + if let Err(error) = &result { + self.record(&context, error); + } + result + } + async fn join_inner(&self, context: &Context, invitation: Invitation) -> Result { + if invitation.version != 1 { + return Err("sync-incompatible".into()); + } + if invitation.group_id != context.scope.group_id + || invitation.expires_at <= now() + || invitation.expires_at > now() + PAIR_TIMEOUT.as_secs() + 30 + || invitation.epoch.len() > 128 + || invitation.ticket.len() != 43 + || invitation.certificate.len() > 12000 + { + return Err("sync-invalid-invitation".into()); + } + let cert = URL_SAFE_NO_PAD + .decode(&invitation.certificate) + .map_err(|_| "sync-invalid-invitation")?; + let peer = Peer { + id: invitation.device_id, + name: invitation.name, + address: invitation.address, + cert, + }; + validate_peer(&peer)?; + if peer.id == self.identity.id { + return Err("sync-invalid-invitation".into()); + } + let address = self + .lock()? + .listener + .as_ref() + .ok_or("sync-not-listening")? + .address + .clone(); + let _permit = self + .outgoing + .clone() + .try_acquire_owned() + .map_err(|_| "sync-busy")?; + let mut stream = bounded( + &context.cancel, + IO_TIMEOUT, + transport::connect(&self.identity, &peer, false), + ) + .await?; + let request = Request::Pair { + version: 1, + group_id: context.scope.group_id.clone(), + target_epoch: invitation.epoch.clone(), + source_epoch: context.epoch.clone(), + ticket: invitation.ticket, + certificate: URL_SAFE_NO_PAD.encode(&self.identity.cert), + name: self.identity.name.clone(), + address, + }; + bounded( + &context.cancel, + IO_TIMEOUT, + transport::write(&mut stream, &request, SMALL_FRAME), + ) + .await?; + let reply: Reply = transport::read(&mut stream, SMALL_FRAME).await?; + match reply { + Reply::Paired { + group_id, + source_epoch, + target_epoch, + } if group_id == context.scope.group_id + && source_epoch == context.epoch + && target_epoch == invitation.epoch => + { + self.current(context)?; + let state = self.lock()?; + state.current(context)?; + if state.store.peers(&context.scope.group_id)?.len() >= 32 { + return Err("sync-peer-limit".into()); + } + state.store.save(&context.scope.group_id, &peer)?; + drop(state); + self.status() + } + Reply::Error { code } => Err(public_error(&code)), + _ => Err("sync-invalid-message".into()), + } + } + #[cfg(test)] + pub async fn peer_request( + self: &Arc, + peer_id: &str, + method: &str, + payload: Value, + ) -> Result { + if !method_valid(method) { + return Err("sync-method-denied".into()); + } + let context = self.context()?; + self.peer_request_context(context, peer_id, method, payload) + .await + } + pub async fn peer_request_for( + self: &Arc, + expected: Scope, + peer_id: &str, + method: &str, + payload: Value, + ) -> Result { + if !method_valid(method) { + return Err("sync-method-denied".into()); + } + self.peer_request_context(self.context_for(&expected)?, peer_id, method, payload) + .await + } + async fn peer_request_context( + self: &Arc, + context: Context, + peer_id: &str, + method: &str, + payload: Value, + ) -> Result { + let result = bounded( + &context.cancel, + IO_TIMEOUT, + self.request_inner(&context, peer_id, method, payload), + ) + .await; + if let Err(error) = &result { + self.record(&context, error); + } + result + } + async fn request_inner( + &self, + context: &Context, + peer_id: &str, + method: &str, + payload: Value, + ) -> Result { + let _permit = self + .outgoing + .clone() + .try_acquire_owned() + .map_err(|_| "sync-busy")?; + let peer = self.peer(context, peer_id)?; + let mut stream = transport::connect(&self.identity, &peer, true).await?; + transport::write( + &mut stream, + &Request::Hello { + version: 1, + group_id: context.scope.group_id.clone(), + }, + SMALL_FRAME, + ) + .await?; + let hello: Reply = transport::read(&mut stream, SMALL_FRAME).await?; + let target_epoch = match hello { + Reply::Hello { group_id, epoch } if group_id == context.scope.group_id => epoch, + Reply::Error { code } => return Err(public_error(&code)), + _ => return Err("sync-invalid-message".into()), + }; + let request_id = id(); + transport::write( + &mut stream, + &Request::Call { + version: 1, + group_id: context.scope.group_id.clone(), + target_epoch: target_epoch.clone(), + source_epoch: context.epoch.clone(), + id: request_id.clone(), + method: method.into(), + payload, + }, + MAX_FRAME, + ) + .await?; + let reply: Reply = transport::read(&mut stream, MAX_FRAME).await?; + self.peer(context, peer_id)?; + match reply { + Reply::Result { + group_id, + source_epoch, + target_epoch: reply_target, + id, + payload, + } if group_id == context.scope.group_id + && source_epoch == context.epoch + && reply_target == target_epoch + && id == request_id => + { + Ok(payload) + } + Reply::Error { code } => Err(public_error(&code)), + _ => Err("sync-invalid-message".into()), + } + } + async fn serve(self: &Arc, context: Context, socket: TcpStream) -> Result<()> { + let peers = self.lock()?.store.peers(&context.scope.group_id)?; + let config = transport::server(&self.identity, &peers)?; + let mut stream = bounded(&context.cancel, IO_TIMEOUT, async { + TlsAcceptor::from(config) + .accept(socket) + .await + .map_err(|_| "sync-authentication-failed".into()) + }) + .await?; + if stream.get_ref().1.alpn_protocol() != Some(b"repeaty-sync/1".as_slice()) { + return Err("sync-incompatible".into()); + } + let authenticated = stream + .get_ref() + .1 + .peer_certificates() + .and_then(|chain| chain.first()) + .map(|cert| digest(cert.as_ref())); + let request: Request = bounded( + &context.cancel, + IO_TIMEOUT, + transport::read(&mut stream, SMALL_FRAME), + ) + .await?; + let result = async { + match request { + Request::Pair { version, group_id, target_epoch, source_epoch, ticket, certificate, name, address } if authenticated.is_none() => { + use tokio::io::AsyncReadExt; + tokio::select! { + result = self.pair(&context, version, &group_id, &target_epoch, &source_epoch, &ticket, &certificate, &name, &address) => result, + _ = stream.read_u8() => Err("sync-disconnected".into()), + } + } + Request::Hello { version, group_id } => { + let peer_id = authenticated.ok_or("sync-authentication-failed")?; + if version != 1 { return Err("sync-incompatible".into()); } + if group_id != context.scope.group_id { return Err("sync-workspace-changed".into()); } + self.peer(&context, &peer_id)?; + transport::write(&mut stream, &Reply::Hello { group_id, epoch: context.epoch.clone() }, SMALL_FRAME).await?; + let call: Request = bounded(&context.cancel, IO_TIMEOUT, transport::read(&mut stream, MAX_FRAME)).await?; + self.dispatch(&context, &peer_id, call).await + } + _ => Err("sync-authentication-failed".into()), + } + }.await; + let reply = match result { + Ok(reply) => reply, + Err(code) => { + self.record(&context, &code); + Reply::Error { + code: public_error(&code), + } + } + }; + bounded( + &context.cancel, + IO_TIMEOUT, + transport::write(&mut stream, &reply, MAX_FRAME), + ) + .await + } + #[allow(clippy::too_many_arguments)] + async fn pair( + self: &Arc, + context: &Context, + version: u32, + group: &str, + target_epoch: &str, + source_epoch: &str, + ticket: &str, + certificate: &str, + name: &str, + address: &str, + ) -> Result { + if version != 1 { + return Err("sync-incompatible".into()); + } + self.current(context)?; + if group != context.scope.group_id + || target_epoch != context.epoch + || source_epoch.len() > 128 + || source_epoch.is_empty() + { + return Err("sync-workspace-changed".into()); + } + if certificate.len() > 12000 || ticket.len() != 43 { + return Err("sync-invalid-message".into()); + } + let cert = URL_SAFE_NO_PAD + .decode(certificate) + .map_err(|_| "sync-invalid-certificate")?; + let peer = Peer { + id: digest(&cert), + cert, + name: name.into(), + address: address.into(), + }; + validate_peer(&peer)?; + if peer.id == self.identity.id { + return Err("sync-authentication-failed".into()); + } + let (send, receive) = oneshot::channel(); + let pending_id = id(); + { + let mut state = self.lock()?; + state.current(context)?; + let expiry = state + .invitations + .remove(&digest(ticket.as_bytes())) + .ok_or("sync-pairing-expired")?; + if expiry <= now() { + return Err("sync-pairing-expired".into()); + } + if state.pending.len() >= 8 { + return Err("sync-busy".into()); + } + state.pending.insert( + pending_id.clone(), + Pending { + peer, + expires: expiry, + answer: send, + }, + ); + } + let _pending = PendingGuard { + service: Arc::downgrade(self), + id: pending_id, + }; + let approved = receive.await.unwrap_or(false); + // A dropped, rejected, expired or cancelled approval never becomes an authorization. + self.current(context)?; + if !approved { + return Err("sync-pairing-rejected".into()); + } + Ok(Reply::Paired { + group_id: group.into(), + source_epoch: source_epoch.into(), + target_epoch: target_epoch.into(), + }) + } + async fn dispatch(&self, context: &Context, peer_id: &str, request: Request) -> Result { + let Request::Call { + version, + group_id, + target_epoch, + source_epoch, + id, + method, + payload, + } = request + else { + return Err("sync-invalid-message".into()); + }; + if version != 1 { + return Err("sync-incompatible".into()); + } + if group_id != context.scope.group_id || target_epoch != context.epoch { + return Err("sync-workspace-changed".into()); + } + if source_epoch.is_empty() || source_epoch.len() > 128 || id.is_empty() || id.len() > 128 { + return Err("sync-invalid-message".into()); + } + if !method_valid(&method) { + return Err("sync-method-denied".into()); + } + self.peer(context, peer_id)?; + let handler = self.handler.clone(); + let scope = context.scope.clone(); + let payload = tokio::task::spawn_blocking(move || handler(scope, &method, payload)) + .await + .map_err(|_| "sync-operation-failed")??; + self.peer(context, peer_id)?; + Ok(Reply::Result { + group_id, + target_epoch, + source_epoch, + id, + payload, + }) + } +} +impl Drop for SyncService { + fn drop(&mut self) { + if let Ok(state) = self.state.get_mut() { + state.stop(); + } + } +} + +fn validate_peer(peer: &Peer) -> Result<()> { + if !name_valid(&peer.name) + || peer.cert.len() > 8192 + || peer.cert.is_empty() + || digest(&peer.cert) != peer.id + { + return Err("sync-invalid-certificate".into()); + } + let address = transport::address(&peer.address)?; + if address.port() == 0 { + return Err("sync-invalid-address".into()); + } + let mut roots = rustls::RootCertStore::empty(); + roots + .add(rustls::pki_types::CertificateDer::from(peer.cert.clone())) + .map_err(|_| "sync-invalid-certificate".into()) +} +async fn bounded( + cancel: &CancellationToken, + duration: Duration, + future: impl Future>, +) -> Result { + tokio::select! {biased; _=cancel.cancelled()=>Err("sync-cancelled".into()), result=tokio::time::timeout(duration,future)=>result.map_err(|_|"sync-timeout")?} +} +#[derive(Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum Request { + Pair { + version: u32, + group_id: String, + target_epoch: String, + source_epoch: String, + ticket: String, + certificate: String, + name: String, + address: String, + }, + Hello { + version: u32, + group_id: String, + }, + Call { + version: u32, + group_id: String, + target_epoch: String, + source_epoch: String, + id: String, + method: String, + payload: Value, + }, +} +#[derive(Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum Reply { + Hello { + group_id: String, + epoch: String, + }, + Paired { + group_id: String, + source_epoch: String, + target_epoch: String, + }, + Result { + group_id: String, + source_epoch: String, + target_epoch: String, + id: String, + payload: Value, + }, + Error { + code: String, + }, +} + +#[cfg(test)] +#[path = "device_sync/tests.rs"] +mod tests; diff --git a/src-tauri/src/device_sync/identity.rs b/src-tauri/src/device_sync/identity.rs new file mode 100644 index 0000000..b0e98f6 --- /dev/null +++ b/src-tauri/src/device_sync/identity.rs @@ -0,0 +1,194 @@ +//! Host-owned identity and peer grants. Nothing here is stored in a learning directory. +use super::{Peer, Result, digest}; +use rusqlite::{Connection, OptionalExtension, params}; +use std::{ + fs::{File, OpenOptions}, + path::Path, +}; + +#[cfg(windows)] +mod windows_permissions; + +pub(super) struct Identity { + pub id: String, + pub name: String, + pub cert: Vec, + pub key: Vec, +} +pub(super) struct Store(Connection); +fn disk(_: T) -> String { + "sync-storage-error".into() +} + +fn protect(file: &File) -> Result<()> { + let meta = file.metadata().map_err(disk)?; + if !meta.is_file() || meta.file_type().is_symlink() { + return Err(disk(())); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if meta.nlink() != 1 { + return Err(disk(())); + } + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(disk)?; + } + #[cfg(windows)] + { + use std::os::windows::{fs::MetadataExt, io::AsRawHandle}; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + if meta.file_attributes() & 0x400 != 0 { + return Err(disk(())); + } + // SAFETY: the handle is live and the initialized result buffer is writable. + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 + || info.nNumberOfLinks != 1 + { + return Err(disk(())); + } + windows_permissions::restrict(file).map_err(disk)?; + } + Ok(()) +} + +impl Store { + pub fn open(directory: &Path) -> Result<(Self, Identity)> { + std::fs::create_dir_all(directory).map_err(disk)?; + let directory = directory.join("device-sync"); + match std::fs::create_dir(&directory) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(disk(error)), + } + let metadata = std::fs::symlink_metadata(&directory).map_err(disk)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(disk(())); + } + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let handle = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY) + .open(&directory) + .map_err(disk)?; + handle + .set_permissions(std::fs::Permissions::from_mode(0o700)) + .map_err(disk)?; + } + #[cfg(windows)] + { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + use windows_sys::Win32::{Foundation::GENERIC_READ, Storage::FileSystem::WRITE_DAC}; + if metadata.file_attributes() & 0x400 != 0 { + return Err(disk(())); + } + let handle = OpenOptions::new() + .read(true) + .custom_flags(0x02000000 | 0x00200000) + .access_mode(GENERIC_READ | WRITE_DAC) + .open(&directory) + .map_err(disk)?; + windows_permissions::restrict(&handle).map_err(disk)?; + } + let path = directory.join("device-sync.sqlite3"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::{ + Foundation::{GENERIC_READ, GENERIC_WRITE}, + Storage::FileSystem::WRITE_DAC, + }; + options + .custom_flags(0x00200000) + .access_mode(GENERIC_READ | GENERIC_WRITE | WRITE_DAC); + } + let file = options.open(&path).map_err(disk)?; + protect(&file)?; + let mut connection = Connection::open(&path).map_err(disk)?; + connection + .busy_timeout(std::time::Duration::from_secs(5)) + .map_err(disk)?; + // DELETE avoids a persistent WAL containing key material beside the protected database. + connection.execute_batch("PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON; + CREATE TABLE IF NOT EXISTS identity(slot INTEGER PRIMARY KEY CHECK(slot=1),name TEXT NOT NULL,cert BLOB NOT NULL,key BLOB NOT NULL); + CREATE TABLE IF NOT EXISTS peers(group_id TEXT NOT NULL,id TEXT NOT NULL,name TEXT NOT NULL,address TEXT NOT NULL,cert BLOB NOT NULL,PRIMARY KEY(group_id,id));").map_err(disk)?; + let tx = connection.transaction().map_err(disk)?; + let stored: Option<(String, Vec, Vec)> = tx + .query_row("SELECT name,cert,key FROM identity WHERE slot=1", [], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + }) + .optional() + .map_err(disk)?; + let (name, cert, key) = match stored { + Some(stored) => stored, + None => { + let generated = rcgen::generate_simple_self_signed(vec!["repeaty-sync".into()]) + .map_err(|_| "sync-identity-error")?; + let cert = generated.cert.der().to_vec(); + let key = generated.signing_key.serialize_der(); + let name = format!("Repeaty {}", &digest(&cert)[..8]); + tx.execute( + "INSERT INTO identity VALUES(1,?1,?2,?3)", + params![name, cert, key], + ) + .map_err(disk)?; + (name, cert, key) + } + }; + tx.commit().map_err(disk)?; + if cert.len() > 8192 || key.len() > 8192 { + return Err("sync-identity-error".into()); + } + Ok(( + Self(connection), + Identity { + id: digest(&cert), + name, + cert, + key, + }, + )) + } + pub fn peers(&self, group: &str) -> Result> { + let mut stmt = self + .0 + .prepare("SELECT id,name,address,cert FROM peers WHERE group_id=?1 ORDER BY id") + .map_err(disk)?; + stmt.query_map([group], |r| { + Ok(Peer { + id: r.get(0)?, + name: r.get(1)?, + address: r.get(2)?, + cert: r.get(3)?, + }) + }) + .map_err(disk)? + .collect::, _>>() + .map_err(disk) + } + pub fn save(&self, group: &str, peer: &Peer) -> Result<()> { + self.0.execute("INSERT INTO peers VALUES(?1,?2,?3,?4,?5) ON CONFLICT(group_id,id) DO UPDATE SET name=excluded.name,address=excluded.address,cert=excluded.cert", params![group,peer.id,peer.name,peer.address,peer.cert]).map_err(disk)?; + Ok(()) + } + pub fn remove(&self, group: &str, id: &str) -> Result<()> { + self.0 + .execute( + "DELETE FROM peers WHERE group_id=?1 AND id=?2", + params![group, id], + ) + .map_err(disk)?; + Ok(()) + } +} diff --git a/src-tauri/src/device_sync/tests.rs b/src-tauri/src/device_sync/tests.rs new file mode 100644 index 0000000..6bedc39 --- /dev/null +++ b/src-tauri/src/device_sync/tests.rs @@ -0,0 +1,548 @@ +use super::*; +use tokio::io::AsyncWriteExt; + +async fn service(directory: &Path) -> Arc { + let service = SyncService::new( + directory, + Arc::new(|scope, method, payload| { + Ok(json!({"group":scope.group_id,"method":method,"payload":payload})) + }), + ) + .unwrap(); + service + .activate(Scope { + token: id(), + group_id: "test-group".into(), + }) + .unwrap(); + service.listen("127.0.0.1:0").await.unwrap(); + service +} +async fn pending(service: &SyncService) -> String { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(id) = service.status().unwrap()["pending"][0]["id"].as_str() { + return id.to_owned(); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("pending pairing") +} +async fn pair(a: &Arc, b: &Arc) -> Invitation { + let invitation = a.invite().unwrap(); + let client = b.clone(); + let copy = invitation.clone(); + let joining = tokio::spawn(async move { client.join(copy).await }); + let request = pending(a).await; + assert!(a.status().unwrap()["peers"].as_array().unwrap().is_empty()); + a.approve(&request, true).unwrap(); + joining.await.unwrap().unwrap(); + invitation +} + +#[tokio::test] +async fn real_tls_pairing_is_manual_scoped_mutual_and_persistent() { + let da = tempfile::tempdir().unwrap(); + let db = tempfile::tempdir().unwrap(); + let a = service(da.path()).await; + let b = service(db.path()).await; + assert_ne!(a.identity.id, b.identity.id); + let old_id = a.identity.id.clone(); + let invitation = pair(&a, &b).await; + let result = b + .peer_request(&a.identity.id, "journal/status", json!({"round":7})) + .await + .unwrap(); + assert_eq!(result["payload"]["round"], 7); + let result = a + .peer_request(&b.identity.id, "objects/info", json!({"hash":"test"})) + .await + .unwrap(); + assert_eq!(result["method"], "objects/info"); + assert_eq!( + b.join(invitation).await.unwrap_err(), + "sync-pairing-expired" + ); + assert_eq!( + b.peer_request(&a.identity.id, "filesystem/read", Value::Null) + .await + .unwrap_err(), + "sync-method-denied" + ); + let diagnostic = a.diagnostics().unwrap().to_string(); + for private in [ + &a.identity.id, + &a.identity.name, + &URL_SAFE_NO_PAD.encode(&a.identity.cert), + "127.0.0.1", + ] { + assert!(!diagnostic.contains(private)); + } + a.stop().unwrap(); + b.stop().unwrap(); + drop(a); + drop(b); + let reopened = SyncService::new(da.path(), Arc::new(|_, _, _| Ok(Value::Null))).unwrap(); + assert_eq!(reopened.identity.id, old_id); + assert_eq!(reopened.status().unwrap()["enabled"], false); + reopened + .activate(Scope { + token: id(), + group_id: "test-group".into(), + }) + .unwrap(); + assert_eq!( + reopened.status().unwrap()["peers"] + .as_array() + .unwrap() + .len(), + 1 + ); + reopened + .activate(Scope { + token: id(), + group_id: "other-group".into(), + }) + .unwrap(); + assert!( + reopened.status().unwrap()["peers"] + .as_array() + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn real_tls_rejection_expiry_wrong_pin_and_anonymous_calls_fail() { + let da = tempfile::tempdir().unwrap(); + let db = tempfile::tempdir().unwrap(); + let dc = tempfile::tempdir().unwrap(); + let a = service(da.path()).await; + let b = service(db.path()).await; + let c = service(dc.path()).await; + let invitation = a.invite().unwrap(); + let client = b.clone(); + let join = tokio::spawn(async move { client.join(invitation).await }); + a.approve(&pending(&a).await, false).unwrap(); + assert_eq!(join.await.unwrap().unwrap_err(), "sync-pairing-rejected"); + assert!(b.status().unwrap()["peers"].as_array().unwrap().is_empty()); + let mut expired = a.invite().unwrap(); + expired.expires_at = now() - 1; + assert_eq!( + b.join(expired).await.unwrap_err(), + "sync-invalid-invitation" + ); + let mut wrong = a.invite().unwrap(); + wrong.device_id = c.identity.id.clone(); + wrong.certificate = URL_SAFE_NO_PAD.encode(&c.identity.cert); + assert_eq!( + b.join(wrong).await.unwrap_err(), + "sync-authentication-failed" + ); + let peer = Peer { + id: a.identity.id.clone(), + name: a.identity.name.clone(), + address: a.status().unwrap()["listenAddress"] + .as_str() + .unwrap() + .into(), + cert: a.identity.cert.clone(), + }; + let mut stream = transport::connect(&b.identity, &peer, false).await.unwrap(); + transport::write( + &mut stream, + &Request::Hello { + version: 1, + group_id: "test-group".into(), + }, + SMALL_FRAME, + ) + .await + .unwrap(); + assert!( + matches!(transport::read::<_,Reply>(&mut stream,SMALL_FRAME).await.unwrap(),Reply::Error{code} if code=="sync-authentication-failed") + ); + a.stop().unwrap(); + b.stop().unwrap(); + c.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn real_tls_revocation_pause_switch_and_cancel_stop_requests() { + let da = tempfile::tempdir().unwrap(); + let db = tempfile::tempdir().unwrap(); + let entered = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let signal = entered.clone(); + let a = SyncService::new( + da.path(), + Arc::new(move |_, _, _| { + signal.store(true, std::sync::atomic::Ordering::Release); + std::thread::sleep(Duration::from_millis(300)); + Ok(json!("late")) + }), + ) + .unwrap(); + a.activate(Scope { + token: id(), + group_id: "test-group".into(), + }) + .unwrap(); + a.listen("127.0.0.1:0").await.unwrap(); + let b = service(db.path()).await; + pair(&a, &b).await; + let client = b.clone(); + let peer = a.identity.id.clone(); + let call = tokio::spawn(async move { + client + .peer_request(&peer, "journal/status", Value::Null) + .await + }); + tokio::time::timeout(Duration::from_secs(5), async { + while !entered.load(std::sync::atomic::Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + a.revoke(&b.identity.id).unwrap(); + assert!(call.await.unwrap().is_err()); + assert!( + b.peer_request(&a.identity.id, "journal/status", Value::Null) + .await + .is_err() + ); + pair(&a, &b).await; + a.pause(true).unwrap(); + assert!( + b.peer_request(&a.identity.id, "journal/status", Value::Null) + .await + .is_err() + ); + a.pause(false).unwrap(); + let client = b.clone(); + let peer = a.identity.id.clone(); + let call = tokio::spawn(async move { + client + .peer_request(&peer, "journal/status", Value::Null) + .await + }); + tokio::time::sleep(Duration::from_millis(20)).await; + b.cancel().unwrap(); + assert_eq!(call.await.unwrap().unwrap_err(), "sync-cancelled"); + let invitation = a.invite().unwrap(); + a.activate(Scope { + token: id(), + group_id: "different-group".into(), + }) + .unwrap(); + assert_eq!(a.status().unwrap()["enabled"], false); + assert!(b.join(invitation).await.is_err()); + b.stop().unwrap(); +} + +#[tokio::test] +async fn real_tls_message_budget_and_protocol_version_are_enforced() { + let da = tempfile::tempdir().unwrap(); + let db = tempfile::tempdir().unwrap(); + let a = service(da.path()).await; + let b = service(db.path()).await; + pair(&a, &b).await; + let context = b.context().unwrap(); + let peer = b.peer(&context, &a.identity.id).unwrap(); + let mut stream = transport::connect(&b.identity, &peer, true).await.unwrap(); + transport::write( + &mut stream, + &Request::Hello { + version: 1, + group_id: "test-group".into(), + }, + SMALL_FRAME, + ) + .await + .unwrap(); + let _: Reply = transport::read(&mut stream, SMALL_FRAME).await.unwrap(); + stream.write_u32(MAX_FRAME as u32 + 1).await.unwrap(); + stream.flush().await.unwrap(); + assert!( + matches!(transport::read::<_,Reply>(&mut stream,SMALL_FRAME).await.unwrap(),Reply::Error{code} if code=="sync-message-limit") + ); + let mut stream = transport::connect(&b.identity, &peer, true).await.unwrap(); + transport::write( + &mut stream, + &Request::Hello { + version: 99, + group_id: "test-group".into(), + }, + SMALL_FRAME, + ) + .await + .unwrap(); + assert!( + matches!(transport::read::<_,Reply>(&mut stream,SMALL_FRAME).await.unwrap(),Reply::Error{code} if code=="sync-incompatible") + ); + a.stop().unwrap(); + b.stop().unwrap(); +} + +#[test] +fn bind_addresses_never_select_public_or_unspecified_interfaces() { + for address in [ + "0.0.0.0:123", + "[::]:123", + "8.8.8.8:123", + "example.org:123", + "[::ffff:8.8.8.8]:123", + ] { + assert!(transport::address(address).is_err()); + } + for address in [ + "127.0.0.1:0", + "192.168.1.10:123", + "10.0.0.2:123", + "[::1]:123", + "[fd00::1]:123", + ] { + assert!(transport::address(address).is_ok()); + } +} + +#[tokio::test] +async fn real_tls_pending_disconnect_and_expired_ticket_cannot_be_approved() { + let da = tempfile::tempdir().unwrap(); + let db = tempfile::tempdir().unwrap(); + let a = service(da.path()).await; + let b = service(db.path()).await; + let invitation = a.invite().unwrap(); + let client = b.clone(); + let join = tokio::spawn(async move { client.join(invitation).await }); + let pending_id = pending(&a).await; + b.cancel().unwrap(); + assert_eq!(join.await.unwrap().unwrap_err(), "sync-cancelled"); + tokio::time::timeout(Duration::from_secs(5), async { + while !a.status().unwrap()["pending"] + .as_array() + .unwrap() + .is_empty() + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!( + a.approve(&pending_id, true).unwrap_err(), + "sync-pairing-expired" + ); + assert!(a.status().unwrap()["peers"].as_array().unwrap().is_empty()); + let invitation = a.invite().unwrap(); + a.lock() + .unwrap() + .invitations + .insert(digest(invitation.ticket.as_bytes()), now() - 1); + assert_eq!( + b.join(invitation).await.unwrap_err(), + "sync-pairing-expired" + ); + a.stop().unwrap(); + b.stop().unwrap(); +} + +#[cfg(windows)] +#[test] +fn windows_identity_directory_and_database_have_protected_current_user_acl() { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::{ + Foundation::LocalFree, + Security::{ + Authorization::{GetSecurityInfo, SE_FILE_OBJECT}, + DACL_SECURITY_INFORMATION, GetSecurityDescriptorControl, SE_DACL_PROTECTED, + }, + }; + let temp = tempfile::tempdir().unwrap(); + let service = SyncService::new(temp.path(), Arc::new(|_, _, _| Ok(Value::Null))).unwrap(); + let mut options = std::fs::OpenOptions::new(); + use std::os::windows::fs::OpenOptionsExt; + options.read(true).custom_flags(0x02000000); + for path in [ + temp.path().join("device-sync"), + temp.path().join("device-sync/device-sync.sqlite3"), + ] { + let file = options.open(path).unwrap(); + let mut descriptor = std::ptr::null_mut(); + // SAFETY: file is live and descriptor points to a valid output pointer. + assert_eq!( + unsafe { + GetSecurityInfo( + file.as_raw_handle(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let mut control = 0; + let mut revision = 0; + // SAFETY: GetSecurityInfo returned a security descriptor which remains allocated. + assert_ne!( + unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) }, + 0 + ); + assert_ne!(control & SE_DACL_PROTECTED, 0); + // SAFETY: ownership of this LocalAlloc allocation was returned by GetSecurityInfo. + unsafe { + LocalFree(descriptor); + } + } + drop(service); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn real_tls_transfers_resumable_objects_between_two_real_workspaces() { + use base64::engine::general_purpose::STANDARD; + use repeaty_workspace::library::Library; + let app_a = tempfile::tempdir().unwrap(); + let app_b = tempfile::tempdir().unwrap(); + let dir_a = tempfile::tempdir().unwrap(); + let dir_b = tempfile::tempdir().unwrap(); + let library_a = Arc::new(Mutex::new(Library::create(dir_a.path()).unwrap())); + let library_b = Arc::new(Mutex::new(Library::create(dir_b.path()).unwrap())); + let content = vec![91; 262144 + 53]; + library_a.lock().unwrap().write(json!({"type":"media","id":"shared-media","mime":"application/octet-stream","body":STANDARD.encode(&content)})).unwrap(); + let factory = |path: &Path, library: Arc>| { + let token = library.lock().unwrap().token.clone(); + let service = SyncService::new( + path, + Arc::new(move |scope, method, payload| { + let mut library = library.lock().map_err(|_| "sync-storage-error")?; + library + .guard(&scope.token) + .map_err(|_| "sync-workspace-changed")?; + library + .sync_object_request(method, payload) + .map_err(|error| error.0.to_owned()) + }), + ) + .unwrap(); + service + .activate(Scope { + token, + group_id: "test-group".into(), + }) + .unwrap(); + service + }; + let a = factory(app_a.path(), library_a.clone()); + let b = factory(app_b.path(), library_b.clone()); + a.listen("127.0.0.1:0").await.unwrap(); + b.listen("127.0.0.1:0").await.unwrap(); + pair(&a, &b).await; + let inventory = b + .peer_request(&a.identity.id, "objects/info", json!({})) + .await + .unwrap(); + let object = inventory["objects"][0].clone(); + let chunk = b + .peer_request( + &a.identity.id, + "objects/read", + json!({"object":object,"offset":262144}), + ) + .await + .unwrap(); + a.peer_request(&b.identity.id,"objects/write",json!({"object":object,"offset":chunk["offset"],"data":chunk["data"],"chunkHash":chunk["chunkHash"]})).await.unwrap(); + b.cancel().unwrap(); + let progress = a + .peer_request(&b.identity.id, "objects/info", json!({"object":object})) + .await + .unwrap(); + assert_eq!(progress["received"], json!([1])); + let chunk = b + .peer_request( + &a.identity.id, + "objects/read", + json!({"object":object,"offset":0}), + ) + .await + .unwrap(); + a.peer_request( + &b.identity.id, + "objects/write", + json!({"object":object,"offset":0,"data":chunk["data"],"chunkHash":chunk["chunkHash"]}), + ) + .await + .unwrap(); + a.peer_request(&b.identity.id, "objects/finish", json!({"object":object})) + .await + .unwrap(); + assert_eq!( + std::fs::read( + dir_b + .path() + .join(".repeaty/media") + .join(format!("{}.bin", object["hash"].as_str().unwrap())) + ) + .unwrap(), + content + ); + assert_eq!( + a.peer_request(&b.identity.id, "objects/info", json!({})) + .await + .unwrap()["objects"][0]["id"], + "shared-media" + ); + a.stop().unwrap(); + b.stop().unwrap(); +} + +#[tokio::test] +async fn stale_async_commands_cannot_claim_a_new_scope_after_workspace_switch() { + let da = tempfile::tempdir().unwrap(); + let db = tempfile::tempdir().unwrap(); + let a = service(da.path()).await; + let b = service(db.path()).await; + let old = a.context().unwrap().scope; + let invitation = b.invite().unwrap(); + let gate = Arc::new(tokio::sync::Barrier::new(2)); + let waiting = gate.clone(); + let service = a.clone(); + let task = tokio::spawn(async move { + waiting.wait().await; + assert_eq!( + service + .listen_for(old.clone(), "127.0.0.1:0") + .await + .unwrap_err(), + "sync-workspace-changed" + ); + assert_eq!( + service.join_for(old.clone(), invitation).await.unwrap_err(), + "sync-workspace-changed" + ); + assert_eq!( + service + .peer_request_for(old, "unused", "journal/status", Value::Null) + .await + .unwrap_err(), + "sync-workspace-changed" + ); + }); + a.activate(Scope { + token: id(), + group_id: "next-group".into(), + }) + .unwrap(); + a.listen("127.0.0.1:0").await.unwrap(); + let expected = a.status().unwrap(); + gate.wait().await; + task.await.unwrap(); + assert_eq!(a.status().unwrap(), expected); + a.stop().unwrap(); + b.stop().unwrap(); +} diff --git a/src-tauri/src/device_sync/transport.rs b/src-tauri/src/device_sync/transport.rs new file mode 100644 index 0000000..07c4c38 --- /dev/null +++ b/src-tauri/src/device_sync/transport.rs @@ -0,0 +1,147 @@ +use super::{Identity, Peer, Result}; +use rustls::{ + ClientConfig, RootCertStore, ServerConfig, + pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}, + server::WebPkiClientVerifier, +}; +use serde::{Serialize, de::DeserializeOwned}; +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::TcpStream, +}; +use tokio_rustls::{TlsConnector, client::TlsStream}; + +pub(super) const MAX_FRAME: usize = 64 * 1024 * 1024; +pub(super) const IO_TIMEOUT: Duration = Duration::from_secs(30); +pub(super) const PAIR_TIMEOUT: Duration = Duration::from_secs(300); +pub(super) const SMALL_FRAME: usize = 32 * 1024; + +pub(super) fn address(value: &str) -> Result { + let address: SocketAddr = value.parse().map_err(|_| "sync-invalid-address")?; + let allowed = match address.ip() { + IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), + IpAddr::V6(ip) => ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local(), + }; + if !allowed { + return Err("sync-invalid-address".into()); + } + Ok(address) +} +fn provider() -> Arc { + Arc::new(rustls::crypto::ring::default_provider()) +} +fn certificates(identity: &Identity) -> Vec> { + vec![CertificateDer::from(identity.cert.clone())] +} +fn key(identity: &Identity) -> PrivateKeyDer<'static> { + PrivatePkcs8KeyDer::from(identity.key.clone()).into() +} +fn roots(certs: impl IntoIterator>) -> Result { + let mut roots = RootCertStore::empty(); + for cert in certs { + roots + .add(CertificateDer::from(cert)) + .map_err(|_| "sync-invalid-certificate")?; + } + Ok(roots) +} +pub(super) fn server(identity: &Identity, peers: &[Peer]) -> Result> { + // The host's own root makes an empty peer set valid. It never authorizes a request. + let roots = + roots(std::iter::once(identity.cert.clone()).chain(peers.iter().map(|p| p.cert.clone())))?; + let verifier = WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider()) + .allow_unauthenticated() + .build() + .map_err(|_| "sync-tls-error")?; + let mut config = ServerConfig::builder_with_provider(provider()) + .with_protocol_versions(&[&rustls::version::TLS13]) + .map_err(|_| "sync-tls-error")? + .with_client_cert_verifier(verifier) + .with_single_cert(certificates(identity), key(identity)) + .map_err(|_| "sync-identity-error")?; + config.alpn_protocols = vec![b"repeaty-sync/1".to_vec()]; + config.max_early_data_size = 0; + config.send_tls13_tickets = 0; + Ok(Arc::new(config)) +} +pub(super) async fn connect( + identity: &Identity, + peer: &Peer, + authenticated: bool, +) -> Result> { + let builder = ClientConfig::builder_with_provider(provider()) + .with_protocol_versions(&[&rustls::version::TLS13]) + .map_err(|_| "sync-tls-error")? + .with_root_certificates(roots([peer.cert.clone()])?); + let mut config = if authenticated { + builder + .with_client_auth_cert(certificates(identity), key(identity)) + .map_err(|_| "sync-identity-error")? + } else { + builder.with_no_client_auth() + }; + config.alpn_protocols = vec![b"repeaty-sync/1".to_vec()]; + config.enable_early_data = false; + config.resumption = rustls::client::Resumption::disabled(); + let socket = TcpStream::connect(address(&peer.address)?) + .await + .map_err(|_| "sync-unreachable")?; + let stream = TlsConnector::from(Arc::new(config)) + .connect( + ServerName::try_from("repeaty-sync").expect("fixed SAN"), + socket, + ) + .await + .map_err(|_| "sync-authentication-failed")?; + let cert = stream + .get_ref() + .1 + .peer_certificates() + .and_then(|chain| chain.first()) + .ok_or("sync-authentication-failed")?; + if cert.as_ref() != peer.cert + || stream.get_ref().1.alpn_protocol() != Some(b"repeaty-sync/1".as_slice()) + { + return Err("sync-authentication-failed".into()); + } + Ok(stream) +} +pub(super) async fn read( + stream: &mut S, + limit: usize, +) -> Result { + let size = stream.read_u32().await.map_err(|_| "sync-disconnected")? as usize; + if size == 0 || size > limit { + return Err("sync-message-limit".into()); + } + let mut bytes = vec![0; size]; + stream + .read_exact(&mut bytes) + .await + .map_err(|_| "sync-disconnected")?; + serde_json::from_slice(&bytes).map_err(|_| "sync-invalid-message".into()) +} +pub(super) async fn write( + stream: &mut S, + value: &T, + limit: usize, +) -> Result<()> { + let bytes = serde_json::to_vec(value).map_err(|_| "sync-invalid-message")?; + if bytes.len() > limit || bytes.is_empty() { + return Err("sync-message-limit".into()); + } + stream + .write_u32(bytes.len() as u32) + .await + .map_err(|_| "sync-disconnected")?; + stream + .write_all(&bytes) + .await + .map_err(|_| "sync-disconnected")?; + stream.flush().await.map_err(|_| "sync-disconnected".into()) +} diff --git a/src-tauri/src/device_sync/windows_permissions.rs b/src-tauri/src/device_sync/windows_permissions.rs new file mode 100644 index 0000000..5bbf61e --- /dev/null +++ b/src-tauri/src/device_sync/windows_permissions.rs @@ -0,0 +1,91 @@ +use std::{ + fs::File, + io, + mem::size_of, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + ptr::null_mut, +}; +use windows_sys::Win32::{ + Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_SUCCESS}, + Security::{ + ACCESS_ALLOWED_ACE, ACL, ACL_REVISION, AddAccessAllowedAceEx, + Authorization::{SE_FILE_OBJECT, SetSecurityInfo}, + DACL_SECURITY_INFORMATION, GetLengthSid, GetTokenInformation, InitializeAcl, + PROTECTED_DACL_SECURITY_INFORMATION, TOKEN_QUERY, TOKEN_USER, TokenUser, + }, + Storage::FileSystem::FILE_ALL_ACCESS, + System::Threading::{GetCurrentProcess, OpenProcessToken}, +}; + +// The TOKEN_USER contains a SID pointing into this buffer. usize storage keeps both +// the structure and its SID aligned, and the buffer stays alive while they are used. +fn current_user() -> io::Result> { + let mut raw = null_mut(); + // SAFETY: the output points to a valid handle slot; the pseudo process handle is borrowed. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: OpenProcessToken returned a new owned handle on success. + let token = unsafe { OwnedHandle::from_raw_handle(raw) }; + let mut size = 0; + // SAFETY: a null, zero-length buffer requests the required size without writing to it. + unsafe { GetTokenInformation(token.as_raw_handle(), TokenUser, null_mut(), 0, &mut size) }; + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) { + return Err(error); + } + if (size as usize) < size_of::() { + return Err(io::Error::other("Invalid Windows token user size")); + } + let mut buffer = vec![0usize; (size as usize).div_ceil(size_of::())]; + // SAFETY: the aligned buffer is at least `size` bytes long; the token handle is live. + if unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenUser, + buffer.as_mut_ptr().cast(), + size, + &mut size, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(buffer) +} + +/// Replace inherited and explicit grants with one grant for the actual process user. +/// Errors must stop opening the credential store before SQLite reads or writes it. +pub(crate) fn restrict(file: &File) -> io::Result<()> { + let user = current_user()?; + // SAFETY: GetTokenInformation populated an aligned TOKEN_USER and its SID in `user`. + let sid = unsafe { (*user.as_ptr().cast::()).User.Sid }; + // SAFETY: the SID belongs to the still-live token information buffer. + let size = size_of::() + size_of::() - size_of::() + + unsafe { GetLengthSid(sid) } as usize; + let mut buffer = vec![0u32; size.div_ceil(size_of::())]; + let acl = buffer.as_mut_ptr().cast::(); + // SAFETY: the ACL buffer is aligned and has room for its header and one complete ACE. + if unsafe { InitializeAcl(acl, size as u32, ACL_REVISION) } == 0 + || unsafe { AddAccessAllowedAceEx(acl, ACL_REVISION, 0, FILE_ALL_ACCESS, sid) } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: the handle is live and was opened with WRITE_DAC; `acl` is a non-null, + // initialized ACL. Protection prevents grants from the parent being added again. + let status = unsafe { + SetSecurityInfo( + file.as_raw_handle(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + acl, + null_mut(), + ) + }; + if status != ERROR_SUCCESS { + return Err(io::Error::from_raw_os_error(status as i32)); + } + Ok(()) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 214913b..4a4849e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,9 +1,11 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod collection; mod desktop_experience; +mod device_sync; mod exports; mod mcp; mod plugins; +mod sync_host; mod window_frame; mod workspace_backups; mod workspaces; @@ -242,6 +244,15 @@ fn main() { plugins: Plugins::new(&directory)?, }); app.manage(workspaces::Workspaces::new(&directory)); + let sync = device_sync::SyncService::new( + &directory, + app.state::().sync_handler(), + ) + .map_err(std::io::Error::other)?; + app.state::() + .attach_sync(&sync) + .map_err(std::io::Error::other)?; + app.manage(sync); app.manage(workspace_backups::Backups::new(&directory)); let collection = collection::Collection::new(&directory)?; collection.attach_workspace(app.state::().active_identity()); @@ -311,6 +322,7 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + sync_host::device_sync_request, mcp_attach, mcp_workspace, mcp_reply, diff --git a/src-tauri/src/sync_host.rs b/src-tauri/src/sync_host.rs new file mode 100644 index 0000000..b9b11b5 --- /dev/null +++ b/src-tauri/src/sync_host.rs @@ -0,0 +1,171 @@ +use crate::{ + device_sync::{Invitation, Scope, SyncService}, + main_window, + workspaces::{Workspaces, run, run_checked}, +}; +use serde_json::{Value, json}; +use std::sync::Arc; +use tauri::State; + +#[tauri::command] +pub async fn device_sync_request( + window: tauri::WebviewWindow, + state: State<'_, Workspaces>, + service: State<'_, Arc>, + token: String, + method: String, + payload: Value, +) -> Result { + main_window(&window)?; + let check = token.clone(); + let host = service.inner().clone(); + let status = run(&state, move |s| { + let mut status = s.sync_request(&check, "journal/status", Value::Null)?; + if let Some(group) = status["groupId"].as_str() { + host.activate(Scope { + token: check, + group_id: group.into(), + }) + .map_err(|_| repeaty_workspace::error::Error("sync-unavailable"))?; + } else { + host.deactivate(); + } + status["transport"] = host + .status() + .map_err(|_| repeaty_workspace::error::Error("sync-unavailable"))?; + Ok(status) + }) + .await?; + if method == "leave" { + let host = service.inner().clone(); + return run(&state, move |s| { + let status = s.sync_request(&token, "journal/status", Value::Null)?; + if status["groupId"].is_null() { + return Ok(status); + } + let peers = host + .status() + .map_err(|_| repeaty_workspace::error::Error("sync-unavailable"))?; + for peer in peers["peers"].as_array().into_iter().flatten() { + if let Some(id) = peer["id"].as_str() { + host.revoke(id) + .map_err(|_| repeaty_workspace::error::Error("sync-unavailable"))?; + } + } + host.deactivate(); + s.sync_request(&token, "leave", Value::Null) + }) + .await; + } + if method == "start" || method == "join" || method == "prepareJoin" { + let invitation = if method != "start" { + Some( + serde_json::from_value::(payload["invitation"].clone()) + .map_err(|_| "sync-invalid-invitation")?, + ) + } else { + None + }; + let group = if method != "start" { + payload["invitation"]["groupId"].as_str() + } else { + payload["groupId"].as_str() + } + .ok_or("sync-invalid-group")? + .to_owned(); + let owner = token.clone(); + let host = service.inner().clone(); + run(&state, move |s| { + s.sync_request(&owner, "start", json!({"groupId":group}))?; + host.activate(Scope { + token: owner, + group_id: group, + }) + .map_err(|_| repeaty_workspace::error::Error("sync-unavailable"))?; + Ok(()) + }) + .await?; + if method != "join" { + return service.status(); + } + let invitation = invitation.ok_or("sync-invalid-invitation")?; + return service + .inner() + .join_for( + Scope { + token, + group_id: invitation.group_id.clone(), + }, + invitation, + ) + .await; + } + if method == "status" { + return Ok(json!({"transport":status["transport"],"journal":status})); + } + match method.as_str() { + "listen" => { + service + .inner() + .listen_for( + Scope { + token, + group_id: status["groupId"].as_str().ok_or("sync-not-enabled")?.into(), + }, + payload["address"].as_str().ok_or("sync-invalid-address")?, + ) + .await + } + "invite" | "approve" | "reject" | "pause" | "resume" | "revoke" | "cancel" | "stop" + | "diagnostics" | "deactivate" => { + let host = service.inner().clone(); + run_checked(&state, move |sessions| { + sessions + .sync_request(&token, "journal/status", Value::Null) + .map_err(|e| e.0.to_owned())?; + match method.as_str() { + "invite" => { + serde_json::to_value(host.invite()?).map_err(|_| "sync-error".into()) + } + "approve" => { + host.approve(payload["id"].as_str().ok_or("sync-invalid-peer")?, true) + } + "reject" => { + host.approve(payload["id"].as_str().ok_or("sync-invalid-peer")?, false) + } + "pause" => host.pause(true), + "resume" => host.pause(false), + "revoke" => host.revoke(payload["id"].as_str().ok_or("sync-invalid-peer")?), + "cancel" => host.cancel(), + "stop" => host.stop(), + "diagnostics" => host.diagnostics(), + "deactivate" => { + host.deactivate(); + Ok(Value::Null) + } + _ => Err("sync-invalid-method".into()), + } + }) + .await + } + "peer" => { + service + .inner() + .peer_request_for( + Scope { + token, + group_id: status["groupId"].as_str().ok_or("sync-not-enabled")?.into(), + }, + payload["peerId"].as_str().ok_or("sync-invalid-peer")?, + payload["method"].as_str().ok_or("sync-invalid-method")?, + payload["payload"].clone(), + ) + .await + } + "snapshot" | "journal/status" | "journal/list" | "journal/receive" | "objects/info" + | "objects/read" | "objects/write" | "objects/finish" => { + run(&state, move |s| s.sync_request(&token, &method, payload)).await + } + _ => Err("sync-invalid-method".into()), + } +} diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index f8f6718..a207b76 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -14,6 +14,8 @@ struct Inner { directory: PathBuf, sessions: Option, subscription: Option, + sync_device: Option, + sync_service: Option>, } pub type ActiveIdentity = Arc>; pub struct Workspaces(Arc>, Arc, ActiveIdentity); @@ -24,6 +26,8 @@ impl Workspaces { directory: directory.into(), sessions: None, subscription: None, + sync_device: None, + sync_service: None, })), Arc::new(AtomicU64::new(0)), Arc::new(Mutex::new((String::new(), String::new()))), @@ -35,6 +39,36 @@ impl Workspaces { pub fn active_identity(&self) -> ActiveIdentity { self.2.clone() } + pub fn attach_sync( + &self, + service: &Arc, + ) -> Result<(), String> { + let mut guard = self.0.lock().map_err(|_| "storage-error")?; + guard.sync_device = Some( + service.status()?["deviceId"] + .as_str() + .ok_or("sync-device-required")? + .into(), + ); + guard.sync_service = Some(Arc::downgrade(service)); + Ok(()) + } + pub fn sync_handler(&self) -> crate::device_sync::Handler { + let manager = self.0.clone(); + Arc::new(move |scope, method, payload| { + let mut guard = manager.lock().map_err(|_| "storage-error".to_owned())?; + let sessions = guard.sessions.as_mut().ok_or("workspace-not-open")?; + let status = sessions + .sync_request(&scope.token, "journal/status", Value::Null) + .map_err(|e| e.0.to_owned())?; + if status["groupId"] != scope.group_id { + return Err("sync-wrong-group".into()); + } + sessions + .sync_request(&scope.token, method, payload) + .map_err(|e| e.0.to_owned()) + }) + } } /// Serialize plugin filesystem operations with workspace switching and use a held root capability. pub async fn with_plugin_workspace( @@ -149,6 +183,15 @@ pub async fn workspace_unwatch( pub(crate) async fn run( state: &Workspaces, task: impl FnOnce(&mut Sessions) -> StoreResult + Send + 'static, +) -> Result { + run_checked(state, move |sessions| { + task(sessions).map_err(|e| e.0.to_owned()) + }) + .await +} +pub(crate) async fn run_checked( + state: &Workspaces, + task: impl FnOnce(&mut Sessions) -> Result + Send + 'static, ) -> Result { let manager = state.0.clone(); let identity = state.2.clone(); @@ -157,13 +200,36 @@ pub(crate) async fn run( if guard.sessions.is_none() { guard.sessions = Some(Sessions::open(&guard.directory).map_err(|e| e.0.to_owned())?); } + let device = guard.sync_device.clone(); let sessions = guard.sessions.as_mut().ok_or("storage-error")?; - let result = task(sessions).map_err(|e| e.0.to_owned()); + if let Some(device) = &device { + sessions + .sync_bind_device(device) + .map_err(|e| e.0.to_owned())?; + } + let previous = sessions + .active_identity() + .map(|(_, token)| token.to_owned()); + let result = task(sessions); + if let Some(device) = &device { + // A successful learning commit remains successful. Publication of sync + // events rechecks and durably advances the device frontier before sending. + let _ = sessions.sync_bind_device(device); + } let active = sessions .active_identity() .map(|(id, token)| (format!("local:workspace:{id}"), token.to_owned())) .unwrap_or_default(); *identity.lock().map_err(|_| "storage-error")? = active; + if previous + != guard + .sessions + .as_ref() + .and_then(|s| s.active_identity().map(|(_, token)| token.to_owned())) + && let Some(service) = guard.sync_service.as_ref().and_then(|s| s.upgrade()) + { + service.deactivate(); + } result }) .await diff --git a/src-tauri/tests/device_sync.rs b/src-tauri/tests/device_sync.rs new file mode 100644 index 0000000..b1e1c2e --- /dev/null +++ b/src-tauri/tests/device_sync.rs @@ -0,0 +1,4 @@ +// Exercise the native service independently of the Tauri application or a GUI session. +#[allow(dead_code)] +#[path = "../src/device_sync.rs"] +mod device_sync; From 9f2fbf98eae5e8fe09d894d0e0556f97427b7a6a Mon Sep 17 00:00:00 2001 From: CharlesHsu Date: Fri, 30 Oct 2026 22:14:03 +0800 Subject: [PATCH 3/5] feat(sync): apply causal updates through the native learning transaction --- .../examples/workspace-runtime.rs | 130 +++-- src/application/library-context.ts | 1 + src/application/library.ts | 18 + src/browser/workspace-reader.ts | 4 +- src/core/study-persistence.test.ts | 11 +- src/desktop/repository.ts | 2 + src/desktop/workspaces.test.ts | 33 ++ src/desktop/workspaces.ts | 31 +- src/sync/commit.ts | 11 + src/sync/merge.test.ts | 21 +- src/sync/merge.ts | 75 ++- src/sync/protocol.ts | 2 +- tests/workspace-native/sync.test.ts | 504 ++++++++++++++++++ 13 files changed, 764 insertions(+), 79 deletions(-) create mode 100644 src/sync/commit.ts create mode 100644 tests/workspace-native/sync.test.ts diff --git a/crates/workspace-store/examples/workspace-runtime.rs b/crates/workspace-store/examples/workspace-runtime.rs index 451a26f..ec81c7f 100644 --- a/crates/workspace-store/examples/workspace-runtime.rs +++ b/crates/workspace-store/examples/workspace-runtime.rs @@ -152,6 +152,10 @@ fn backups( fn main() -> std::result::Result<(), Box> { let directory = std::env::args().nth(1).ok_or("missing device directory")?; let mut sessions = Sessions::open(Path::new(&directory))?; + // A stable fixture device identity; the production store additionally scopes + // the journal writer to the selected workspace identity and canonical path. + let device = repeaty_workspace::files::digest(directory.as_bytes()); + sessions.sync_bind_device(&device)?; let mut previews = HashMap::new(); let stdin = io::stdin(); let mut out = io::stdout().lock(); @@ -159,68 +163,78 @@ fn main() -> std::result::Result<(), Box> { let input: Value = serde_json::from_str(&line?)?; let args = &input["args"]; let epoch = args["epoch"].as_str().unwrap_or(""); - let result = match input["command"].as_str().unwrap_or("") { - "workspace_backups" => { - backups(&mut sessions, Path::new(&directory), &mut previews, args) - } - "workspace_status" => sessions.status(), - "workspace_choose" => sessions.choose( - epoch, - Path::new(args["path"].as_str().unwrap_or("")), - args["mode"] == "create", - ), - "workspace_close" => sessions.close(epoch), - "workspace_recent" => sessions.recent( - epoch, - args["path"].as_str().unwrap_or(""), - args["id"].as_str().unwrap_or(""), - ), - "workspace_restore" => sessions.restore(epoch), - "workspace_backup" => sessions - .backup( + let result = sessions.sync_bind_device(&device).and_then(|()| { + let result = match input["command"].as_str().unwrap_or("") { + "workspace_sync" => sessions.sync_request( args["token"].as_str().unwrap_or(""), + args["method"].as_str().unwrap_or(""), + args["payload"].clone(), + ), + "workspace_backups" => { + backups(&mut sessions, Path::new(&directory), &mut previews, args) + } + "workspace_status" => sessions.status(), + "workspace_choose" => sessions.choose( + epoch, + Path::new(args["path"].as_str().unwrap_or("")), + args["mode"] == "create", + ), + "workspace_close" => sessions.close(epoch), + "workspace_recent" => sessions.recent( + epoch, + args["path"].as_str().unwrap_or(""), + args["id"].as_str().unwrap_or(""), + ), + "workspace_restore" => sessions.restore(epoch), + "workspace_backup" => sessions + .backup( + args["token"].as_str().unwrap_or(""), + Path::new(args["path"].as_str().unwrap_or("")), + ) + .map(|v| serde_json::to_value(v).unwrap()), + "workspace_backup_inspect" => { + Sessions::inspect_backup(Path::new(args["path"].as_str().unwrap_or(""))) + .map(|v| serde_json::to_value(v).unwrap()) + } + "workspace_backup_search" => Sessions::search_backup( Path::new(args["path"].as_str().unwrap_or("")), + args["query"].as_str().unwrap_or(""), + args["limit"].as_u64().unwrap_or(30) as usize, ) .map(|v| serde_json::to_value(v).unwrap()), - "workspace_backup_inspect" => { - Sessions::inspect_backup(Path::new(args["path"].as_str().unwrap_or(""))) - .map(|v| serde_json::to_value(v).unwrap()) - } - "workspace_backup_search" => Sessions::search_backup( - Path::new(args["path"].as_str().unwrap_or("")), - args["query"].as_str().unwrap_or(""), - args["limit"].as_u64().unwrap_or(30) as usize, - ) - .map(|v| serde_json::to_value(v).unwrap()), - "workspace_backup_restore" => sessions.restore_backup( - epoch, - Path::new(args["path"].as_str().unwrap_or("")), - Path::new(args["target"].as_str().unwrap_or("")), - args["sha256"].as_str().unwrap_or(""), - ), - "workspace_backup_restore_items" => Sessions::restore_backup_items( - Path::new(args["path"].as_str().unwrap_or("")), - Path::new(args["target"].as_str().unwrap_or("")), - args["sha256"].as_str().unwrap_or(""), - &serde_json::from_value::>(args["paths"].clone()).unwrap_or_default(), - ), - "workspace_maintenance" => sessions.maintenance( - args["token"].as_str().unwrap_or(""), - args["vacuum"].as_bool().unwrap_or(false), - ), - "workspace_read" => sessions.read( - args["token"].as_str().unwrap_or(""), - args["request"].clone(), - ), - "workspace_changes" => sessions - .changes(args["token"].as_str().unwrap_or("")) - .map(|value| serde_json::to_value(value).unwrap()), - "workspace_write" => sessions.write( - args["token"].as_str().unwrap_or(""), - args["request"].clone(), - ), - _ => Err(repeaty_workspace::error::Error("invalid-data")), - }; + "workspace_backup_restore" => sessions.restore_backup( + epoch, + Path::new(args["path"].as_str().unwrap_or("")), + Path::new(args["target"].as_str().unwrap_or("")), + args["sha256"].as_str().unwrap_or(""), + ), + "workspace_backup_restore_items" => Sessions::restore_backup_items( + Path::new(args["path"].as_str().unwrap_or("")), + Path::new(args["target"].as_str().unwrap_or("")), + args["sha256"].as_str().unwrap_or(""), + &serde_json::from_value::>(args["paths"].clone()) + .unwrap_or_default(), + ), + "workspace_maintenance" => sessions.maintenance( + args["token"].as_str().unwrap_or(""), + args["vacuum"].as_bool().unwrap_or(false), + ), + "workspace_read" => sessions.read( + args["token"].as_str().unwrap_or(""), + args["request"].clone(), + ), + "workspace_changes" => sessions + .changes(args["token"].as_str().unwrap_or("")) + .map(|value| serde_json::to_value(value).unwrap()), + "workspace_write" => sessions.write( + args["token"].as_str().unwrap_or(""), + args["request"].clone(), + ), + _ => Err(repeaty_workspace::error::Error("invalid-data")), + }; + sessions.sync_bind_device(&device)?; + result + }); let response = match result { Ok(value) => json!({"id":input["id"],"value":value}), Err(error) => json!({"id":input["id"],"error":error.0}), diff --git a/src/application/library-context.ts b/src/application/library-context.ts index 18c958e..dcc79af 100644 --- a/src/application/library-context.ts +++ b/src/application/library-context.ts @@ -33,6 +33,7 @@ export interface LibraryState { redoOperations: Cell } export interface CommitOptions { + sync?: import('../sync/commit').SyncCommit retired?: import('../desktop/retirement').RetiredNote[] assets?: MediaUpload[] reason?: string diff --git a/src/application/library.ts b/src/application/library.ts index 993683d..54a284b 100644 --- a/src/application/library.ts +++ b/src/application/library.ts @@ -508,6 +508,24 @@ export function createLibraryService(state: LibraryState, dependencies: LibraryD canUndoReading(library.value, readingUndo.value), }, commands: { + async applyDeviceSync( + expectedScope: string, + expectedRevision: number, + next: Library, + options: Pick, + ) { + if (!desktopEdition) throw new Error('sync-desktop-required') + if (expectedScope !== scope.value || expectedRevision !== revision || !options.sync) + throw new Error('sync-preview-stale') + await commit( + () => { + if (expectedScope !== scope.value || expectedRevision !== revision) + throw new Error('sync-preview-stale') + return next + }, + { ...options, undoBarrier: true, reason: '设备同步' }, + ) + }, undoLastOperation, redoLastOperation, readCardDrafts: (scope: string) => draftRepository(scope).readCardDrafts(), diff --git a/src/browser/workspace-reader.ts b/src/browser/workspace-reader.ts index dd7675b..dbaffc8 100644 --- a/src/browser/workspace-reader.ts +++ b/src/browser/workspace-reader.ts @@ -117,7 +117,9 @@ export class BrowserWorkspaceReader { db.run('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF;') if ( scalar(db, 'PRAGMA application_id') !== 0x52505459 || - ![10, 11, 12, 13, 14, 15, 16, 17].includes(scalar(db, 'PRAGMA user_version') as number) || + ![10, 11, 12, 13, 14, 15, 16, 17, 18].includes( + scalar(db, 'PRAGMA user_version') as number, + ) || scalar(db, 'SELECT id FROM workspace_identity') !== marker.id ) throw new WorkspaceReadError('incompatible') diff --git a/src/core/study-persistence.test.ts b/src/core/study-persistence.test.ts index fd25e63..985a112 100644 --- a/src/core/study-persistence.test.ts +++ b/src/core/study-persistence.test.ts @@ -189,7 +189,16 @@ it('reads gate16 study records into a browser copy and never changes selected wo } finally { await repo.close() } - env.db.run('PRAGMA user_version=18') + env.db.run( + 'PRAGMA user_version=18; CREATE TABLE sync_state(id INTEGER PRIMARY KEY,group_id TEXT,clock TEXT)', + ) + env.files[fileIndex] = selectedFile('.repeaty/state.sqlite3', env.db.export()) + const syncOriginal = new Uint8Array(await env.files[fileIndex]!.arrayBuffer()) + const synced = await BrowserWorkspaceReader.open(SQL, { kind: 'files', files: env.files }) + expect(synced.snapshot.library).toEqual(library) + synced.close() + expect(new Uint8Array(await env.files[fileIndex]!.arrayBuffer())).toEqual(syncOriginal) + env.db.run('PRAGMA user_version=19') env.files[fileIndex] = selectedFile('.repeaty/state.sqlite3', env.db.export()) await expect( BrowserWorkspaceReader.open(SQL, { kind: 'files', files: env.files }), diff --git a/src/desktop/repository.ts b/src/desktop/repository.ts index 7a20758..47af8c2 100644 --- a/src/desktop/repository.ts +++ b/src/desktop/repository.ts @@ -135,6 +135,7 @@ export function createNativeRepository(active: ActiveWorkspace, api: NativeApi = assetObservations?: { path: string; hash: string }[] operationHistory?: OperationHistory retired?: RetiredNote[] + sync?: import('../sync/commit').SyncCommit } = {}, ): Promise { if (!cached) await repository.load() @@ -379,6 +380,7 @@ export function createNativeRepository(active: ActiveWorkspace, api: NativeApi = assetObservations: options.assetObservations ?? [], operationHistory: options.operationHistory, retired: options.retired, + sync: options.sync, }, }) if (result.revision !== expectedRevision + 1) throw changed() diff --git a/src/desktop/workspaces.test.ts b/src/desktop/workspaces.test.ts index 681a01b..e90d99d 100644 --- a/src/desktop/workspaces.test.ts +++ b/src/desktop/workspaces.test.ts @@ -215,4 +215,37 @@ describe('desktop workspace lifecycle', () => { ).resolves.toEqual({}) expect(store.workspaceStatus.value?.active).toEqual(selected) }) + it('serializes a sync settings refresh with durable writes so a late read cannot overwrite new values', async () => { + const selected = active() + invoke.mockImplementation(async (command) => + command === 'workspace_status' ? status(selected) : {}, + ) + const store = await import('./workspaces') + store.installWorkspaceStores() + await store.initializeWorkspaces() + const settings = await import('../settings-store') + let finishRead!: (value: Record) => void + invoke.mockImplementation(async (command) => + command === 'workspace_read' + ? new Promise>((resolve) => { + finishRead = resolve + }) + : undefined, + ) + const reading = store.hydrateSettings() + await vi.waitFor(() => expect(finishRead).toBeTypeOf('function')) + const writing = settings.persistSetting('plugin:sample:config', 'new value') + await vi.waitFor(() => + expect(invoke.mock.calls.filter(([command]) => command === 'workspace_write')).toHaveLength( + 1, + ), + ) + finishRead({ 'plugin:sample:config': 'old value' }) + await Promise.all([reading, writing]) + expect(settings.readSetting('plugin:sample:config')).toBe('new value') + expect(invoke).toHaveBeenCalledWith( + 'workspace_write', + expect.objectContaining({ token: selected.token }), + ) + }) }) diff --git a/src/desktop/workspaces.ts b/src/desktop/workspaces.ts index 3dbeaa6..f13794a 100644 --- a/src/desktop/workspaces.ts +++ b/src/desktop/workspaces.ts @@ -28,6 +28,12 @@ export function connectWorkspaceLeaveGuard(beforeLeave: () => boolean | Promise< } } const settings = new Map() +let settingsQueue: Promise = Promise.resolve() +function serializeSettings(operation: () => Promise): Promise { + const next = settingsQueue.then(operation) + settingsQueue = next.catch(() => {}) + return next +} export function installWorkspaceStores() { // Never read browser storage, including while native startup is still pending. useSettingsStore({ @@ -36,25 +42,40 @@ export function installWorkspaceStores() { const owner = workspaceStatus.value?.active?.token const active = await currentWorkspace() if (!owner || active.token !== owner) throw nativeError('stale-workspace-session') - await workspaceApi('workspace_write', { + // Begin the durable write now so workspace-close's existing write barrier + // includes it. Serialize only cache publication with hydration reads. + const outcome = workspaceApi('workspace_write', { token: active.token, request: { type: 'value', namespace: 'settings', id: key, value }, + }).then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ) + await serializeSettings(async () => { + const saved = await outcome + if (!saved.ok) throw saved.error + if (workspaceStatus.value?.active?.token === owner) settings.set(key, value) }) - if (workspaceStatus.value?.active?.token === owner) settings.set(key, value) }, }) } -async function hydrateSettings() { +export async function hydrateSettings() { + return serializeSettings(hydrateSettingsNow) +} +async function hydrateSettingsNow() { const active = workspaceStatus.value?.active - settings.clear() + const replacement = new Map() if (active) { const values = await workspaceApi>('workspace_read', { token: active.token, request: { type: 'settings' }, }) for (const [key, value] of Object.entries(values)) - if (typeof value === 'string') settings.set(key, value) + if (typeof value === 'string') replacement.set(key, value) } + if (workspaceStatus.value?.active?.token !== active?.token) return + settings.clear() + for (const [key, value] of replacement) settings.set(key, value) initI18n() } export function nativeError(issue: unknown) { diff --git a/src/sync/commit.ts b/src/sync/commit.ts new file mode 100644 index 0000000..1887579 --- /dev/null +++ b/src/sync/commit.ts @@ -0,0 +1,11 @@ +import type { SyncChange, SyncClock } from './protocol' + +/** One atomic materialization plus an optional causal conflict-resolution event. */ +export interface SyncCommit { + expectedClock: SyncClock + clock: SyncClock + appliedIds: string[] + values: Record> + resolutions: SyncChange[] + retiredMedia: Record +} diff --git a/src/sync/merge.test.ts b/src/sync/merge.test.ts index ff0b157..c0650a0 100644 --- a/src/sync/merge.test.ts +++ b/src/sync/merge.test.ts @@ -95,7 +95,7 @@ describe('sync envelope validation', () => { it('enforces group, count, causal device and per-transaction identity boundaries', () => { expect(() => parseSyncEvents([event('a', 1, [])], 'other')).toThrow('sync-wrong-group') expect(() => - parseSyncEvents(Array.from({ length: SYNC_LIMITS.events + 1 }, () => event('a', 1, []))), + parseSyncEvents(Array(SYNC_LIMITS.events + 1).fill(event('a', 1, []))), ).toThrow('sync-batch-limit') const change = { store: 'values:settings', id: 'plugin:theme', before: null, after: 'green' } expect(() => parseSyncEvents([event('a', 1, [change, change])])).toThrow( @@ -181,6 +181,25 @@ describe('sync envelope validation', () => { }) describe('causal planning and content conflict resolution', () => { + it('processes more than 2048 offline transactions through indexed causal dependencies', () => { + const snapshot = snapshotFromLibrary(fixture()) + const events = Array.from({ length: 4096 }, (_, index) => + event('offline', index + 1, [ + { + store: 'values:plugin:sample', + id: 'counter', + before: index === 0 ? null : index, + after: index + 1, + position: 0, + }, + ]), + ).reverse() + const result = run(snapshot, events) + expect(result.blocked).toBe(false) + expect(result.pending).toEqual([]) + expect(result.clock.offline).toBe(4096) + expect(result.snapshot.values['plugin:sample']!.counter!.value).toBe(4096) + }) it('applies unordered dependencies and duplicate delivery exactly once without mutating inputs', () => { const base = fixture(), edited = changeNote(base, { front: 'first' }), diff --git a/src/sync/merge.ts b/src/sync/merge.ts index 82cbd0b..beea1bc 100644 --- a/src/sync/merge.ts +++ b/src/sync/merge.ts @@ -80,11 +80,13 @@ export function planSync(input: SyncPlanInput, choices: Record [event.id, event])) const clock = { ...originalClock } const snapshot = cloneSyncSnapshot(input.snapshot) const remoteProjection = cloneSyncSnapshot(input.snapshot) const conflicts: SyncConflict[] = [], appliedIds: string[] = [] + const conflictsByRecord = new Map() const conflictContents = new Map() const waiting = new Map( events @@ -99,6 +101,48 @@ export function planSync(input: SyncPlanInput, choices: Record Object.entries(right).every(([device, sequence]) => sequenceAt(left, device) >= sequence) + const dependents = new Map() + const needed = new Map() + const ready: string[] = [] + const dependencyKey = (device: string, sequence: number) => JSON.stringify([device, sequence]) + function readyPush(id: string) { + ready.push(id) + let at = ready.length - 1 + while (at > 0) { + const parent = (at - 1) >>> 1 + if (ready[parent]! <= ready[at]!) break + ;[ready[parent], ready[at]] = [ready[at]!, ready[parent]!] + at = parent + } + } + function readyPop(): string { + const first = ready[0]!, + last = ready.pop()! + if (ready.length) { + ready[0] = last + let at = 0 + while (true) { + let child = at * 2 + 1 + if (child >= ready.length) break + if (child + 1 < ready.length && ready[child + 1]! < ready[child]!) child++ + if (ready[at]! <= ready[child]!) break + ;[ready[at], ready[child]] = [ready[child]!, ready[at]!] + at = child + } + } + return first + } + for (const event of waiting.values()) { + const dependencies = Object.entries(missing(event)) + needed.set(event.id, dependencies.length) + if (!dependencies.length) readyPush(event.id) + for (const [device, sequence] of dependencies) { + const key = dependencyKey(device, sequence) + const ids = dependents.get(key) ?? [] + ids.push(event.id) + dependents.set(key, ids) + } + } function conflict( event: SyncEvent, @@ -125,7 +169,7 @@ export function planSync(input: SyncPlanInput, choices: Record !Object.keys(missing(event)).length) - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - if (!ready.length) break - // Re-evaluate after every event: causally eligible does not imply arrival order. - const event = ready[0]! + while (ready.length) { + const event = waiting.get(readyPop())! for (const change of event.changes) { const rawTarget = syncStore(change.store) const rawRows = { ...(remoteProjection[rawTarget.section][rawTarget.store] ?? {}) } @@ -250,14 +294,14 @@ export function planSync(input: SyncPlanInput, choices: Record entry.store === target && entry.recordId === change.id && !entry.supersededBy, + const unresolved = (conflictsByRecord.get(JSON.stringify([target, change.id])) ?? []).filter( + (entry) => !entry.supersededBy, ) const resolves = unresolved.length > 0 && dominates(event.clock, originalClock) && unresolved.every((entry) => { - const cause = events.find((candidate) => candidate.id === entry.eventId)! + const cause = eventById.get(entry.eventId)! return sequenceAt(event.clock, cause.deviceId) >= cause.sequence }) if (resolves) for (const entry of unresolved) entry.supersededBy = event.id @@ -287,6 +331,13 @@ export function planSync(input: SyncPlanInput, choices: Record { + execFileSync( + 'cargo', + [ + 'build', + '--locked', + '--manifest-path', + 'crates/workspace-store/Cargo.toml', + '--example', + 'workspace-runtime', + ], + { stdio: 'pipe' }, + ) + const metadata = JSON.parse( + execFileSync( + 'cargo', + [ + 'metadata', + '--no-deps', + '--format-version', + '1', + '--manifest-path', + 'crates/workspace-store/Cargo.toml', + ], + { encoding: 'utf8' }, + ), + ) + binary = join( + metadata.target_directory, + 'debug/examples/workspace-runtime' + (process.platform === 'win32' ? '.exe' : ''), + ) +}) +afterEach(async () => { + for (const child of processes.splice(0)) + if (child.exitCode === null) { + child.stdin.end() + await once(child, 'exit') + } + const parent = resolve(tmpdir()) + sep + for (const root of roots.splice(0)) { + const target = resolve(root) + if ( + !target.startsWith(parent) || + !target.slice(parent.length).startsWith('repeaty-sync-native-') + ) + throw new Error('unsafe-test-cleanup') + await rm(target, { recursive: true, force: true }) + } +}) +function host(device: string) { + const child = spawn(binary, [device], { stdio: 'pipe' }) + processes.push(child) + let id = 0, + errors = '' + child.stderr.on('data', (part) => { + errors += part + }) + const pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (issue: Error) => void } + >() + createInterface({ input: child.stdout }).on('line', (line) => { + const response = JSON.parse(line), + waiter = pending.get(response.id) + if (!waiter) return + pending.delete(response.id) + if (response.error) waiter.reject(new Error(response.error)) + else waiter.resolve(response.value) + }) + child.on('exit', () => { + for (const waiter of pending.values()) + waiter.reject(new Error('Native sync fixture exited: ' + errors)) + pending.clear() + }) + const api: NativeApi = (command: string, args: Record = {}) => + new Promise((resolve, reject) => { + const requestId = ++id + pending.set(requestId, { resolve: resolve as (value: unknown) => void, reject }) + child.stdin.write(JSON.stringify({ id: requestId, command, args }) + '\n') + }) + return { + api, + async stop() { + if (child.exitCode === null) { + child.stdin.end() + await once(child, 'exit') + } + }, + } +} +async function workspace() { + const root = await mkdtemp(join(tmpdir(), 'repeaty-sync-native-')) + roots.push(root) + const path = join(root, '真实学习库🙂'), + device = join(root, 'device') + await mkdir(path) + const runtime = host(device), + status = statusSchema.parse(await runtime.api('workspace_status')) + const selected = statusSchema.parse( + await runtime.api('workspace_choose', { epoch: status.epoch, mode: 'create', path }), + ) + const active = selected.active!, + repo = createNativeRepository(active, runtime.api) + await repo.load() + return { root, path, device, runtime, active, repo } +} +type Environment = Awaited> +interface NativeState { + snapshot: SyncSnapshot + revision: number + status: { groupId: string; clock: SyncClock; pending: number } +} +interface SyncObject { + kind: 'media' | 'document' + id: string + mime: string + hash: string + size: number +} +const sync = (env: Environment, method: string, payload: unknown = {}) => + env.runtime.api('workspace_sync', { token: env.active.token, method, payload }) +async function restart(env: Environment) { + await env.runtime.stop() + env.runtime = host(env.device) + const status = statusSchema.parse(await env.runtime.api('workspace_status')) + env.active = status.active! + env.repo = createNativeRepository(env.active, env.runtime.api) + return env.repo.load() +} +async function objects(from: Environment, to: Environment) { + const inventory = await sync<{ objects: SyncObject[]; chunkSize: number }>(from, 'objects/info') + for (const object of inventory.objects) { + const progress = await sync<{ complete: boolean; received: number[] }>(to, 'objects/info', { + object, + }) + if (!progress.complete) + for (let offset = 0; offset < object.size; offset += inventory.chunkSize) { + if (progress.received.includes(offset / inventory.chunkSize)) continue + const chunk = await sync<{ offset: number; data: string; chunkHash: string }>( + from, + 'objects/read', + { object, offset }, + ) + await sync(to, 'objects/write', { object, ...chunk }) + } + await sync(to, 'objects/finish', { object }) + } +} +async function transfer(from: Environment, to: Environment) { + const status = await sync<{ receivedClock: SyncClock }>(to, 'journal/status') + const events = await sync(from, 'journal/list', { + clock: status.receivedClock, + limit: 256, + }) + if (events.length) + await sync(to, 'journal/receive', { events: [...events].reverse().concat(events.slice(0, 1)) }) + await objects(from, to) + return events +} +async function preview(env: Environment) { + const base = await sync(env, 'snapshot') + const events = await sync(env, 'journal/list', { + clock: base.status.clock, + limit: 256, + }) + return { + base, + events, + plan: planSync({ + snapshot: base.snapshot, + events, + appliedClock: base.status.clock, + groupId: base.status.groupId, + }), + } +} +async function documentBodies(env: Environment, snapshot: SyncSnapshot) { + const required = new Set( + Object.values(snapshot.retired.documents ?? {}).map( + (row) => (row.value as { hash: string }).hash, + ), + ) + const bodies: Record = Object.create(null) + if (!required.size) return bodies + const inventory = await sync<{ objects: SyncObject[]; chunkSize: number }>(env, 'objects/info') + for (const hash of required) { + const object = inventory.objects.find( + (entry) => entry.kind === 'document' && entry.hash === hash, + )! + expect(object).toBeDefined() + const parts: Buffer[] = [] + for (let offset = 0; offset < object.size; offset += inventory.chunkSize) { + const chunk = await sync<{ data: string }>(env, 'objects/read', { object, offset }) + parts.push(Buffer.from(chunk.data, 'base64')) + } + const content = Buffer.concat(parts) + expect(createHash('sha256').update(content).digest('hex')).toBe(hash) + bodies[hash] = content.toString('utf8') + } + return bodies +} +function values(before: SyncSnapshot, after: SyncSnapshot): SyncCommit['values'] { + const result: SyncCommit['values'] = Object.create(null) + for (const namespace of new Set([...Object.keys(before.values), ...Object.keys(after.values)])) { + const left = before.values[namespace] ?? {}, + right = after.values[namespace] ?? {} + for (const id of new Set([...Object.keys(left), ...Object.keys(right)])) { + const prior = Object.hasOwn(left, id) ? left[id]!.value : null, + next = Object.hasOwn(right, id) ? right[id]!.value : null + if (!sameSyncValue(prior, next)) (result[namespace] ??= Object.create(null))[id] = next + } + } + return result +} +async function persist(env: Environment, base: NativeState, plan: SyncPlan) { + expect(plan.blocked).toBe(false) + const loaded = await env.repo.load() + expect(loaded.revision).toBe(base.revision) + return env.repo.save(libraryFromSnapshot(plan.snapshot), base.revision, { + retired: snapshotToRetired(plan.snapshot, await documentBodies(env, plan.snapshot)), + sync: { + expectedClock: base.status.clock, + clock: plan.clock, + appliedIds: plan.appliedIds, + values: values(base.snapshot, plan.snapshot), + resolutions: plan.resolutionChanges, + retiredMedia: Object.fromEntries( + Object.entries(plan.snapshot.retired.media ?? {}).map(([id, row]) => [id, row.value]), + ), + }, + }) +} +async function apply(env: Environment, choice?: SyncChoice) { + const prepared = await preview(env) + let plan = prepared.plan + if (choice) + plan = planSync( + { + snapshot: prepared.base.snapshot, + events: prepared.events, + appliedClock: prepared.base.status.clock, + groupId: prepared.base.status.groupId, + }, + Object.fromEntries( + plan.conflicts.filter((entry) => entry.resolvable).map((entry) => [entry.id, choice]), + ), + ) + expect(plan.pending).toEqual([]) + if (plan.appliedIds.length || plan.blocked) await persist(env, prepared.base, plan) + return plan +} +async function pair(library: Library, assets: Parameters[2] = {}) { + const a = await workspace(), + b = await workspace(), + groupId = crypto.randomUUID() + await a.repo.save(library, 0, assets) + await sync(a, 'start', { groupId }) + await sync(b, 'start', { groupId }) + await transfer(a, b) + await apply(b) + return { a, b, groupId } +} +async function edit(env: Environment, patch: Partial) { + const state = await env.repo.load(), + note = state.library.notes[0]! + return env.repo.save( + saveNote(state.library, { ...note, ...patch }, note.id, false, now), + state.revision, + ) +} + +describe('real native storage and TypeScript domain sync', () => { + it('round-trips baseline objects, disjoint offline edits, concurrent real FSRS schedules and causal choices across two native workspaces', async () => { + const source = prepareSource({ + title: '同步材料', + format: 'markdown', + url: '', + content: '保留材料原文🙂\n正文', + }) + const original = addSource(fixture(), source.source, source.asset) + const { a, b } = await pair(original, { assets: [source.asset] }) + expect((await b.repo.load()).library).toEqual((await a.repo.load()).library) + expect(await (await b.repo.getMedia(source.asset.meta.id))!.text()).toBe( + await source.asset.blob.text(), + ) + await edit(a, { front: '设备 A 改题面' }) + await edit(b, { back: '设备 B 改答案' }) + await transfer(b, a) + await apply(a) + await transfer(a, b) + await apply(b) + expect((await b.repo.load()).library.notes[0]).toMatchObject({ + front: '设备 A 改题面', + back: '设备 B 改答案', + }) + expect((await b.repo.load()).library).toEqual((await a.repo.load()).library) + const left = await a.repo.load(), + right = await b.repo.load(), + cardId = left.library.cards[0]!.id + const leftScore = reviewCard(left.library, cardId, 1, now), + rightScore = reviewCard(right.library, cardId, 4, now) + await a.repo.save(leftScore, left.revision) + await b.repo.save(rightScore, right.revision) + await transfer(b, a) + const conflicting = await preview(a) + expect(conflicting.plan.blocked).toBe(true) + expect(conflicting.plan.conflicts.some((entry) => entry.kind === 'schedule')).toBe(true) + expect((await a.repo.load()).library.reviews).toHaveLength(1) + await apply(a, 'remote') + await transfer(a, b) + await apply(b) + const merged = (await a.repo.load()).library + expect(merged.reviews.map((review) => review.id).sort()).toEqual( + [leftScore.reviews[0]!.id, rightScore.reviews[0]!.id].sort(), + ) + expect(merged.cards[0]!.schedule).toEqual(rightScore.cards[0]!.schedule) + expect((await b.repo.load()).library).toEqual(merged) + expect((await restart(a)).library).toEqual(merged) + expect((await restart(b)).library).toEqual(merged) + expect((await sync<{ pending: number }>(b, 'journal/status')).pending).toBe(0) + }) + + it('retains missing predecessors, applies duplicated unordered score and undo events exactly once, and rejects cross-batch equivocation', async () => { + const { a, b } = await pair(fixture()) + const base = await a.repo.load(), + scored = reviewCard(base.library, base.library.cards[0]!.id, 3, now) + const saved = await a.repo.save(scored, base.revision) + await a.repo.save(undoReview(saved.library), saved.revision) + const status = await sync<{ receivedClock: SyncClock }>(b, 'journal/status') + const events = await sync(a, 'journal/list', { + clock: status.receivedClock, + limit: 256, + }) + expect(events).toHaveLength(2) + await sync(b, 'journal/receive', { events: [events[1]] }) + const waiting = await preview(b) + expect(waiting.plan.pending).toHaveLength(1) + expect(waiting.plan.appliedIds).toEqual([]) + expect((await b.repo.load()).library.reviews).toHaveLength(0) + await sync(b, 'journal/receive', { events: [events[1], events[0], events[0]] }) + await apply(b) + expect((await b.repo.load()).library.reviews).toHaveLength(0) + expect((await b.repo.load()).library.cards[0]!.schedule).toEqual( + base.library.cards[0]!.schedule, + ) + await sync(b, 'journal/receive', { events }) + expect((await preview(b)).plan.appliedIds).toEqual([]) + await expect( + sync(b, 'journal/receive', { events: [{ ...events[0], changes: [] }] }), + ).rejects.toThrow('sync-event-identity-conflict') + expect( + (await sync(b, 'journal/list', { clock: {}, limit: 256 })).filter((entry) => + events.some((event) => event.id === entry.id), + ), + ).toHaveLength(2) + await restart(b) + expect((await b.repo.load()).library.reviews).toHaveLength(0) + }) + + it('keeps complete code, remote ratings and portable plugin settings through delete/edit conflict, retirement, restoration and restart', async () => { + const plugin = { id: 'sample', version: 1, data: { prompt: '保留插件数据' } } + const original = fixture({ type: 'plugin', plugin }), + cardId = original.cards[0]!.id, + noteId = original.notes[0]!.id + original.practices = [ + { + id: 'practice', + cardId, + contentRevision: 'v1', + question: { front: 'Q', back: 'A', format: 'plain', plugin }, + startedAt: now.toISOString(), + }, + ] + original.attempts = [ + { + id: 'attempt', + practiceId: 'practice', + submittedAt: now.toISOString(), + updatedAt: now.toISOString(), + language: 'python3', + code: 'print("完整历史代码🙂")', + elapsedMs: 1000, + status: 'failed', + }, + ] + const { a, b } = await pair(original) + await a.runtime.api('workspace_write', { + token: a.active.token, + request: { + type: 'value', + namespace: 'plugin:sample', + id: 'data', + value: { retained: 'private plugin state' }, + }, + }) + await a.runtime.api('workspace_write', { + token: a.active.token, + request: { + type: 'value', + namespace: 'settings', + id: 'plugin:sample:configuration', + value: '{"mode":"retain"}', + }, + }) + await a.runtime.api('workspace_write', { + token: a.active.token, + request: { + type: 'value', + namespace: 'settings', + id: 'device-only', + value: 'never synchronized', + }, + }) + const before = await a.repo.load() + await a.repo.save(removeNotes(before.library, new Set([noteId])), before.revision) + const edited = await edit(b, { front: '并发编辑仍在事件日志里' }) + const scored = reviewCard(edited.library, cardId, 3, now) + await b.repo.save(scored, edited.revision) + await transfer(b, a) + expect((await preview(a)).plan.conflicts.some((entry) => entry.kind === 'delete-edit')).toBe( + true, + ) + await apply(a, 'local') + await transfer(a, b) + await apply(b) + expect((await b.repo.load()).library.notes).toHaveLength(0) + const archived = await b.repo.retiredNotes() + expect(archived[0]!.cards[0]!.id).toBe(cardId) + expect(archived[0]!.reviews.map((review) => review.id)).toContain(scored.reviews[0]!.id) + expect(archived[0]!.attempts[0]).toMatchObject({ code: 'print("完整历史代码🙂")' }) + const readValue = (namespace: string, id: string) => + b.runtime.api('workspace_read', { + token: b.active.token, + request: { type: 'value', namespace, id }, + }) + expect(await readValue('plugin:sample', 'data')).toEqual({ retained: 'private plugin state' }) + expect(await readValue('settings', 'plugin:sample:configuration')).toBe('{"mode":"retain"}') + expect(await readValue('settings', 'device-only')).toBeNull() + const current = await a.repo.load(), + retained = await a.repo.retiredNote(noteId) + const restored = noteLifecycle(current.library, retained.note!, noteId, retained, true, now) + await a.repo.save(restored.library, current.revision) + await transfer(a, b) + await apply(b) + expect((await b.repo.load()).library.cards[0]!.id).toBe(cardId) + expect(await b.repo.getAttempt('attempt')).toMatchObject({ code: 'print("完整历史代码🙂")' }) + await restart(a) + await restart(b) + expect((await b.repo.load()).library).toEqual((await a.repo.load()).library) + expect(await readValue('plugin:sample', 'data')).toEqual({ retained: 'private plugin state' }) + }) + + it('does not acknowledge or partially apply an incoming event when the guarded Markdown write fails', async () => { + const { a, b } = await pair(fixture()) + await edit(a, { front: '远端新正文' }) + await transfer(a, b) + const prepared = await preview(b) + expect(prepared.plan.blocked).toBe(false) + const documents = await b.runtime.api('workspace_read', { + token: b.active.token, + request: { type: 'documents' }, + }) + const document = documents[0]! + await writeFile( + join(b.path, ...document.path.split('/')), + document.raw + '\n外部尚未接受的修改', + 'utf8', + ) + await expect(persist(b, prepared.base, prepared.plan)).rejects.toThrow() + const after = await sync(b, 'snapshot') + expect(after.status.clock).toEqual(prepared.base.status.clock) + expect(after.revision).toBe(prepared.base.revision) + expect(after.snapshot).toEqual(prepared.base.snapshot) + expect(after.status.pending).toBeGreaterThan(0) + }) +}) From 79928c4f31f0c53c9774d46db795759e092ae0f6 Mon Sep 17 00:00:00 2001 From: CharlesHsu Date: Fri, 30 Oct 2026 22:19:15 +0800 Subject: [PATCH 4/5] feat(sync): add device settings, staged previews and safe apply lifecycle --- docs/DEVICE_SYNC.md | 53 +++ docs/WORKSPACE_MODE.md | 8 +- src/app/createApplication.ts | 15 + src/app/useDeviceSync.test.ts | 155 +++++++ src/app/useDeviceSync.ts | 114 +++++ src/desktop/DesktopSettings.vue | 4 +- src/desktop/SyncSettings.test.ts | 274 ++++++++++++ src/desktop/SyncSettings.vue | 672 +++++++++++++++++++++++++++++ src/desktop/device-sync.ts | 24 ++ src/i18n/en.ts | 2 + src/i18n/sync.ts | 193 +++++++++ src/platform/desktop.ts | 1 + src/platform/server.ts | 1 + src/platform/static.ts | 5 + src/settings/application.test.ts | 1 + src/settings/application.ts | 24 +- src/sync/controller.test.ts | 415 ++++++++++++++++++ src/sync/controller.ts | 697 +++++++++++++++++++++++++++++++ src/sync/ui.ts | 84 ++++ 19 files changed, 2732 insertions(+), 10 deletions(-) create mode 100644 docs/DEVICE_SYNC.md create mode 100644 src/app/useDeviceSync.test.ts create mode 100644 src/app/useDeviceSync.ts create mode 100644 src/desktop/SyncSettings.test.ts create mode 100644 src/desktop/SyncSettings.vue create mode 100644 src/desktop/device-sync.ts create mode 100644 src/i18n/sync.ts create mode 100644 src/sync/controller.test.ts create mode 100644 src/sync/controller.ts create mode 100644 src/sync/ui.ts diff --git a/docs/DEVICE_SYNC.md b/docs/DEVICE_SYNC.md new file mode 100644 index 0000000..3663ffa --- /dev/null +++ b/docs/DEVICE_SYNC.md @@ -0,0 +1,53 @@ +# 局域网设备同步 + +桌面版在「设置 → 连接 → 设备同步」提供同一局域网内的学习库同步。两台设备分别保存自己的工作目录;同步交换领域事件和按内容哈希标识的附件,不复制正在使用的 SQLite 文件,也不依赖账号或云服务。 + +## 配对与交换 + +1. 在第一台设备打开学习库,填写本机私有 IP 和端口,例如 `192.168.1.8:43127`,创建同步组并设置监听地址。回环地址只能用于本机连接。 +2. 生成邀请,将邀请交给另一台设备的使用者。邀请包含连接地址、公有证书和限时一次性票据,应只交给自己信任的设备。 +3. 在第二台设备打开目标工作目录,填写它自己的监听地址,粘贴邀请并申请加入。在第一台设备核对申请设备后批准。 +4. 选择交换更新。两端需要能够互相访问所选端口;系统防火墙可能需要允许 Repeaty 使用私有网络。 +5. 交换结束后预览收到的更新,处理冲突,再确认合入。另一台设备也需要确认合入;有冲突解决结果时再交换一次,使各端获得解决事件。 + +同步组已建立的学习库不能直接改为另一个组。可先退出同步组,再重新配对。退出停止本机连接和配对授权,保留学习内容、插件数据及归档的旧同步日志。 + +## 接收与合入 + +接收只写入事件暂存和附件对象。复习、阅读和编辑中的内容不会被远端推送替换。合入要求当前学习库没有进行中的学习或编辑操作,并再次检查预览时的库修订号和事件进度。 + +合入使用原有学习库保存事务:卡片、调度、历史、退役记录、插件数据和已应用的事件进度一起提交。失败时不提前推进已应用进度。工作目录中已被外部编辑的 Markdown 和附件仍受原有覆盖检查保护。 + +同步合入建立本地撤销边界,之前尚未执行的撤销/重做不能跨越同步改写其他设备已修改的内容。已记录在同步日志中的撤销仍按原事件传播。 + +## 冲突与历史 + +每个修改事务都有独立身份和因果向量。重复传输不产生重复评分;乱序到达的事件等待前序事件。相同事件身份携带不同内容会被拒绝。 + +不同字段的独立编辑可自动合并。同一字段的不同修改、删除与编辑冲突,需要明确选择本机或另一设备的内容。选择产生新的因果解决事件,不用设备时钟决定谁覆盖谁。 + +两台设备离线复习同一张卡时,双方独立的评分历史均保留,后续调度由用户在实际产生的两个调度分支中选择。系统不会根据缺失的历史 FSRS 参数重新编造一次评分回放。删除或退役与远端学习并发时,历史保留在退役记录中;无法满足题型、变体或引用约束的结构合并会阻止提交,不能用丢弃历史来强行通过。 + +## 附件与插件 + +附件按固定大小分块传输,每块和完整对象都核对 SHA-256。中断后保留已验证块,重试仅传缺少的块。完整对象验证完成后才登记到对象库;用户确认合入时才发布工作目录中的附件。相同内容可复用,未完成文件不能成为学习内容的引用。 + +同步包含笔记和材料数据、版本引用、完整评分与代码历史、插件题目数据,以及公开 SDK 存储的插件配置和 JSON 数据。未知或未安装插件的内容仍保留。 + +接收端根据已接受的笔记领域内容生成可编辑 Markdown。材料的属性随材料保存;笔记中不属于宿主模型的自定义 YAML 字段、注释和原始排版模板不会自动映射到新设备。已有本机模板继续由原有编辑器保留,整体文件保真请使用完整备份或工作目录复制。 + +插件代码包、插件私有文件目录、本机登录凭据、来源目录授权和代码信任不通过此同步通道传播。需要的插件在每台设备上独立安装并授权;同步不会自动执行另一台设备的代码。整体移动或备份工作目录时,仍遵守原有插件文件随库保存的规则。 + +## 连接与设备边界 + +网络监听默认关闭,应用重启后需要重新开启监听。暂停停止交换;移除设备使其在本机的已有连接和授权失效。每个请求同时检查当前工作目录作用域,切换或关闭学习库会取消旧会话。 + +邀请使用限时票据和完整证书固定,批准后的数据连接使用双向 TLS。私钥与配对授权保存在设备应用数据目录,随学习库复制不会复制本机身份。诊断只提供协议、数量和错误分类,不包含邀请、密钥、学习正文或路径。 + +当前通道面向桌面局域网直连,不提供公网中继或自动端口映射。浏览器只读工作目录不会启动监听,也不会因此写入原目录。 + +## 文件格式 + +首次建立同步组时,同一事务将工作目录的 SQLite `user_version` 提升为 18;旧客户端会拒绝写入。`workspace.json` 标记仍为 3,学习库 payload 和 JSON 备份仍为 19。只读网页可以读取结构 18 的学习数据,忽略设备同步日志。 + +同步日志及暂存对象用于幂等重试与恢复,不应手工编辑。完整备份保留库内日志;恢复出的新工作目录具有新的库身份,生成新的本机事件写入者,避免与原库重用事件序号。 diff --git a/docs/WORKSPACE_MODE.md b/docs/WORKSPACE_MODE.md index 3df9ade..a55d3a8 100644 --- a/docs/WORKSPACE_MODE.md +++ b/docs/WORKSPACE_MODE.md @@ -1,6 +1,6 @@ # 桌面工作目录 -Repeaty 以桌面客户端为主要产品。一个学习库对应一个文件夹,客户端直接打开它;不再在桌面端区分本地学习库与上游账号模式。在线页面在无后端的前提下尽可能展示可用能力;通过浏览器接口、针对性的适配或 WASM 补足部分原生能力。同步服务另行设计。 +Repeaty 以桌面客户端为主要产品。一个学习库对应一个文件夹,客户端直接打开它;不再在桌面端区分本地学习库与上游账号模式。在线页面在无后端的前提下尽可能展示可用能力;通过浏览器接口、针对性的适配或 WASM 补足部分原生能力。桌面局域网同步使用独立领域事件通道,不引入上游账号服务。 本轮按项目尚无其他使用者处理,直接建立新格式,不开发旧 IndexedDB、上游账户或实验目录的迁移和双写机制。程序不会自动读取、删除或覆盖旧库。M0 文档与测试保留为协议实验记录,不代表正式目录支持这些实验格式。 @@ -28,7 +28,7 @@ Repeaty 以桌面客户端为主要产品。一个学习库对应一个文件夹 正式目录标记格式为 3,产品版本仍为 0.27.0。格式 1 / 2 属于此前未发布的接管实现,当前不自动迁移;测试时请创建新目录。SQLite 的内部结构编号不另作产品版本。 -材料扩展使用学习库 payload 19、JSON 备份 19 和 `.repeaty` 归档清单 16(`libraryVersion: 19`)。现有正式目录的 SQLite 结构 10 可继续读取,打开目录不会改写已有学习记录。第一次成功保存材料原件信息、跨格式定位、父子关系或材料操作收据时,同一 SQLite 事务将 `user_version` 至少提升为 11;第一次保存持久撤销/重做记录或附件回收状态时至少提升为 12;第一次保存材料文件夹、属性、批注或材料草稿时至少提升为 13;文档章节与资源映射、公式定位、多区域变体和可移植退役状态使用结构 14;收件箱、来源连接、待确认候选和来源绑定使用结构 15;学习设置、数值优先级、个人 FSRS 模型、阅读/练习事件和前台活跃时长使用结构 16;自动化事务收据和虚拟引用集合使用结构 17。取消、验证失败或事务回滚不会提升。版本不会回退,旧客户端会拒绝打开,避免未知数据被旧写入器覆盖。`workspace.json` 标记仍为 3,并不表示旧应用能读取新的内部结构。网页只读打开接受内部结构 10–17,不写原目录;独立浏览器学习库使用 IndexedDB 24,旧版本写入器受到浏览器版本检查阻止。 +材料扩展使用学习库 payload 19、JSON 备份 19 和 `.repeaty` 归档清单 16(`libraryVersion: 19`)。现有正式目录的 SQLite 结构 10 可继续读取,打开目录不会改写已有学习记录。第一次成功保存材料原件信息、跨格式定位、父子关系或材料操作收据时,同一 SQLite 事务将 `user_version` 至少提升为 11;第一次保存持久撤销/重做记录或附件回收状态时至少提升为 12;第一次保存材料文件夹、属性、批注或材料草稿时至少提升为 13;文档章节与资源映射、公式定位、多区域变体和可移植退役状态使用结构 14;收件箱、来源连接、待确认候选和来源绑定使用结构 15;学习设置、数值优先级、个人 FSRS 模型、阅读/练习事件和前台活跃时长使用结构 16;自动化事务收据和虚拟引用集合使用结构 17;首次建立设备同步组使用结构 18,保存事务事件、因果进度和接收对象登记。取消、验证失败或事务回滚不会提升。版本不会回退,旧客户端会拒绝打开,避免未知数据被旧写入器覆盖。`workspace.json` 标记仍为 3,并不表示旧应用能读取新的内部结构。网页只读打开接受内部结构 10–18,不写原目录;独立浏览器学习库使用 IndexedDB 24,旧版本写入器受到浏览器版本检查阻止。 材料扩展保留不可变原件与可独立阅读的文本投影,记录提供方、可选阅读器和格式版本。定位器绑定材料 ID 与文本投影版本 ID;支持文本范围与引用、PDF 零起始页码和多页归一化区域、EPUB 章节位置、图片归一化区域。阅读位置、材料摘录和制卡出处共用该绑定;修改正文产生新版本,旧原件、旧摘录与旧阅读位置仍指向原版本。父子组织关系独立于版本,删除父级时解除关系而保留子材料。普通图片/音频附件接口不把 PDF、EPUB 或通用原件当作可播放媒体。 @@ -166,3 +166,7 @@ SQLite 接受记录与正文发布意图在同一事务提交。文件以完整 前端结构整理后重新验证:单元 491 项通过、1 项既有跳过;静态界面 78 项、桌面界面 65 项、跨语言 41 项通过。新增回归覆盖 Pinia 实例隔离、合并初始化、浅引用、卸载后的迟到订阅、关闭期间的保存与重新打开。实际打包前端资源的 Linux WebKit 客户端通过系统选择器、目录创建 / 打开 / 取消、卡片编辑与评分、自动文件检测、接受和恢复、进程重启、旧材料及图片 / 音频读取;未使用 IndexedDB。格式、类型、三种前端构建、静态产物隔离、共享 Schema、发布元数据与 28 项发布规则检查通过。结构说明见[前端结构与状态归属](FRONTEND_ARCHITECTURE.md)。本批仍未发布或安装新客户端,跨平台 GUI 验收保持待办。 收集、外部只读索引、设备授权及来源更新规则见 [收集材料与连接来源](COLLECTION.md)。外部目录授权与全文索引保存在设备,连接身份和待确认候选随库保存;恢复备份后来源保持断开,用户重新关联后再检查。 + +## 局域网设备同步 + +桌面同步通过已批准设备之间的双向 TLS 交换领域事件与内容地址附件;接收和显式合入分开,复用当前学习库事务及工作文件覆盖检查。配对、冲突、续传和设备凭据边界见 [设备同步](DEVICE_SYNC.md)。浏览器只读打开不参与网络同步、不写原目录。 diff --git a/src/app/createApplication.ts b/src/app/createApplication.ts index 6fb7d12..9781b6c 100644 --- a/src/app/createApplication.ts +++ b/src/app/createApplication.ts @@ -1,4 +1,5 @@ import { useNativeCollection } from './useNativeCollection' +import { useDeviceSync } from './useDeviceSync' import { useSourceConnections } from './useSourceConnections' import { computed, onScopeDispose, watch } from 'vue' import { t } from '../i18n' @@ -171,6 +172,19 @@ export function createApplication() { ...editor, }) const transfers = useBackupActions({ ...base, ...clock, ...navigation, ...runner }) + const deviceSync = useDeviceSync( + workspace, + computed(() => { + if (!ready.value) return '请先打开桌面工作目录。' + if (busy.value) return '学习库正在保存,请稍后再合入。' + if (navigation.review.value || navigation.mixed.value) + return '请先结束当前复习,再合入同步更新。' + if (navigation.modal.value || views.activeSource.value || views.mixedReader.value) + return '请先结束阅读或编辑,再合入同步更新。' + return null + }), + () => plugins.reloadPlugins(), + ) watch(workspace.scope, () => { navigation.resetLibrary() editor.resetEditor() @@ -201,6 +215,7 @@ export function createApplication() { pluginReviews, automationConsent, networkConsent, + deviceSync, pageTitle: computed(() => extensions.activePluginPage.value ? t(extensions.activePluginPage.value.contribution.title) diff --git a/src/app/useDeviceSync.test.ts b/src/app/useDeviceSync.test.ts new file mode 100644 index 0000000..ad0d692 --- /dev/null +++ b/src/app/useDeviceSync.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from 'vitest' +import { createApp, h, ref, shallowRef } from 'vue' +import { useDeviceSync } from './useDeviceSync' +import { + emptySyncUiState, + type DeviceSyncController, + type DeviceSyncOptions, +} from '../sync/controller' +import type { useLibrary } from '../composables/useLibrary' + +const apps: { unmount(): void }[] = [] +afterEach(() => { + for (const app of apps.splice(0)) app.unmount() + document.body.innerHTML = '' + vi.useRealTimers() +}) +const flush = async () => { + for (let index = 0; index < 12; index++) await Promise.resolve() +} +function workspace() { + return { + scope: ref('local:workspace:first'), + ready: ref(true), + close: vi.fn(async () => {}), + applyDeviceSync: vi.fn(async () => {}), + } as unknown as ReturnType +} +function service(): DeviceSyncController { + return { + state: shallowRef({ ...emptySyncUiState(), available: true }), + run: vi.fn(async () => {}), + refresh: vi.fn(async () => {}), + dispose: vi.fn(async () => {}), + } +} +function mount( + reader: ReturnType, + factory: (options: DeviceSyncOptions) => Promise, +) { + const host = document.createElement('div') + document.body.append(host) + const app = createApp({ + setup() { + useDeviceSync(reader, ref(null), undefined, factory) + return () => h('div') + }, + }) + app.mount(host) + apps.push(app) + return app +} + +it('owns status polling at app lifetime and releases it on unmount', async () => { + vi.useFakeTimers() + const controller = service(), + factory = vi.fn(async () => controller) + const app = mount(workspace(), factory) + await flush() + expect(controller.refresh).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(3_000) + expect(controller.refresh).toHaveBeenCalledTimes(2) + app.unmount() + apps.splice(apps.indexOf(app), 1) + await flush() + await vi.advanceTimersByTimeAsync(9_000) + expect(controller.refresh).toHaveBeenCalledTimes(2) + expect(controller.dispose).toHaveBeenCalledOnce() +}) + +it('awaits disposal before a remount of the same store can own native resources', async () => { + const reader = workspace(), + first = service(), + second = service() + let finish!: () => void + first.dispose = vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const firstFactory = vi.fn(async () => first), + nextFactory = vi.fn(async () => second) + mount(reader, firstFactory) + await flush() + mount(reader, nextFactory) + await flush() + expect(first.dispose).toHaveBeenCalledOnce() + expect(nextFactory).not.toHaveBeenCalled() + finish() + await flush() + expect(nextFactory).toHaveBeenCalledOnce() + expect(second.refresh).toHaveBeenCalledOnce() +}) + +it('disposes a factory response that arrives after the workspace has changed', async () => { + const reader = workspace(), + old = service(), + next = service() + let finish!: (controller: DeviceSyncController) => void + const seen: DeviceSyncOptions[] = [] + const factory = vi.fn(async (options: DeviceSyncOptions) => { + seen.push(options) + if (seen.length === 1) + return new Promise((resolve) => { + finish = resolve + }) + return next + }) + mount(reader, factory) + await flush() + ;(reader.scope as { value: string }).value = 'local:workspace:second' + await flush() + expect(seen[0]!.current()).toBe(false) + expect(seen).toHaveLength(1) + finish(old) + await flush() + expect(seen[1]!.current()).toBe(true) + expect(old.dispose).toHaveBeenCalledOnce() + expect(old.refresh).not.toHaveBeenCalled() + expect(next.refresh).toHaveBeenCalledOnce() +}) + +it('serializes a late factory disposal before a new owner of the same workspace starts', async () => { + const reader = workspace(), + old = service(), + next = service() + let finishFactory!: (value: DeviceSyncController) => void, finishDisposal!: () => void + old.dispose = vi.fn( + () => + new Promise((resolve) => { + finishDisposal = resolve + }), + ) + const firstFactory = vi.fn( + () => + new Promise((resolve) => { + finishFactory = resolve + }), + ) + const nextFactory = vi.fn(async () => next) + mount(reader, firstFactory) + await flush() + mount(reader, nextFactory) + await flush() + expect(nextFactory).not.toHaveBeenCalled() + finishFactory(old) + await flush() + expect(old.dispose).toHaveBeenCalledOnce() + expect(nextFactory).not.toHaveBeenCalled() + finishDisposal() + await flush() + expect(nextFactory).toHaveBeenCalledOnce() + expect(next.refresh).toHaveBeenCalledOnce() +}) diff --git a/src/app/useDeviceSync.ts b/src/app/useDeviceSync.ts new file mode 100644 index 0000000..2e92cb2 --- /dev/null +++ b/src/app/useDeviceSync.ts @@ -0,0 +1,114 @@ +import { computed, onScopeDispose, provide, shallowRef, watch, type Ref } from 'vue' +import { createDeviceSync } from '@repeaty-platform' +import type { useLibrary } from '../composables/useLibrary' +import { + emptySyncUiState, + syncErrorMessage, + type DeviceSyncController, + type DeviceSyncOptions, +} from '../sync/controller' +import { syncUiKey, type SyncUiController } from '../sync/ui' + +type Factory = (options: DeviceSyncOptions) => Promise +const owners = new WeakMap< + () => Promise, + { owner?: symbol; controller?: DeviceSyncController; closing: Promise } +>() + +/** App lifetime owns the transport. Visiting settings only observes this controller. */ +export function useDeviceSync( + workspace: ReturnType, + blockedReason: Readonly>, + afterApply?: () => Promise, + factory: Factory | undefined = createDeviceSync, +): SyncUiController { + const selected = shallowRef() + const fallback = shallowRef(emptySyncUiState()) + const owner = Symbol('device-sync-owner') + const shared = owners.get(workspace.close) ?? { closing: Promise.resolve() } + owners.set(workspace.close, shared) + shared.owner = owner + let alive = true, + generation = 0, + timer: ReturnType | undefined + function close() { + clearTimeout(timer) + const previous = selected.value ?? shared.controller + selected.value = undefined + if (previous) shared.closing = shared.closing.then(() => previous.dispose()).catch(() => {}) + shared.controller = undefined + } + function poll(controller: DeviceSyncController, sequence: number) { + if (!alive || shared.owner !== owner || sequence !== generation) return + timer = setTimeout(() => { + if (!alive || shared.owner !== owner || sequence !== generation) return + void controller.refresh().finally(() => poll(controller, sequence)) + }, 3_000) + } + watch( + [workspace.scope, workspace.ready], + async ([scope, ready]) => { + if (shared.owner !== owner) return + const sequence = ++generation + close() + fallback.value = emptySyncUiState() + if (!factory || !ready || !scope) return + const current = () => + alive && + shared.owner === owner && + generation === sequence && + workspace.ready.value && + workspace.scope.value === scope + try { + const setup = shared.closing.then(async () => { + if (!current()) return + const controller = await factory({ + scope, + current, + blockedReason: () => blockedReason.value, + apply: (expectedScope, revision, library, options) => + workspace.applyDeviceSync(expectedScope, revision, library, options), + afterApply, + }) + if (!current()) { + await controller.dispose() + return + } + selected.value = controller + shared.controller = controller + return controller + }) + // Include an unfinished factory in the barrier: its late disposal must precede the next owner. + shared.closing = setup.then( + () => {}, + () => {}, + ) + const controller = await setup + if (!controller || !current()) return + await controller.refresh() + poll(controller, sequence) + } catch (issue) { + if (current()) + fallback.value = { ...emptySyncUiState(), error: syncErrorMessage(issue), phase: 'error' } + } + }, + { immediate: true, flush: 'sync' }, + ) + const facade: SyncUiController = { + state: computed(() => selected.value?.state.value ?? fallback.value), + run: async (action) => { + await selected.value?.run(action) + }, + } + provide(syncUiKey, facade) + onScopeDispose(() => { + alive = false + generation++ + clearTimeout(timer) + if (shared.owner === owner) { + shared.owner = undefined + close() + } + }) + return facade +} diff --git a/src/desktop/DesktopSettings.vue b/src/desktop/DesktopSettings.vue index d87d31d..8c9852c 100644 --- a/src/desktop/DesktopSettings.vue +++ b/src/desktop/DesktopSettings.vue @@ -6,8 +6,9 @@ import { displayFilesystemPath } from './display-path' import McpSettings from './McpSettings.vue' import CaptureSettings from './CaptureSettings.vue' import DesktopExperienceSettings from './DesktopExperienceSettings.vue' +import SyncSettings from './SyncSettings.vue' import { SettingGroup } from '../components/ui' -defineProps<{ section?: 'workspace' | 'mcp' | 'capture' | 'experience' }>() +defineProps<{ section?: 'workspace' | 'mcp' | 'capture' | 'experience' | 'sync' }>() const path = computed(() => workspaceStatus.value?.active?.path) diff --git a/src/desktop/device-sync.ts b/src/desktop/device-sync.ts new file mode 100644 index 0000000..0599431 --- /dev/null +++ b/src/desktop/device-sync.ts @@ -0,0 +1,24 @@ +import { invoke } from '@tauri-apps/api/core' +import { currentWorkspace, hydrateSettings, workspaceStatus } from './workspaces' +import { restorePreferences } from '../preferences' +import { createSyncController, type DeviceSyncOptions } from '../sync/controller' + +export async function createDeviceSync(options: DeviceSyncOptions) { + const active = await currentWorkspace() + if (!options.current() || options.scope !== `local:workspace:${active.id}`) + throw new Error('sync-workspace-changed') + const current = () => options.current() && workspaceStatus.value?.active?.token === active.token + return createSyncController({ + ...options, + current, + request: (method, payload = {}) => + invoke('device_sync_request', { token: active.token, method, payload }), + async afterApply() { + if (!current()) return + await hydrateSettings() + if (!current()) return + restorePreferences() + await options.afterApply?.() + }, + }) +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index f373f75..f9c116c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -13,7 +13,9 @@ import { desktopExperienceMessages } from './desktop-experience' import { personalizationMessages } from './personalization' import { inputEnglish } from './input.en' import { appearanceMessages } from './appearance' +import { syncMessages } from './sync' export const en = { + ...syncMessages, ...inputEnglish, ...appearanceMessages, ...desktopExperienceMessages, diff --git a/src/i18n/sync.ts b/src/i18n/sync.ts new file mode 100644 index 0000000..f56a043 --- /dev/null +++ b/src/i18n/sync.ts @@ -0,0 +1,193 @@ +export const syncMessages = { + 设备同步: 'Device sync', + 添加设备或更改连接: 'Add a device or change connection', + 查看记录标识: 'View record identifier', + 此字段不存在: 'This field is absent', + 此记录已删除: 'This record was deleted', + 笔记: 'Note', + 卡片: 'Card', + 卡组: 'Deck', + 学习材料: 'Study material', + 评分记录: 'Review record', + 练习记录: 'Practice record', + 答题记录: 'Answer record', + 附件: 'Attachment', + 笔记正文: 'Note content', + 保存的搜索: 'Saved search', + 学习设置: 'Study settings', + 学习事件: 'Study event', + 学习活动: 'Study activity', + 记忆模型: 'Memory model', + 材料文件夹: 'Material folder', + 材料集合: 'Material collection', + 材料记录: 'Material record', + 材料连接: 'Material connection', + 候选材料: 'Material candidate', + 自动化记录: 'Automation record', + 插件设置: 'Plugin settings', + 插件数据: 'Plugin data', + 已退役: 'Retired', + 学习记录: 'Study record', + 问题: 'Question', + 答案: 'Answer', + 标题: 'Title', + 名称: 'Name', + 说明: 'Description', + 复习安排: 'Review schedule', + 标签: 'Tags', + 所属卡组: 'Deck', + 所属笔记: 'Note', + 来源材料: 'Source material', + 问题附件: 'Question attachments', + 答案附件: 'Answer attachments', + 正文: 'Content', + 插件内容: 'Plugin content', + 暂停状态: 'Suspension status', + 卡片变体: 'Card variant', + 顺序: 'Order', + 所属文件夹: 'Folder', + 评分: 'Rating', + 作答代码: 'Answer code', + 内容格式: 'Content format', + 内容类型: 'Content type', + 内容字段: 'Content field', + '请先打开桌面工作目录。': 'Open a desktop workspace first.', + 同步需要处理: 'Sync needs attention', + 正在处理同步: 'Sync in progress', + 同步尚未启动: 'Sync is not running', + 同步已暂停: 'Sync paused', + 等待同步: 'Ready to sync', + 尚未配对: 'Not paired', + '同步操作未完成,请重试。': 'The sync action did not finish. Please try again.', + '请输入私有局域网或回环 IP 地址与端口。': 'Enter a private LAN or loopback IP address and port.', + '无法复制邀请,请显示后手动复制。': + 'Could not copy the invitation. Reveal it and copy it manually.', + '无法显示此版本,请重新检查。': 'Could not display this version. Check again.', + 局域网同步: 'LAN sync', + '在同一局域网内交换学习库更新。收到内容后,先检查再合入。': + 'Exchange library updates on the same local network. Review received changes before applying them.', + 启动同步: 'Start sync', + 恢复同步: 'Resume sync', + 立即交换更新: 'Exchange updates now', + 暂停同步: 'Pause sync', + 取消当前操作: 'Cancel current action', + 刷新状态: 'Refresh status', + 本机设备: 'This device', + 同步组: 'Sync group', + 接收的更新: 'Received updates', + '待检查 {count} 项更新': '{count} updates to review', + '复习、阅读或编辑进行中时不能合入。先结束当前操作,学习内容才会更新。': + 'Updates cannot be applied during review, reading, or editing. Finish the current activity before updating the library.', + 待处理冲突: 'Conflicts to resolve', + 整个记录: 'Entire record', + 查看并处理: 'Review conflict', + '将合入 {changes} 项内容与 {events} 条学习事件。': + '{changes} content changes and {events} learning events will be applied.', + 预览合入: 'Preview changes', + 确认合入学习库: 'Apply to library', + 设备与配对: 'Devices and pairing', + '只与自己信任的设备配对。请核对设备标识后批准加入。': + 'Pair only with devices you trust. Verify the device identifier before approving a request.', + 本机监听地址: 'Local listening address', + '手动填写本机私有 IP 与端口,例如 192.168.1.8:43127。回环地址只用于本机连接。': + 'Enter this device’s private IP address and port, such as 192.168.1.8:43127. Loopback addresses connect only on this machine.', + 设置监听地址: 'Set listening address', + 生成配对邀请: 'Create invitation', + '邀请含有配对密钥。请通过可信方式交给另一台设备。': + 'The invitation contains a pairing secret. Share it with the other device using a trusted method.', + 隐藏邀请: 'Hide invitation', + 显示邀请: 'Reveal invitation', + 复制邀请: 'Copy invitation', + '邀请已复制。': 'Invitation copied.', + 配对邀请: 'Pairing invitation', + 另一台设备的邀请: 'Invitation from another device', + '粘贴邀请后申请加入,并在另一台设备上批准。': + 'Paste the invitation to request access, then approve it on the other device.', + 申请加入: 'Request to join', + 加入申请: 'Join requests', + 批准加入: 'Approve request', + 拒绝: 'Reject', + 已配对设备: 'Paired devices', + 移除设备: 'Remove device', + '尚未连接其他设备。': 'No other devices are connected.', + 同步诊断: 'Sync diagnostics', + '查看连接状态、协议和传输计数。诊断不包含正文、邀请密钥或凭据。': + 'Inspect connection status, protocol, and transfer counts. Diagnostics exclude content, pairing secrets, and credentials.', + 查看同步诊断: 'View sync diagnostics', + '移除“{name}”后,此设备不能继续同步。已下载的内容仍保留在对方设备上。': + 'Removing “{name}” prevents further sync. Content already downloaded remains on that device.', + 确认移除: 'Confirm removal', + 处理同步冲突: 'Resolve sync conflict', + '选择要使用的版本,再预览并确认合入。': 'Choose a version, then preview and confirm the changes.', + 本机版本: 'Local version', + 远端版本: 'Remote version', + 使用本机版本: 'Use local version', + 使用远端版本: 'Use remote version', + 退出同步组: 'Leave sync group', + 确认退出同步组: 'Confirm leaving group', + '退出后停止同步并移除本机保存的配对授权。学习内容和插件数据保留,旧同步日志归档后不再交换。': + 'Leaving stops sync and removes pairing authorizations stored on this device. Study content and plugin data are kept. Previous sync logs are archived and no longer exchanged.', + '已退出同步组,学习内容和插件数据保留。': + 'Left the sync group. Study content and plugin data are kept.', + '同步已取消,已接收的更新仍保留。': 'Sync cancelled. Received updates are kept.', + '学习库已变化,请重新预览合入。': 'The library has changed. Preview the updates again.', + '当前学习库已加入其他同步组,不能直接更换配对。': + 'This library already belongs to another sync group. Leave that group before joining another.', + '邀请无效或已过期,请重新生成。': + 'The invitation is invalid or expired. Create a new invitation.', + '对方拒绝了加入申请。': 'The other device rejected the request.', + '无法使用这个本机地址,请检查 IP、端口和网络权限。': + 'Cannot use this local address. Check the IP address, port, and network permissions.', + '请先填写并设置本机监听地址。': 'Enter and set the local listening address first.', + '设备协议不兼容,请将两端更新到兼容版本。': + 'The device protocols are incompatible. Update both devices to compatible versions.', + '单次合入最多处理 10 万条更新、10 万项变更和 64 MiB;本次未合入,待处理更新保留。': + 'A single apply can process up to 100,000 updates, 100,000 changes, and 64 MiB. Nothing was applied; pending updates are kept.', + '部分更新缺少前序记录,请与原设备继续交换。': + 'Some updates are missing earlier records. Exchange updates with the original device again.', + '请先处理冲突,再确认合入。': 'Resolve conflicts before applying the updates.', + '关联记录存在冲突,当前无法合入;双方版本仍保留。': + 'Related records conflict and cannot be applied yet. Both versions are kept.', + '所需附件尚未传输完成,请继续交换更新。': + 'Required attachments are not fully transferred. Exchange updates again.', + '请先配对另一台设备。': 'Pair with another device first.', + '请先启动同步并设置本机监听地址。': 'Start sync and set the local listening address first.', + '请先结束当前学习或编辑,再合入同步更新。': + 'Finish the current study or editing activity before applying sync updates.', + '学习库已切换,请重新打开同步设置。': 'The library has changed. Reopen sync settings.', + '同步操作未完成,请检查连接和待处理状态后重试。': + 'The sync action did not finish. Check the connection and pending updates, then try again.', + '交换完成。请预览收到的更新,再确认合入。': + 'Exchange completed. Preview the received updates before applying them.', + '同步更新已合入学习库。': 'Sync updates applied to the library.', + '同步组已创建,请设置本机监听地址。': 'Sync group created. Set the local listening address.', + '地址已准备。可以创建同步组,或使用另一台设备的邀请加入。': + 'Address prepared. Create a sync group or join using another device’s invitation.', + '加入申请已发送,请在另一台设备上批准。': 'Join request sent. Approve it on the other device.', + '设备已配对。现在可以交换更新。': 'Devices paired. You can now exchange updates.', + '学习库正在保存,请稍后再合入。': + 'The library is being saved. Apply updates after saving finishes.', + '请先结束当前复习,再合入同步更新。': 'Finish the current review before applying sync updates.', + '请先结束阅读或编辑,再合入同步更新。': 'Finish reading or editing before applying sync updates.', + '无法连接对方设备,请检查局域网地址、监听状态和防火墙。': + 'Cannot reach the other device. Check its local address, listening status, and firewall.', + '连接已断开,已收到的更新保留,可以重新交换。': + 'The connection was lost. Received updates are kept. You can exchange updates again.', + '连接等待超时,请检查对方设备后重试。': + 'The connection timed out. Check the other device and try again.', + '设备认证失败,请检查配对是否已被撤销。': + 'Device authentication failed. Check whether pairing has been revoked.', + '同步已暂停,请恢复后重试。': 'Sync is paused. Resume it and try again.', + '磁盘空间不足,已接收的分块保留,请释放空间后重试。': + 'The disk is full. Received chunks are kept. Free some space and try again.', + '附件校验失败,未合入学习库,请重新交换。': + 'Attachment verification failed. Nothing was applied. Exchange updates again.', + '同一附件标识的内容不同,已停止合入以保留原有内容。': + 'An attachment identifier has conflicting content. Applying was stopped to preserve existing content.', + '附件超过传输上限,原数据和待处理更新均保留。': + 'An attachment exceeds the transfer limit. Existing data and pending updates are kept.', + '同步已合入,但插件设置暂未刷新,请重新打开学习库。': + 'Sync updates were applied, but plugin settings could not be refreshed. Reopen the library.', + '正在提交合入,已提交的学习内容会保留。': + 'Applying updates. Study content that has been committed will be kept.', +} as const diff --git a/src/platform/desktop.ts b/src/platform/desktop.ts index cd60a94..672ec6b 100644 --- a/src/platform/desktop.ts +++ b/src/platform/desktop.ts @@ -18,6 +18,7 @@ export const WorkspaceBrowser = defineAsyncComponent( () => import('../desktop/WorkspaceManager.vue'), ) export const PlatformSettings = defineAsyncComponent(() => import('../desktop/DesktopSettings.vue')) +export { createDeviceSync } from '../desktop/device-sync' export const WorkspaceBackupTools = defineAsyncComponent( () => import('../desktop/WorkspaceRecovery.vue'), ) diff --git a/src/platform/server.ts b/src/platform/server.ts index 04f8ebc..9a5c967 100644 --- a/src/platform/server.ts +++ b/src/platform/server.ts @@ -19,6 +19,7 @@ export { DraftWorkbench, DesktopTitlebar, PlatformSettings, + createDeviceSync, workspaceAvailable, connectWorkspaceMonitor, workspacePendingCount, diff --git a/src/platform/static.ts b/src/platform/static.ts index 1ddbb6b..cb37bd4 100644 --- a/src/platform/static.ts +++ b/src/platform/static.ts @@ -4,6 +4,11 @@ import { createRepository } from '../core/storage' import type { LearningReader } from '../core/learning-reader' export const AccountControls: Component | undefined = undefined export const PlatformSettings: Component | undefined = undefined +export const createDeviceSync: + | (( + options: import('../sync/controller').DeviceSyncOptions, + ) => Promise) + | undefined = undefined export const DraftWorkbench: Component | undefined = undefined export const DesktopTitlebar: Component | undefined = undefined export const WorkspaceBackupTools: Component | undefined = undefined diff --git a/src/settings/application.test.ts b/src/settings/application.test.ts index 2cb6c03..d297a21 100644 --- a/src/settings/application.test.ts +++ b/src/settings/application.test.ts @@ -95,6 +95,7 @@ it('provides individual desktop configuration views without registering plugin m 'mcp', 'capture', 'experience', + 'sync', 'ai', 'reading-appearance', 'navigation', diff --git a/src/settings/application.ts b/src/settings/application.ts index 412078a..fe00884 100644 --- a/src/settings/application.ts +++ b/src/settings/application.ts @@ -194,17 +194,19 @@ export function createApplicationSettings(options: ApplicationSettingsOptions): } if (options.platformSettings) { const PlatformSettings = options.platformSettings - for (const section of ['workspace', 'mcp', 'capture', 'experience'] as const) { + for (const section of ['workspace', 'mcp', 'capture', 'experience', 'sync'] as const) { entries.push( entry( { id: section, get title() { - return section === 'experience' - ? t('桌面') - : section === 'capture' - ? '快捷收集' - : t(section === 'workspace' ? '工作目录' : 'MCP 连接') + return section === 'sync' + ? t('设备同步') + : section === 'experience' + ? t('桌面') + : section === 'capture' + ? '快捷收集' + : t(section === 'workspace' ? '工作目录' : 'MCP 连接') }, category: section === 'experience' @@ -213,7 +215,15 @@ export function createApplicationSettings(options: ApplicationSettingsOptions): ? [category('workspace', '工作目录')] : [ category('connections', '连接'), - { id: section, title: section === 'capture' ? '快捷收集' : t('MCP 连接') }, + { + id: section, + title: + section === 'sync' + ? t('设备同步') + : section === 'capture' + ? '快捷收集' + : t('MCP 连接'), + }, ], component: markRaw( defineComponent({ diff --git a/src/sync/controller.test.ts b/src/sync/controller.test.ts new file mode 100644 index 0000000..36a828f --- /dev/null +++ b/src/sync/controller.test.ts @@ -0,0 +1,415 @@ +import { expect, it, vi } from 'vitest' +import { emptyLibrary } from '../core/model' +import { fixture } from '../core/test-fixtures' +import { snapshotFromLibrary } from './projection' +import { createSyncController, type SyncRequest } from './controller' +import type { SyncClock, SyncEvent, SyncSnapshot } from './protocol' + +const group = 'group-one' +const event = ( + sequence: number, + before: unknown = null, + after: unknown = `value-${sequence}`, +): SyncEvent => ({ + protocol: 1, + groupId: group, + deviceId: 'other', + id: `other:${sequence}`, + sequence, + clock: { other: sequence - 1 }, + changes: [{ store: 'values:plugin:reader', id: `key-${sequence}`, before, after }], +}) +function harness(pending: SyncEvent[] = []) { + let revision = 4, + clock: SyncClock = {}, + source: SyncSnapshot = snapshotFromLibrary(emptyLibrary()) + let blocked: string | null = null, + current = true + const transport = { + deviceId: 'this-device', + enabled: true, + paused: false, + listenAddress: '192.168.1.8:43127', + group, + peers: [{ id: 'other', name: 'Other', address: '192.168.1.9:43127' }], + pending: [], + errors: [], + } + const request = vi.fn(async (method, payload) => { + if (method === 'status') + return { + transport, + journal: { groupId: group, clock, receivedClock: {}, pending: pending.length }, + } + if (method === 'snapshot') + return { + snapshot: source, + status: { groupId: group, clock, pending: pending.length }, + revision, + } + if (method === 'journal/status') + return { groupId: group, clock, pending: pending.length, receivedClock: {} } + if (method === 'journal/list') { + const args = payload as { clock: SyncClock; limit: number } + return pending + .filter((entry) => entry.sequence > (args.clock[entry.deviceId] ?? 0)) + .slice(0, args.limit) + } + if (method === 'objects/info') return { objects: [], chunkSize: 262144 } + if (method === 'cancel' || method === 'deactivate') return null + throw new Error(`unexpected:${method}`) + }) + const apply = vi.fn(async () => {}) + const controller = createSyncController({ + scope: 'library-one', + current: () => current, + blockedReason: () => blocked, + request, + apply, + }) + return { + controller, + request, + apply, + transport, + setRevision: (value: number) => { + revision = value + }, + setSnapshot: (value: SyncSnapshot) => { + source = value + }, + setBlocked: (value: string | null) => { + blocked = value + }, + setCurrent: (value: boolean) => { + current = value + }, + setClock: (value: SyncClock) => { + clock = value + }, + } +} + +it('previews all pages without applying or advancing the journal clock, then commits explicitly', async () => { + const test = harness(Array.from({ length: 130 }, (_, index) => event(index + 1))) + await test.controller.run({ type: 'refresh' }) + await test.controller.run({ type: 'preview' }) + expect(test.controller.state.value.preview).toEqual({ changes: 130, events: 130 }) + expect(test.apply).not.toHaveBeenCalled() + expect(test.request.mock.calls.filter(([method]) => method === 'journal/list')).toHaveLength(3) + await test.controller.run({ type: 'apply' }) + expect(test.apply).toHaveBeenCalledOnce() + const args = test.apply.mock.calls[0] as unknown as [ + string, + number, + unknown, + { + sync: { + expectedClock: SyncClock + clock: SyncClock + appliedIds: string[] + values: unknown + retiredMedia: unknown + } + }, + ] + expect(args[0]).toBe('library-one') + expect(args[1]).toBe(4) + expect(args[3].sync.expectedClock).toEqual({}) + expect(args[3].sync.clock).toEqual({ other: 130 }) + expect(args[3].sync.appliedIds).toHaveLength(130) + expect(args[3].sync.retiredMedia).toEqual({}) + expect(test.request.mock.calls.some(([method]) => method === 'journal/receive')).toBe(false) +}) + +it('keeps unresolved data and a selected choice until the explicit apply transaction', async () => { + const test = harness([event(1, 'original', 'remote')]) + const snapshot = snapshotFromLibrary(emptyLibrary()) + snapshot.values['plugin:reader'] = { 'key-1': { value: 'local', position: 0 } } + test.setSnapshot(snapshot) + await test.controller.run({ type: 'refresh' }) + await test.controller.run({ type: 'preview' }) + const conflict = test.controller.state.value.conflicts[0]! + expect(test.controller.state.value.preview).toBeNull() + await test.controller.run({ type: 'apply' }) + expect(test.apply).not.toHaveBeenCalled() + await test.controller.run({ type: 'resolve', id: conflict.id, choice: 'remote' }) + expect(test.controller.state.value.conflicts).toEqual([]) + expect(test.controller.state.value.preview?.events).toBe(1) + await test.controller.run({ type: 'preview' }) + expect(test.controller.state.value.conflicts).toEqual([]) + expect(test.apply).not.toHaveBeenCalled() + await test.controller.run({ type: 'apply' }) + const options = (test.apply.mock.calls[0] as unknown as unknown[])[3] as { + sync: { values: unknown; resolutions: unknown[] } + } + expect(options.sync.values).toEqual({ 'plugin:reader': { 'key-1': 'remote' } }) + expect(options.sync.resolutions.length).toBeGreaterThan(0) +}) + +it('drops outdated conflict choices when a fresh preview has different local content', async () => { + const test = harness([event(1, 'original', 'remote')]) + const source = snapshotFromLibrary(emptyLibrary()) + source.values['plugin:reader'] = { 'key-1': { value: 'local', position: 0 } } + test.setSnapshot(source) + await test.controller.run({ type: 'preview' }) + const conflict = test.controller.state.value.conflicts[0]! + expect(conflict.label).toBe('key-1') + await test.controller.run({ type: 'resolve', id: conflict.id, choice: 'remote' }) + source.values['plugin:reader']!['key-1']!.value = 'new local edit' + test.setRevision(5) + await test.controller.run({ type: 'preview' }) + expect(test.controller.state.value.error).toBeNull() + expect(test.controller.state.value.conflicts[0]?.id).not.toBe(conflict.id) + expect(test.controller.state.value.conflicts[0]?.local).toBe('new local edit') + expect(test.apply).not.toHaveBeenCalled() +}) + +it('does not keep an earlier success message when a new action fails', async () => { + const test = harness([event(1)]) + await test.controller.run({ type: 'preview' }) + await test.controller.run({ type: 'apply' }) + expect(test.controller.state.value.message).toBe('同步更新已合入学习库。') + test.request.mockRejectedValue(new Error('sync-batch-limit')) + await test.controller.run({ type: 'preview' }) + expect(test.controller.state.value.message).toBeNull() + expect(test.controller.state.value.error).toContain('本次未合入') +}) + +it('labels note conflicts with a readable question and unwraps the versions for display', async () => { + const library = fixture({ front: '**植物**如何感知光照?' }) + const note = library.notes[0]! + const pending = event(1) + pending.changes = JSON.parse( + JSON.stringify([ + { + store: 'notes', + id: note.id, + before: { ...note, front: 'Original question' }, + after: { ...note, front: 'Remote question' }, + }, + ]), + ) + const test = harness([pending]) + test.setSnapshot(snapshotFromLibrary(library)) + await test.controller.run({ type: 'preview' }) + expect(test.controller.state.value.conflicts[0]).toMatchObject({ + recordId: note.id, + label: '植物如何感知光照?', + fields: ['front'], + local: '**植物**如何感知光照?', + remote: 'Remote question', + }) +}) + +it('rechecks study state, revision and clock at the apply boundary', async () => { + const test = harness([event(1)]) + await test.controller.run({ type: 'preview' }) + test.setBlocked('editing') + await test.controller.run({ type: 'apply' }) + expect(test.apply).not.toHaveBeenCalled() + test.setBlocked(null) + test.setRevision(5) + await test.controller.run({ type: 'apply' }) + expect(test.apply).not.toHaveBeenCalled() + expect(test.controller.state.value.error).toContain('重新预览') + expect(test.controller.state.value.preview).toBeNull() + await test.controller.run({ type: 'preview' }) + test.setClock({ new: 1 }) + await test.controller.run({ type: 'apply' }) + expect(test.apply).not.toHaveBeenCalled() +}) + +it('can preview an offline backlog larger than 2048 events without silently truncating it', async () => { + const test = harness(Array.from({ length: 2049 }, (_, index) => event(index + 1))) + await test.controller.run({ type: 'preview' }) + expect(test.controller.state.value.error).toBeNull() + expect(test.controller.state.value.preview?.events).toBe(2049) + expect(test.apply).not.toHaveBeenCalled() +}) + +it('rejects a preview when the group changes without a library revision change', async () => { + const test = harness([event(1)]), + original = test.request.getMockImplementation()! + await test.controller.run({ type: 'preview' }) + test.request.mockImplementation(async (method, payload) => { + const result = await original(method, payload) + return method === 'snapshot' + ? { ...(result as object), status: { groupId: 'new-group', clock: {}, pending: 1 } } + : result + }) + await test.controller.run({ type: 'apply' }) + expect(test.apply).not.toHaveBeenCalled() + expect(test.controller.state.value.preview).toBeNull() + expect(test.controller.state.value.error).toContain('重新预览') +}) + +it('does not cancel a transaction that has entered the commit boundary', async () => { + const test = harness([event(1)]) + let finish!: () => void + test.apply.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + await test.controller.run({ type: 'preview' }) + const applying = test.controller.run({ type: 'apply' }) + await vi.waitFor(() => expect(test.apply).toHaveBeenCalledOnce()) + await test.controller.run({ type: 'cancel' }) + expect(test.request.mock.calls.some(([method]) => method === 'cancel')).toBe(false) + expect(test.controller.state.value.message).toContain('正在提交合入') + finish() + await applying + expect(test.controller.state.value.message).toBe('同步更新已合入学习库。') +}) + +it('clears invitations and previews when leaving without replacing learning data', async () => { + const test = harness([event(1)]), + original = test.request.getMockImplementation()! + let left = false + test.request.mockImplementation(async (method, payload) => { + if (method === 'leave') { + left = true + return null + } + if (method === 'invite') return { secret: 'private-invitation' } + if (method === 'status' && left) + return { + transport: { ...test.transport, group: null, enabled: false, peers: [] }, + journal: { groupId: null, clock: {}, pending: 0, receivedClock: {} }, + } + return original(method, payload) + }) + await test.controller.run({ type: 'preview' }) + await test.controller.run({ type: 'invite' }) + expect(test.controller.state.value.invite).not.toBeNull() + await test.controller.run({ type: 'leave' }) + expect(test.controller.state.value.invite).toBeNull() + expect(test.controller.state.value.preview).toBeNull() + expect(test.controller.state.value.groupId).toBeNull() + expect(test.apply).not.toHaveBeenCalled() +}) + +it('does not revive historical transport errors and clears old invitations if the group changes', async () => { + const test = harness([event(1)]), + original = test.request.getMockImplementation()! + let nextGroup = group + test.request.mockImplementation(async (method, payload) => { + if (method === 'invite') return { secret: 'private-invitation' } + if (method === 'status') + return { + transport: { ...test.transport, group: nextGroup, errors: ['sync-disconnected'] }, + journal: { groupId: nextGroup, clock: {}, pending: 1, receivedClock: {} }, + } + return original(method, payload) + }) + await test.controller.run({ type: 'refresh' }) + expect(test.controller.state.value.error).toBeNull() + await test.controller.run({ type: 'preview' }) + await test.controller.run({ type: 'invite' }) + nextGroup = 'replacement-group' + await test.controller.refresh() + expect(test.controller.state.value.invite).toBeNull() + expect(test.controller.state.value.preview).toBeNull() + expect(test.controller.state.value.error).toBeNull() +}) + +it('stages a bidirectional exchange and resumes only missing attachment chunks', async () => { + const test = harness() + const original = test.request.getMockImplementation()! + const object = { + kind: 'media', + id: 'attachment', + mime: 'application/pdf', + hash: 'a'.repeat(64), + size: 262144 * 2 + 5, + } + const written: number[] = [] + const received: string[] = [] + let sent = false + test.request.mockImplementation(async (method, payload) => { + if (method === 'objects/info') return { objects: [object], chunkSize: 262144 } + if (method === 'objects/read') { + const offset = (payload as { offset: number }).offset + return { offset, data: 'eA==', chunkHash: 'b'.repeat(64) } + } + if (method === 'journal/receive') { + received.push(...(payload as { events: SyncEvent[] }).events.map((entry) => entry.id)) + return { receivedClock: { other: 1 } } + } + if (method === 'peer') { + const peer = payload as { method: string; payload: Record } + if (peer.method === 'journal/status') + return { groupId: group, clock: {}, pending: 0, receivedClock: {} } + if (peer.method === 'journal/list') { + if (sent) return [] + sent = true + return [event(1)] + } + if (peer.method === 'objects/info') + return peer.payload.object + ? { complete: false, received: [1], chunkSize: 262144 } + : { objects: [], chunkSize: 262144 } + if (peer.method === 'objects/write') { + written.push(peer.payload.offset as number) + return { complete: false, received: [0, 1, 2], chunkSize: 262144 } + } + if (peer.method === 'objects/finish') return { complete: true } + throw new Error(`unexpected-peer:${peer.method}`) + } + return original(method, payload) + }) + await test.controller.run({ type: 'exchange' }) + expect(test.controller.state.value.error).toBeNull() + expect(written).toEqual([0, 524288]) + expect(received).toEqual(['other:1']) + expect(test.apply).not.toHaveBeenCalled() + expect(test.controller.state.value.preview).toBeNull() +}) + +it('prepares the invited group before listening and keeps invitation secrets out of errors', async () => { + const test = harness() + const original = test.request.getMockImplementation()! + test.request.mockImplementation(async (method, payload) => { + if (['prepareJoin', 'listen', 'join'].includes(method)) return {} + return original(method, payload) + }) + await test.controller.run({ type: 'listen', address: '192.168.1.11:43127' }) + expect(test.request).toHaveBeenLastCalledWith('status', {}) + test.request.mockClear() + await test.controller.run({ type: 'join', invite: '{"groupId":"group-one","secret":"private"}' }) + expect(test.request.mock.calls.slice(0, 3).map(([method]) => method)).toEqual([ + 'prepareJoin', + 'listen', + 'join', + ]) + expect(JSON.stringify(test.controller.state.value)).not.toContain('private') + test.request.mockImplementation(async () => { + throw new Error('private-secret-from-transport') + }) + await test.controller.run({ type: 'invite' }) + expect(test.controller.state.value.error).not.toContain('private-secret') +}) + +it('invalidates in-flight replies on cancellation and disposes native resources once', async () => { + const test = harness() + const original = test.request.getMockImplementation()! + let complete!: (value: unknown) => void + test.request.mockImplementation((method, payload) => + method === 'status' + ? new Promise((resolve) => { + complete = resolve + }) + : original(method, payload), + ) + const reading = test.controller.run({ type: 'refresh' }) + await test.controller.run({ type: 'cancel' }) + complete({ transport: test.transport, journal: { groupId: 'stale', clock: {}, pending: 5 } }) + await reading + expect(test.controller.state.value.groupId).toBeNull() + expect(test.controller.state.value.pending).toBe(0) + await Promise.all([test.controller.dispose(), test.controller.dispose()]) + expect(test.request.mock.calls.filter(([method]) => method === 'deactivate')).toHaveLength(1) + expect(test.controller.state.value.available).toBe(false) +}) diff --git a/src/sync/controller.ts b/src/sync/controller.ts new file mode 100644 index 0000000..07e8c4c --- /dev/null +++ b/src/sync/controller.ts @@ -0,0 +1,697 @@ +import { computed, shallowRef } from 'vue' +import { z } from 'zod' +import type { Library } from '../core/model' +import type { RetiredNote } from '../desktop/retirement' +import { planSync, type SyncChoice, type SyncConflict, type SyncPlan } from './merge' +import { libraryFromSnapshot, snapshotToRetired, syncRow, validateSyncSnapshot } from './projection' +import { + canonicalSyncValue, + parseSyncClock, + parseSyncEvents, + sameSyncValue, + SYNC_LIMITS, + type SyncClock, + type SyncEvent, + type SyncSnapshot, +} from './protocol' +import type { SyncCommit } from './commit' +import { + isPrivateSyncAddress, + type SyncAction, + type SyncUiController, + type SyncUiState, +} from './ui' + +export type SyncRequest = (method: string, payload?: unknown) => Promise +export interface DeviceSyncOptions { + scope: string + current(): boolean + blockedReason(): string | null + apply( + scope: string, + revision: number, + library: Library, + options: { retired: RetiredNote[]; sync: SyncCommit }, + ): Promise + afterApply?(): Promise +} +export interface DeviceSyncController extends SyncUiController { + refresh(): Promise + dispose(): Promise +} +export const emptySyncUiState = (): SyncUiState => ({ + available: false, + phase: 'idle', + enabled: false, + paused: false, + deviceId: '', + groupId: null, + address: '', + invite: null, + peers: [], + requests: [], + pending: 0, + conflicts: [], + preview: null, + message: null, + error: null, + blockedReason: null, +}) + +const count = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +const journalSchema = z.object({ + groupId: z.string().nullable(), + clock: z.unknown(), + pending: count, + receivedClock: z.unknown().optional(), +}) +const peerSchema = z.object({ id: z.string(), name: z.string(), address: z.string() }) +const statusSchema = z.object({ + transport: z.object({ + deviceId: z.string(), + enabled: z.boolean(), + paused: z.boolean(), + listenAddress: z.string().nullable(), + group: z.string().nullable(), + peers: z.array(peerSchema).max(32), + pending: z.array(z.object({ id: z.string(), name: z.string() })).max(128), + errors: z.array(z.string()).max(100), + }), + journal: journalSchema, +}) +const snapshotSchema = z.object({ snapshot: z.unknown(), status: journalSchema, revision: count }) +const objectSchema = z.object({ + kind: z.enum(['media', 'document']), + id: z.string().min(1).max(512), + mime: z.string().max(256), + hash: z.string().regex(/^[a-f0-9]{64}$/), + size: count, +}) +const CHUNK_BYTES = 262144 +const inventorySchema = z.object({ + objects: z.array(objectSchema).max(200_000), + chunkSize: z.literal(CHUNK_BYTES), +}) +const progressSchema = z.object({ + complete: z.boolean(), + received: z.array(count), + chunkSize: z.literal(CHUNK_BYTES), +}) +const chunkSchema = z.object({ + data: z.string().max(Math.ceil(CHUNK_BYTES / 3) * 4), + chunkHash: z.string().regex(/^[a-f0-9]{64}$/), + offset: count, +}) +const cancelled = () => new Error('sync-cancelled') +const invalid = () => new Error('sync-invalid-response') + +/** Redact exceptions by translating known codes; never echo transport or invitation payloads. */ +export function syncErrorMessage(issue: unknown): string { + const code = typeof issue === 'string' ? issue : issue instanceof Error ? issue.message : '' + const messages: Record = { + 'sync-cancelled': '同步已取消,已接收的更新仍保留。', + 'sync-preview-stale': '学习库已变化,请重新预览合入。', + 'sync-already-grouped': '当前学习库已加入其他同步组,不能直接更换配对。', + 'sync-invalid-invitation': '邀请无效或已过期,请重新生成。', + 'sync-pairing-expired': '邀请无效或已过期,请重新生成。', + 'sync-pairing-rejected': '对方拒绝了加入申请。', + 'sync-listen-failed': '无法使用这个本机地址,请检查 IP、端口和网络权限。', + 'sync-invalid-address': '请输入私有局域网或回环 IP 地址与端口。', + 'sync-address-required': '请先填写并设置本机监听地址。', + 'sync-protocol-incompatible': '设备协议不兼容,请将两端更新到兼容版本。', + 'sync-incompatible': '设备协议不兼容,请将两端更新到兼容版本。', + 'sync-unreachable': '无法连接对方设备,请检查局域网地址、监听状态和防火墙。', + 'sync-disconnected': '连接已断开,已收到的更新保留,可以重新交换。', + 'sync-timeout': '连接等待超时,请检查对方设备后重试。', + 'sync-authentication-failed': '设备认证失败,请检查配对是否已被撤销。', + 'sync-not-listening': '请先填写并设置本机监听地址。', + 'sync-paused': '同步已暂停,请恢复后重试。', + 'sync-disk-full': '磁盘空间不足,已接收的分块保留,请释放空间后重试。', + 'sync-hash-mismatch': '附件校验失败,未合入学习库,请重新交换。', + 'sync-object-conflict': '同一附件标识的内容不同,已停止合入以保留原有内容。', + 'sync-object-incomplete': '所需附件尚未传输完成,请继续交换更新。', + 'sync-object-limit': '附件超过传输上限,原数据和待处理更新均保留。', + 'sync-batch-limit': + '单次合入最多处理 10 万条更新、10 万项变更和 64 MiB;本次未合入,待处理更新保留。', + 'sync-dependency-missing': '部分更新缺少前序记录,请与原设备继续交换。', + 'sync-conflict-blocked': '请先处理冲突,再确认合入。', + 'sync-structure-conflict': '关联记录存在冲突,当前无法合入;双方版本仍保留。', + 'sync-object-missing': '所需附件尚未传输完成,请继续交换更新。', + 'sync-no-peers': '请先配对另一台设备。', + 'sync-not-enabled': '请先启动同步并设置本机监听地址。', + 'sync-study-active': '请先结束当前学习或编辑,再合入同步更新。', + 'sync-workspace-changed': '学习库已切换,请重新打开同步设置。', + } + return messages[code] ?? '同步操作未完成,请检查连接和待处理状态后重试。' +} + +export function createSyncController( + options: DeviceSyncOptions & { request: SyncRequest }, +): DeviceSyncController { + const data = shallowRef({ ...emptySyncUiState(), available: true }) + let alive = true, + epoch = 0, + active = false, + refreshing = false + let committing = false + let closing: Promise | undefined + let preview: + | { + source: SyncSnapshot + revision: number + clock: SyncClock + groupId: string + events: SyncEvent[] + plan: SyncPlan + } + | undefined + let choices: Record = Object.create(null) + const state = computed(() => ({ + ...data.value, + available: alive && options.current(), + blockedReason: options.blockedReason(), + })) + const update = (patch: Partial) => { + data.value = { ...data.value, ...patch } + } + function current(generation: number) { + if (!alive || generation !== epoch || !options.current()) throw cancelled() + } + function request(generation: number): SyncRequest { + return async (method, payload = {}) => { + current(generation) + const result = await options.request(method, payload) + current(generation) + return result + } + } + function clearPreview() { + preview = undefined + choices = Object.create(null) + update({ preview: null, conflicts: [] }) + } + async function status(call: SyncRequest) { + const result = statusSchema.parse(await call('status')) + const { transport, journal } = result + parseSyncClock(journal.clock) + if (data.value.groupId && data.value.groupId !== journal.groupId) { + clearPreview() + update({ invite: null }) + } + update({ + deviceId: transport.deviceId, + enabled: transport.enabled, + paused: transport.paused, + groupId: journal.groupId, + address: transport.listenAddress ?? data.value.address, + peers: transport.peers, + requests: transport.pending, + pending: journal.pending, + }) + return result + } + async function snapshot(call: SyncRequest) { + const raw = snapshotSchema.parse(await call('snapshot')) + validateSyncSnapshot(raw.snapshot as SyncSnapshot) + return { + source: raw.snapshot as SyncSnapshot, + revision: raw.revision, + clock: parseSyncClock(raw.status.clock), + groupId: raw.status.groupId, + } + } + async function events( + call: SyncRequest, + clock: SyncClock, + groupId: string, + ): Promise { + const cursor = { ...clock }, + collected: SyncEvent[] = [] + let bytes = 0, + changes = 0 + while (true) { + const batch = parseSyncEvents( + await call('journal/list', { clock: cursor, limit: 128 }), + groupId, + ) + if (!batch.length) return parseSyncEvents(collected, groupId) + for (const event of batch) { + if (event.sequence <= (cursor[event.deviceId] ?? 0)) throw invalid() + cursor[event.deviceId] = event.sequence + bytes += new TextEncoder().encode(canonicalSyncValue(event)).byteLength + changes += event.changes.length + if (bytes > SYNC_LIMITS.bytes || changes > SYNC_LIMITS.changes) + throw new Error('sync-batch-limit') + } + collected.push(...batch) + if (collected.length > SYNC_LIMITS.events) throw new Error('sync-batch-limit') + } + } + function showPlan(plan: SyncPlan) { + const conflicts = plan.conflicts.filter((entry) => !entry.choice && !entry.supersededBy) + const applied = new Set(plan.appliedIds) + const value = (entry: unknown) => { + const envelope = entry as { present: boolean; value?: unknown } + return envelope.present ? envelope.value : undefined + } + update({ + conflicts: conflicts.map((entry) => ({ + id: entry.id, + store: entry.store, + recordId: entry.recordId, + label: conflictLabel(entry, plan.proposedSnapshot), + fields: entry.path ? [entry.path] : [], + local: value(entry.local), + remote: value(entry.remote), + })), + preview: + plan.blocked || !plan.appliedIds.length + ? null + : { + changes: + preview?.events.reduce( + (total, event) => total + (applied.has(event.id) ? event.changes.length : 0), + 0, + ) ?? 0, + events: plan.appliedIds.length, + }, + message: plan.pending.length ? syncErrorMessage('sync-dependency-missing') : null, + }) + } + function conflictLabel(entry: SyncConflict, next: SyncSnapshot): string { + const row = (store: string, id: string) => { + const value = (syncRow(next, store, id) ?? (preview && syncRow(preview.source, store, id))) + ?.value + return value && typeof value === 'object' ? (value as Record) : undefined + } + let value = row(entry.store, entry.recordId) + if (typeof value?.practiceId === 'string') value = row('practices', value.practiceId) ?? value + if (typeof value?.cardId === 'string') + value = row('cards', value.cardId) ?? row('retired:cards', value.cardId) ?? value + if (typeof value?.noteId === 'string') + value = row('notes', value.noteId) ?? row('retired:notes', value.noteId) ?? value + const title = [value?.front, value?.title, value?.name].find( + (item): item is string => typeof item === 'string' && !!item.trim(), + ) + if (!title) + return entry.store.startsWith('values:') + ? entry.recordId.slice(0, 80) + : entry.recordId.slice(0, 12) + const text = title + .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '') + .replace(/<[^>]+>/g, '') + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\{\{c\d+::([^}:]*)(?:::[^}]*)?\}\}/g, '$1') + .replace(/[`*_#~>|]/g, '') + .replace(/\s+/g, ' ') + .trim() + return text.length > 80 ? `${text.slice(0, 80)}…` : text || entry.recordId.slice(0, 12) + } + async function buildPreview(call: SyncRequest) { + preview = undefined + update({ preview: null, conflicts: [] }) + const base = await snapshot(call) + if (!base.groupId) throw new Error('sync-not-enabled') + const pending = await events(call, base.clock, base.groupId) + const check = await snapshot(call) + if ( + check.groupId !== base.groupId || + check.revision !== base.revision || + !sameSyncValue(check.clock, base.clock) + ) + throw new Error('sync-preview-stale') + const input = { + snapshot: base.source, + events: pending, + appliedClock: base.clock, + groupId: base.groupId, + } + let plan = planSync(input) + const remaining = new Set( + plan.conflicts + .filter((entry) => entry.resolvable && !entry.supersededBy) + .map((entry) => entry.id), + ) + choices = Object.fromEntries(Object.entries(choices).filter(([id]) => remaining.has(id))) + if (Object.keys(choices).length) plan = planSync(input, choices) + preview = { ...base, groupId: base.groupId, events: pending, plan } + showPlan(plan) + } + async function transferObjects(source: SyncRequest, destination: SyncRequest) { + const { objects } = inventorySchema.parse(await source('objects/info')) + for (const object of objects) { + const progress = progressSchema.parse(await destination('objects/info', { object })) + if (!progress.complete) { + const received = new Set(progress.received) + for (let index = 0; index * CHUNK_BYTES < object.size; index++) { + if (received.has(index)) continue + const offset = index * CHUNK_BYTES + const chunk = chunkSchema.parse(await source('objects/read', { object, offset })) + if (chunk.offset !== offset) throw invalid() + progressSchema.parse(await destination('objects/write', { object, ...chunk })) + } + } + const finished = z + .object({ complete: z.literal(true) }) + .parse(await destination('objects/finish', { object })) + if (!finished.complete) throw invalid() + } + } + async function sendEvents(source: SyncRequest, destination: SyncRequest, groupId: string) { + const remote = journalSchema.parse(await destination('journal/status')) + if (remote.groupId !== groupId) throw new Error('sync-workspace-changed') + // This traversal cursor never becomes the applied frontier. The receiver acknowledges only contiguous receipt. + const cursor = parseSyncClock(remote.receivedClock ?? {}) + while (true) { + const batch = parseSyncEvents( + await source('journal/list', { clock: cursor, limit: 128 }), + groupId, + ) + if (!batch.length) return + const response = z + .object({ receivedClock: z.unknown() }) + .parse(await destination('journal/receive', { events: batch })) + parseSyncClock(response.receivedClock) + for (const event of batch) { + if (event.sequence <= (cursor[event.deviceId] ?? 0)) throw invalid() + cursor[event.deviceId] = event.sequence + } + } + } + async function exchange(call: SyncRequest) { + clearPreview() + const currentStatus = await status(call) + const groupId = currentStatus.journal.groupId + if (!groupId || !currentStatus.transport.enabled || currentStatus.transport.paused) + throw new Error('sync-not-enabled') + if (!currentStatus.transport.peers.length) throw new Error('sync-no-peers') + for (const peer of currentStatus.transport.peers) { + const remote: SyncRequest = (method, payload = {}) => + call('peer', { peerId: peer.id, method, payload }) + await sendEvents(call, remote, groupId) + await transferObjects(call, remote) + await sendEvents(remote, call, groupId) + await transferObjects(remote, call) + } + update({ message: '交换完成。请预览收到的更新,再确认合入。' }) + } + async function documentBodies(call: SyncRequest, next: SyncSnapshot) { + const required = new Set( + Object.values(next.retired.documents ?? {}).map( + (row) => (row.value as { hash: string }).hash, + ), + ) + const result: Record = Object.create(null) + const media = [ + ...Object.values(next.records.media ?? {}), + ...Object.values(next.retired.media ?? {}), + ].map((row) => row.value as { id: string; type: string; size: number }) + if (!required.size && !media.length) return result + const inventory = inventorySchema.parse(await call('objects/info')).objects + const assets = new Map( + inventory.filter((entry) => entry.kind === 'media').map((entry) => [entry.id, entry]), + ) + for (const expected of media) { + const object = assets.get(expected.id) + if ( + !object || + object.mime !== expected.type || + object.size !== expected.size || + !progressSchema.parse(await call('objects/info', { object })).complete + ) + throw new Error('sync-object-missing') + } + const documents = new Map( + inventory.filter((entry) => entry.kind === 'document').map((entry) => [entry.hash, entry]), + ) + for (const hash of required) { + const object = documents.get(hash) + if (!object) throw new Error('sync-object-missing') + if (object.size > SYNC_LIMITS.bytes) throw new Error('sync-batch-limit') + const decoder = new TextDecoder('utf-8', { fatal: true }) + let text = '' + for (let offset = 0; offset < object.size; offset += CHUNK_BYTES) { + const chunk = chunkSchema.parse(await call('objects/read', { object, offset })) + if (chunk.offset !== offset) throw invalid() + const bytes = Uint8Array.from(atob(chunk.data), (character) => character.charCodeAt(0)) + text += decoder.decode(bytes, { stream: true }) + } + result[hash] = text + decoder.decode() + } + return result + } + function changedValues(before: SyncSnapshot, after: SyncSnapshot): SyncCommit['values'] { + const result: SyncCommit['values'] = Object.create(null) + for (const namespace of new Set([ + ...Object.keys(before.values), + ...Object.keys(after.values), + ])) { + const left = before.values[namespace] ?? {}, + right = after.values[namespace] ?? {} + for (const id of new Set([...Object.keys(left), ...Object.keys(right)])) { + const old = left[id]?.value ?? null, + next = right[id]?.value ?? null + if (!sameSyncValue(old, next)) (result[namespace] ??= Object.create(null))[id] = next + } + } + return result + } + async function apply(call: SyncRequest, generation: number) { + if (options.blockedReason()) throw new Error('sync-study-active') + const pending = preview + if (!pending || pending.plan.blocked || !pending.plan.appliedIds.length) + throw new Error('sync-conflict-blocked') + const latest = await snapshot(call) + if ( + latest.groupId !== pending.groupId || + latest.revision !== pending.revision || + !sameSyncValue(latest.clock, pending.clock) + ) { + clearPreview() + throw new Error('sync-preview-stale') + } + const bodies = await documentBodies(call, pending.plan.snapshot) + const retired = snapshotToRetired(pending.plan.snapshot, bodies) + current(generation) + if (options.blockedReason()) throw new Error('sync-study-active') + committing = true + try { + await options.apply( + options.scope, + pending.revision, + libraryFromSnapshot(pending.plan.snapshot), + { + retired, + sync: { + expectedClock: pending.clock, + clock: pending.plan.clock, + appliedIds: pending.plan.appliedIds, + values: changedValues(pending.source, pending.plan.snapshot), + resolutions: pending.plan.resolutionChanges, + retiredMedia: Object.fromEntries( + Object.entries(pending.plan.snapshot.retired.media ?? {}).map(([id, row]) => [ + id, + row.value, + ]), + ), + }, + }, + ) + } finally { + committing = false + } + current(generation) + clearPreview() + update({ message: '同步更新已合入学习库。' }) + try { + await options.afterApply?.() + } catch { + if (alive && generation === epoch && options.current()) + update({ error: '同步已合入,但插件设置暂未刷新,请重新打开学习库。' }) + } + } + async function execute(action: SyncAction, call: SyncRequest, generation: number) { + switch (action.type) { + case 'refresh': + await status(call) + return + case 'start': + await call('start', { groupId: data.value.groupId ?? crypto.randomUUID() }) + if (isPrivateSyncAddress(data.value.address)) + await call('listen', { address: data.value.address }) + else update({ message: '同步组已创建,请设置本机监听地址。' }) + break + case 'listen': + if (!isPrivateSyncAddress(action.address)) throw new Error('sync-invalid-address') + update({ address: action.address }) + if (data.value.groupId) await call('listen', { address: action.address }) + else update({ message: '地址已准备。可以创建同步组,或使用另一台设备的邀请加入。' }) + break + case 'join': { + if (!isPrivateSyncAddress(data.value.address)) throw new Error('sync-address-required') + let invitation: unknown + try { + invitation = JSON.parse(action.invite) + } catch { + throw new Error('sync-invalid-invitation') + } + if (!invitation || typeof invitation !== 'object' || Array.isArray(invitation)) + throw new Error('sync-invalid-invitation') + await call('prepareJoin', { invitation }) + await call('listen', { address: data.value.address }) + update({ message: '加入申请已发送,请在另一台设备上批准。' }) + await call('join', { invitation }) + update({ message: '设备已配对。现在可以交换更新。' }) + break + } + case 'invite': + update({ invite: JSON.stringify(await call('invite')) }) + break + case 'leave': + await call('leave') + clearPreview() + update({ + invite: null, + groupId: null, + peers: [], + requests: [], + pending: 0, + message: '已退出同步组,学习内容和插件数据保留。', + }) + break + case 'approve': + case 'reject': + case 'revoke': + await call(action.type, { id: action.id }) + break + case 'pause': + case 'resume': + await call(action.type) + break + case 'exchange': + await exchange(call) + break + case 'preview': + await buildPreview(call) + break + case 'resolve': { + const conflict = preview?.plan.conflicts.find( + (entry) => entry.id === action.id && !entry.supersededBy, + ) + if (!conflict || !preview) throw new Error('sync-preview-stale') + if (!conflict.resolvable) throw new Error('sync-structure-conflict') + choices[action.id] = action.choice + preview.plan = planSync( + { + snapshot: preview.source, + events: preview.events, + appliedClock: preview.clock, + groupId: data.value.groupId ?? undefined, + }, + choices, + ) + showPlan(preview.plan) + break + } + case 'apply': + await apply(call, generation) + break + case 'diagnostics': { + const report = z + .object({ + protocol: count, + enabled: z.boolean(), + paused: z.boolean(), + peerCount: count, + pendingCount: count, + errors: z.array(z.string()).max(100), + }) + .parse(await call('diagnostics')) + update({ + message: JSON.stringify( + { + protocol: report.protocol, + enabled: report.enabled, + paused: report.paused, + pairedDevices: report.peerCount, + joinRequests: report.pendingCount, + issues: report.errors.map(syncErrorMessage), + }, + null, + 2, + ), + }) + break + } + case 'cancel': + return + } + await status(call) + } + async function run(action: SyncAction) { + if (!alive || !options.current()) return + if (action.type === 'cancel') { + if (committing) { + update({ message: '正在提交合入,已提交的学习内容会保留。' }) + return + } + const generation = ++epoch + active = true + clearPreview() + update({ phase: 'busy', error: null, message: syncErrorMessage('sync-cancelled') }) + await options.request('cancel', {}).catch(() => {}) + if (alive && generation === epoch && options.current()) { + active = false + update({ phase: 'idle' }) + } + return + } + if (active) return + active = true + const generation = ++epoch + update({ phase: 'busy', error: null, ...(action.type === 'refresh' ? {} : { message: null }) }) + try { + await execute(action, request(generation), generation) + } catch (issue) { + if (alive && generation === epoch && options.current()) + update({ phase: 'error', error: syncErrorMessage(issue) }) + } finally { + if (alive && generation === epoch) { + active = false + if (data.value.phase === 'busy') update({ phase: 'idle' }) + } + } + } + return { + state, + run, + async refresh() { + if (!alive || active || refreshing || !options.current()) return + const generation = epoch + refreshing = true + try { + await status(request(generation)) + } catch (issue) { + if (alive && generation === epoch && options.current()) + update({ error: syncErrorMessage(issue) }) + } finally { + refreshing = false + } + }, + dispose() { + if (closing) return closing + alive = false + epoch++ + active = false + preview = undefined + choices = Object.create(null) + data.value = emptySyncUiState() + closing = options + .request('cancel', {}) + .catch(() => {}) + .then(() => options.request('deactivate', {}).catch(() => {})) + .then(() => {}) + return closing + }, + } +} diff --git a/src/sync/ui.ts b/src/sync/ui.ts new file mode 100644 index 0000000..5795574 --- /dev/null +++ b/src/sync/ui.ts @@ -0,0 +1,84 @@ +import type { InjectionKey, Ref } from 'vue' + +export interface SyncUiState { + available: boolean + phase: 'idle' | 'busy' | 'error' + enabled: boolean + paused: boolean + deviceId: string + groupId: string | null + address: string + invite: string | null + peers: Array<{ id: string; name: string; address: string }> + requests: Array<{ id: string; name: string }> + pending: number + conflicts: Array<{ + id: string + store: string + recordId: string + label?: string + fields: string[] + local: unknown + remote: unknown + }> + preview: { changes: number; events: number } | null + message: string | null + error: string | null + blockedReason: string | null +} + +export type SyncAction = + | { + type: + | 'refresh' + | 'start' + | 'pause' + | 'resume' + | 'invite' + | 'exchange' + | 'preview' + | 'apply' + | 'cancel' + | 'diagnostics' + | 'leave' + } + | { type: 'listen'; address: string } + | { type: 'join'; invite: string } + | { type: 'approve' | 'reject' | 'revoke'; id: string } + | { type: 'resolve'; id: string; choice: 'local' | 'remote' } + +export interface SyncUiController { + state: Readonly> + run(action: SyncAction): Promise +} + +export const syncUiKey: InjectionKey = Symbol('sync-ui') + +/** Input feedback only. The native transport must independently enforce its address policy. */ +export function isPrivateSyncAddress(input: string): boolean { + const match = /^(\[[\da-f:]+\]|[\d.]+):(\d{1,5})$/i.exec(input) + if (!match || Number(match[2]) < 1 || Number(match[2]) > 65535) return false + const host = match[1]! + if (host.startsWith('[')) { + try { + const canonical = new URL(`http://${input}`).hostname.slice(1, -1) + return canonical === '::1' || /^(?:f[cd][\da-f]{2}|fe[89ab][\da-f]):/i.test(canonical) + } catch { + return false + } + } + const octets = host.split('.') + if ( + octets.length !== 4 || + octets.some((octet) => !/^(?:0|[1-9]\d{0,2})$/.test(octet) || Number(octet) > 255) + ) + return false + const [first, second] = octets.map(Number) + return ( + first === 10 || + first === 127 || + (first === 172 && second! >= 16 && second! <= 31) || + (first === 192 && second === 168) || + (first === 169 && second === 254) + ) +} From 43b45f50f2d021d13e2eac96f687f75aa32061e0 Mon Sep 17 00:00:00 2001 From: CharlesHsu Date: Fri, 30 Oct 2026 22:35:07 +0800 Subject: [PATCH 5/5] fix(sync): bind conflict choices to exact versions and clarify schedules --- crates/workspace-store/src/sync.rs | 4 +- docs/DEVICE_SYNC.md | 2 + src/desktop/SyncSettings.test.ts | 24 ++++++++ src/desktop/SyncSettings.vue | 92 +++++++++++++++++++++++++----- src/i18n/sync.ts | 12 +++- src/sync/controller.test.ts | 31 ++++++++++ src/sync/controller.ts | 73 +++++++++++++++++------- src/sync/merge.test.ts | 36 +++++++++++- src/sync/merge.ts | 17 ++++-- 9 files changed, 243 insertions(+), 48 deletions(-) diff --git a/crates/workspace-store/src/sync.rs b/crates/workspace-store/src/sync.rs index 21b82b7..b5797de 100644 --- a/crates/workspace-store/src/sync.rs +++ b/crates/workspace-store/src/sync.rs @@ -651,8 +651,8 @@ mod tests { let group = uuid::Uuid::new_v4().to_string(); begin(&mut lib, &group); let event = json!({"protocol":1,"groupId":group,"id":"remote:1","deviceId":"remote","sequence":1,"clock":{"remote":0},"changes":[{"store":"values:plugin:sample","id":"a","before":null,"after":"remote","position":0}]}); - receive(&mut lib.db.connection, &[event.clone()]).unwrap(); - receive(&mut lib.db.connection, &[event.clone()]).unwrap(); + receive(&mut lib.db.connection, std::slice::from_ref(&event)).unwrap(); + receive(&mut lib.db.connection, std::slice::from_ref(&event)).unwrap(); assert_eq!(status(&lib.db.connection).unwrap()["pending"], 1); assert!( snapshot(&lib.db.connection).unwrap()["values"] diff --git a/docs/DEVICE_SYNC.md b/docs/DEVICE_SYNC.md index 3663ffa..ed8e3dd 100644 --- a/docs/DEVICE_SYNC.md +++ b/docs/DEVICE_SYNC.md @@ -51,3 +51,5 @@ 首次建立同步组时,同一事务将工作目录的 SQLite `user_version` 提升为 18;旧客户端会拒绝写入。`workspace.json` 标记仍为 3,学习库 payload 和 JSON 备份仍为 19。只读网页可以读取结构 18 的学习数据,忽略设备同步日志。 同步日志及暂存对象用于幂等重试与恢复,不应手工编辑。完整备份保留库内日志;恢复出的新工作目录具有新的库身份,生成新的本机事件写入者,避免与原库重用事件序号。 + +单次预览最多处理 10 万条事务、10 万项记录变更和 64 MiB 事件内容。超过限制会明确停止预览,保留已接收日志,不部分合入、不推进应用进度;当前不会自动拆开一个超过预览上限的待处理集合。附件独立分块传输,继续使用现有图片/音频和文档大小上限。 diff --git a/src/desktop/SyncSettings.test.ts b/src/desktop/SyncSettings.test.ts index ee0ce25..0b5e3a8 100644 --- a/src/desktop/SyncSettings.test.ts +++ b/src/desktop/SyncSettings.test.ts @@ -202,6 +202,30 @@ it('requires confirmation before revoking a paired device and dispatches approva expect(run).toHaveBeenLastCalledWith({ type: 'revoke', id: 'device-other' }) }) +it('summarizes a schedule choice while keeping both review histories and hiding technical details', async () => { + const { run } = await mount({ + conflicts: [ + { + id: 'schedule-choice', + store: 'cards', + recordId: 'card-one', + label: 'A question', + fields: ['schedule'], + local: { due: '2026-11-01T10:00:00Z', reps: 5, lapses: 1 }, + remote: { due: '2026-11-02T10:00:00Z', reps: 6, lapses: 2 }, + }, + ], + }) + await click('查看并处理') + await vi.waitFor(() => expect(document.body.textContent).toContain('两边的评分历史都会保留')) + expect(document.body.textContent).toContain('下次复习时间') + expect(document.body.textContent).toContain('复习次数') + expect(document.body.textContent).toContain('遗忘次数') + expect(document.body.textContent).not.toContain('"due"') + await click('使用本机版本') + expect(run).toHaveBeenLastCalledWith({ type: 'resolve', id: 'schedule-choice', choice: 'local' }) +}) + it('clears invitation input on a workspace change and ignores late failures', async () => { let reject!: (reason: Error) => void const { host, state } = await mount( diff --git a/src/desktop/SyncSettings.vue b/src/desktop/SyncSettings.vue index 82bf28b..88bba9c 100644 --- a/src/desktop/SyncSettings.vue +++ b/src/desktop/SyncSettings.vue @@ -1,6 +1,6 @@