Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0bbab6d
feat(contacts): add encrypted desktop device sync
zqchris Jul 31, 2026
1fa2b4d
Merge remote-tracking branch 'upstream/main' into contacts-device-sync
zqchris Jul 31, 2026
49fd4ec
fix(contacts): defer sync reconciliation until send
zqchris Jul 31, 2026
ead6855
Merge remote-tracking branch 'upstream/main' into contacts-device-sync
zqchris Jul 31, 2026
432468f
fix(contacts): align sync plural keys
zqchris Jul 31, 2026
d8dd46e
fix(contacts): harden device sync boundaries
zqchris Jul 31, 2026
655ef41
fix: harden contacts sync state and notifications
zqchris Jul 31, 2026
fb2ff05
Merge remote-tracking branch 'upstream/main' into contacts-device-sync
zqchris Jul 31, 2026
a66f2fb
fix: address contacts sync review feedback
zqchris Jul 31, 2026
746ce8c
fix: close contacts sync handshake gaps
zqchris Jul 31, 2026
a13857d
fix: harden contacts sync activation and fanout
zqchris Jul 31, 2026
2055fcf
Merge remote-tracking branch 'upstream/main' into contacts-device-sync
zqchris Jul 31, 2026
d487f98
fix: address review — drain relay backpressure
zqchris Jul 31, 2026
70dbbf0
fix: preserve contacts settings and FTS recovery
zqchris Jul 31, 2026
6c50f4e
Merge remote-tracking branch 'upstream/main' into contacts-device-sync
zqchris Jul 31, 2026
33d5311
fix: address review — serialize keys and repair projections
zqchris Jul 31, 2026
ac62db9
fix: address review — batch FTS initialization
zqchris Jul 31, 2026
68e1ae4
fix: address review — sync passive instance status
zqchris Jul 31, 2026
37d8eec
fix: address review — avoid blocking key lock
zqchris Jul 31, 2026
860c60d
test: fix key preparation mock type
zqchris Jul 31, 2026
9df8e1d
fix: address contacts sync review feedback
zqchris Jul 31, 2026
95c45f9
fix(contacts): harden device sync worker boundaries
zqchris Jul 31, 2026
cbfc67f
fix(contacts): align normalized identity limits
zqchris Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/desktop/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,8 @@ const config: ForgeConfig = {
'Cindy uses Apple Events to read Contacts you import and to add or update Contacts you explicitly export.',
NSContactsUsageDescription:
'Cindy accesses Contacts only when you import them or explicitly export additions or updates.',
NSLocalNetworkUsageDescription:
'Cindy uses your local network to sync end-to-end encrypted Smart Contacts directly between your online desktop devices.',
CFBundleDocumentTypes: [
{
CFBundleTypeName: 'Folder',
Expand Down Expand Up @@ -1334,6 +1336,12 @@ const config: ForgeConfig = {
// SILK/WASM 解码隔离在线程中,避免阻塞 Electron main。
target: 'preload',
},
{
entry: 'src/main/contacts-sync/contactsSyncCodecWorker.ts',
config: 'vite.contacts-sync-codec-worker.config.ts',
// 大通讯录 JSON/gzip/crypto 隔离在线程中,避免阻塞 Electron main。
target: 'preload',
},
{
entry: 'src/main/watcher-host/watcherHostProcess.ts',
config: 'vite.watcher-host.config.ts',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { EventEmitter } from 'node:events';
import { randomUUID } from 'node:crypto';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const harness = vi.hoisted(() => ({
workers: 0,
activeWorkers: 0,
maxActiveWorkers: 0,
terminated: 0,
respond: false,
responseDelayMs: 0,
responseData: undefined as unknown,
requests: [] as Array<Record<string, unknown>>,
transferLists: [] as Array<ArrayBuffer[]>,
}));

vi.mock('node:worker_threads', () => ({
Worker: class extends EventEmitter {
constructor() {
super();
harness.workers += 1;
harness.activeWorkers += 1;
harness.maxActiveWorkers = Math.max(harness.maxActiveWorkers, harness.activeWorkers);
}

postMessage(request: Record<string, unknown>, transferList: ArrayBuffer[] = []): void {
harness.requests.push(request);
harness.transferLists.push(transferList);
if (!harness.respond) return;
const respond = () => {
this.emit('message', {
id: request.id,
ok: true,
data:
harness.responseData ??
(request.type === 'encode'
? {
transferId: randomUUID(),
total: 1,
iv: Buffer.alloc(12).toString('base64'),
tag: Buffer.alloc(16).toString('base64'),
ciphertext: new Uint8Array([1, 2, 3]),
materialized: false,
}
: { materialized: false }),
});
};
if (harness.responseDelayMs > 0) setTimeout(respond, harness.responseDelayMs);
else queueMicrotask(respond);
}

async terminate(): Promise<number> {
harness.terminated += 1;
harness.activeWorkers -= 1;
return 0;
}
},
}));

const { generateContactsSyncIdentity } = await import('../crypto.js');
const { workerContactsSyncCodec } = await import('../contactsSyncCodecWorkerClient.js');

describe('contacts sync codec worker client', () => {
beforeEach(() => {
harness.workers = 0;
harness.activeWorkers = 0;
harness.maxActiveWorkers = 0;
harness.terminated = 0;
harness.respond = false;
harness.responseDelayMs = 0;
harness.responseData = undefined;
harness.requests = [];
harness.transferLists = [];
});

it('marks an active database worker cancelled and waits for its SQLite task to unwind', async () => {
harness.respond = true;
harness.responseDelayMs = 100;
const own = generateContactsSyncIdentity();
const peer = generateContactsSyncIdentity();
const controller = new AbortController();
const task = workerContactsSyncCodec.encode(
{
database: { source: { dbPath: '/tmp/contacts.db' } },
ownPrivateKey: own.privateKey,
ownPublicKey: own.publicKey,
peerPublicKey: peer.publicKey,
srcDeviceId: 'device-a',
dstDeviceId: 'device-b',
},
controller.signal,
);
await vi.waitFor(() => expect(harness.requests).toHaveLength(1));
controller.abort();

const cancellation = harness.requests[0]?.cancellation;
expect(cancellation).toBeInstanceOf(SharedArrayBuffer);
expect(Atomics.load(new Int32Array(cancellation as SharedArrayBuffer), 0)).toBe(1);
await expect(task).rejects.toMatchObject({ name: 'AbortError' });
expect(harness.terminated).toBe(1);
});

it('production encode sends only a database descriptor and receives bounded bytes', async () => {
harness.respond = true;
const own = generateContactsSyncIdentity();
const peer = generateContactsSyncIdentity();
const result = await workerContactsSyncCodec.encode({
database: {
source: { dbPath: '/tmp/contacts.db' },
knownClocks: [{ nodeId: 'node-a', counter: 2 }],
requestReply: true,
},
ownPrivateKey: own.privateKey,
ownPublicKey: own.publicKey,
peerPublicKey: peer.publicKey,
srcDeviceId: 'device-a',
dstDeviceId: 'device-b',
});

expect(result.frames).toHaveLength(1);
expect(harness.requests[0]).toMatchObject({
type: 'encode',
options: {
database: { source: { dbPath: '/tmp/contacts.db' } },
},
});
expect((harness.requests[0]?.options as { message?: unknown }).message).toBeUndefined();
expect(harness.transferLists[0]).toEqual([]);
});

it('serializes database-bound workers while leaving the general worker budget independent', async () => {
harness.respond = true;
harness.responseDelayMs = 100;
const own = generateContactsSyncIdentity();
const peer = generateContactsSyncIdentity();
const options = {
database: { source: { dbPath: '/tmp/contacts.db' } },
ownPrivateKey: own.privateKey,
ownPublicKey: own.publicKey,
peerPublicKey: peer.publicKey,
srcDeviceId: 'device-a',
dstDeviceId: 'device-b',
};

const tasks = [
workerContactsSyncCodec.encode(options),
workerContactsSyncCodec.encode(options),
];
await vi.waitFor(() => expect(harness.requests).toHaveLength(1));
expect(harness.maxActiveWorkers).toBe(1);
await Promise.all(tasks);

expect(harness.requests).toHaveLength(2);
expect(harness.maxActiveWorkers).toBe(1);
});

it.each([
[[{ nodeId: 'node-a', counter: 0 }], 'zero counter'],
[[{ nodeId: 'node with spaces', counter: 1 }], 'invalid node id'],
[
[
{ nodeId: 'node-a', counter: 1 },
{ nodeId: 'node-a', counter: 2 },
],
'duplicate node id',
],
])('rejects applied-state clocks with %s', async (clocks) => {
harness.respond = true;
harness.responseData = {
version: 1,
type: 'applied-state',
changed: true,
clocks,
};
const own = generateContactsSyncIdentity();
const peer = generateContactsSyncIdentity();

await expect(
workerContactsSyncCodec.decode({
ciphertext: new Uint8Array([1, 2, 3]),
iv: Buffer.alloc(12).toString('base64'),
tag: Buffer.alloc(16).toString('base64'),
ownPrivateKey: own.privateKey,
expectedPeerPublicKey: peer.publicKey,
srcDeviceId: 'device-b',
dstDeviceId: 'device-a',
transferId: randomUUID(),
totalChunks: 1,
databaseSource: { dbPath: '/tmp/contacts.db' },
}),
).rejects.toThrow(/invalid contacts sync decode result/);
});

it('bounds the global queue and aborts active plus queued owner work', async () => {
const own = generateContactsSyncIdentity();
const peer = generateContactsSyncIdentity();
const options = {
message: { version: 1 as const, type: 'state' as const, state: { contacts: [] } },
ownPrivateKey: own.privateKey,
ownPublicKey: own.publicKey,
peerPublicKey: peer.publicKey,
srcDeviceId: 'device-a',
dstDeviceId: 'device-b',
};
const controllers = Array.from({ length: 10 }, () => new AbortController());
const tasks = controllers.map((controller) =>
workerContactsSyncCodec.encode(options, controller.signal),
);
await vi.waitFor(() => expect(harness.workers).toBe(2));

await expect(
workerContactsSyncCodec.encode(options, new AbortController().signal),
).rejects.toThrow(/queue is full/);

for (const controller of controllers) controller.abort();
const results = await Promise.allSettled(tasks);
expect(results).toHaveLength(10);
expect(
results.every(
(result) => result.status === 'rejected' && result.reason?.name === 'AbortError',
),
).toBe(true);
});
});
Loading