dsh-webdav-sync 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/engine.js ADDED
@@ -0,0 +1,1042 @@
1
+ /**
2
+ * One sync, and the three actions that go around it.
3
+ *
4
+ * The design comes from magpie's `internal/davsync`. The setup travels in parts,
5
+ * each hashed on its own, and one sync is a three-way merge per part between what
6
+ * the server last held, what it holds now, and what is here. A part changed only
7
+ * here is pushed; one changed only there is brought in; one changed on both keeps
8
+ * the newer and keeps the other beside it under `sync/`. What the server holds is
9
+ * mirrored, so a row removed on one computer goes from the others too.
10
+ *
11
+ * The reason for each step is written where the step is, because most of them
12
+ * exist for a case that is not obvious from the code: a server that says "no file"
13
+ * with 409, a write that went out without the server checking its version, a body
14
+ * a relay cut in half.
15
+ */
16
+ import { readFile } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+ import { BUNDLE_APP, BUNDLE_FORMAT, BUNDLE_VERSION, PARTS, REMOTE_FILE, } from './wire.js';
19
+ import { CorruptError, NotBackupError, PassphraseError, hashJson, open, seal, sum } from './crypto.js';
20
+ import { Dav, LoginError, RateLimitedError } from './dav.js';
21
+ import { applyManifest, applyPatch, collect, isMissing, keepAside, message, missingPlugins, readManifest, readPatch, writeIfChanged, } from './store.js';
22
+ import { cached, ensureStateDir, keptDir, loadConfig, loadState, normaliseUrl, releasePassword, remember, sameAccount, saveConfig, saveState, stateKey, syncsPart, usable, } from './state.js';
23
+ import { resolvePaths } from './paths.js';
24
+ /**
25
+ * One operation at a time, per process.
26
+ *
27
+ * The state file is read, decided on and written without a lock of its own, so
28
+ * two syncs running together would each merge against a state the other is about
29
+ * to replace. They queue here instead. Two DSH windows at once are a different
30
+ * matter, and are caught by the ETag check on the write.
31
+ */
32
+ let queue = Promise.resolve();
33
+ function serially(work) {
34
+ const next = queue.then(work, work);
35
+ // The chain keeps going whether or not this call failed, so one failure never
36
+ // wedges every sync after it.
37
+ queue = next.then(() => undefined, () => undefined);
38
+ return next;
39
+ }
40
+ /** The engine for a profile directory and a configuration. */
41
+ export function engineFor(paths, config) {
42
+ return { paths, config };
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // Sync
46
+ // ---------------------------------------------------------------------------
47
+ /**
48
+ * Run one merge.
49
+ *
50
+ * `force` syncs even when neither side looks changed, which is what the card's
51
+ * own button does: the alternative is a button that appears to do nothing.
52
+ */
53
+ export async function sync(engine, options = {}) {
54
+ return guarded(engine, async () => {
55
+ const { paths, config } = engine;
56
+ if (!usable(config))
57
+ throw new Error('同步还没有配置好:请先填写地址和口令');
58
+ await ensureStateDir(paths);
59
+ const state = await freshState(paths, config);
60
+ const outcome = await syncOnce(engine, state, options.force === true);
61
+ state.last = new Date().toISOString();
62
+ state.error = '';
63
+ await saveState(paths, state);
64
+ return outcome;
65
+ });
66
+ }
67
+ /**
68
+ * Run one of the four operations, recording why it failed when it does.
69
+ *
70
+ * The whole operation goes through {@link serially}: reading the state, deciding
71
+ * on it and writing it back is not atomic on its own, so two of them overlapping
72
+ * would each merge against a state the other is about to replace. Recording the
73
+ * failure is inside the same turn for the same reason — it reads and writes the
74
+ * state file too.
75
+ */
76
+ async function guarded(engine, work) {
77
+ return serially(async () => {
78
+ try {
79
+ return await work();
80
+ }
81
+ catch (cause) {
82
+ await recordFailure(engine, cause);
83
+ throw cause;
84
+ }
85
+ });
86
+ }
87
+ /** The state for this configuration, emptied when it describes a different one. */
88
+ async function freshState(paths, config) {
89
+ const state = await loadState(paths);
90
+ const key = stateKey(config);
91
+ if (state.key !== key) {
92
+ // Another folder, account or passphrase: the hashes describe a file that is
93
+ // no longer the one being synced, so they are no baseline for anything.
94
+ return { key };
95
+ }
96
+ return state;
97
+ }
98
+ async function recordFailure(engine, cause) {
99
+ try {
100
+ const state = await loadState(engine.paths);
101
+ state.last = new Date().toISOString();
102
+ state.error = message(cause);
103
+ // The failure is recorded against the configuration that failed, not against
104
+ // whichever one was synced last. A wrong passphrase changes the state key and
105
+ // so would never match the stored one — and a failed sync that left no trace
106
+ // is the one kind of failure a person cannot act on. The baseline hashes are
107
+ // left exactly as they are: this run learned nothing about the server.
108
+ state.errorKey = stateKey(engine.config);
109
+ await saveState(engine.paths, state);
110
+ }
111
+ catch {
112
+ // Recording why a sync failed must not itself fail the call.
113
+ }
114
+ }
115
+ async function syncOnce(engine, state, force) {
116
+ const { paths, config } = engine;
117
+ const dav = new Dav({ url: config.url, user: config.user, password: config.password });
118
+ const read = await dav.get(state.server);
119
+ // A write that went out without the server checking its version may have
120
+ // overlapped another computer's, and which of the two landed cannot be told
121
+ // from here. The state is rolled back to what it was before that write, so the
122
+ // file on the server is merged against rather than mistaken for one already
123
+ // seen.
124
+ //
125
+ // Rolling back is only right while the file on the server is still somebody
126
+ // else's: when the bytes there are the very ones this computer wrote, nothing
127
+ // overlapped and the hashes taken after that write are the true ones. Rolling
128
+ // back regardless would throw away the baseline of every sync on a server that
129
+ // answers without an ETag — the write is blind there every time — and the
130
+ // baseline it throws away is what makes the next local edit look like an edit
131
+ // at all. Clearing the guard either way is part of the same care: it describes
132
+ // one write, and a guard that outlives it turns every later sync into a first
133
+ // one.
134
+ if (state.blind !== undefined) {
135
+ if (read.notModified !== true && (read.data === undefined || sum(read.data) !== state.blind.sum)) {
136
+ state.remote = state.blind.remote;
137
+ state.local = state.blind.local;
138
+ }
139
+ state.blind = undefined;
140
+ }
141
+ const snapshot = await take(paths);
142
+ const local = hashesOf(snapshot.parts);
143
+ let remote = await openRemote(engine, state, read);
144
+ const first = state.local === undefined;
145
+ if (remote.notModified && sameHashes(local, state.local) && !force) {
146
+ // Neither side moved: the last sync is still the state of the world.
147
+ return { here: [], there: [], pushed: false, reloadNeeded: false, ...(await pendings(engine, snapshot.parts.plugins)) };
148
+ }
149
+ if (remote.notModified) {
150
+ // The server said the file is the one last read, which was worth knowing for
151
+ // the check above — but something here moved, so the file's own parts are
152
+ // needed to merge against and it is read again, this time in full.
153
+ remote = await openRemote(engine, state, await dav.get());
154
+ }
155
+ // The server holds no file of ours. This computer's setup becomes the first
156
+ // version, which is what joining an empty folder means.
157
+ if (remote.empty) {
158
+ return await push(engine, state, snapshot, {
159
+ merged: { ...snapshot.parts },
160
+ applied: { ...snapshot.parts },
161
+ remote: remote.hashes,
162
+ version: remote.version,
163
+ here: [],
164
+ there: [],
165
+ });
166
+ }
167
+ const merged = {};
168
+ const applied = {};
169
+ const here = [];
170
+ const there = [];
171
+ let conflicted = false;
172
+ for (const part of PARTS) {
173
+ const remoteHas = remote.hashes[part] !== undefined;
174
+ const remotePart = () => remote.part(part);
175
+ if (!syncsPart(config, part)) {
176
+ // Not carried with this configuration. The server's copy is carried through
177
+ // the upload untouched — turning a part off must never delete it for the
178
+ // computers that still sync it — and nothing is written here from it.
179
+ if (remoteHas)
180
+ merged[part] = remotePart();
181
+ continue;
182
+ }
183
+ // A side moved when its hash for the part is not the one carried over from
184
+ // the last sync — not merely when its presence changed. Comparing presence
185
+ // would read an edit made only on the other computer as "unchanged" and
186
+ // quietly keep this computer's older copy.
187
+ const changedHere = !first && local[part] !== state.local?.[part];
188
+ const changedThere = first || remote.hashes[part] !== state.remote?.[part];
189
+ // A part the other computer took away is taken away here too — but never the
190
+ // plugin list, which is what loads the shell, and never on a first sync,
191
+ // where an absent part only means this computer has nothing to compare.
192
+ const removable = !first && part !== 'plugins';
193
+ if (changedHere && changedThere) {
194
+ // Both sides moved. The newer of the two wins, compared against the stamp
195
+ // the server wrote into the file.
196
+ conflicted = true;
197
+ const serverAt = Date.parse(remote.created);
198
+ const ours = localChangedAt(snapshot, part);
199
+ if (!Number.isFinite(serverAt) || ours >= serverAt) {
200
+ if (local[part] !== undefined) {
201
+ merged[part] = snapshot.parts[part];
202
+ applied[part] = snapshot.parts[part];
203
+ there.push(part);
204
+ }
205
+ }
206
+ else if (remoteHas) {
207
+ merged[part] = remotePart();
208
+ applied[part] = remotePart();
209
+ here.push(part);
210
+ }
211
+ else if (removable) {
212
+ // The other computer removed the part and its file is the newer one, so
213
+ // the removal is the newer answer.
214
+ applied[part] = null;
215
+ here.push(part);
216
+ }
217
+ continue;
218
+ }
219
+ if (changedThere) {
220
+ // Only the server moved. Its copy is brought in — including its absence,
221
+ // which is how a part removed on another computer is removed here. `null`
222
+ // is the instruction to take it away; leaving it `undefined` would mean
223
+ // "do not touch", which is how a part stays that should have gone.
224
+ if (remoteHas) {
225
+ merged[part] = remotePart();
226
+ applied[part] = remotePart();
227
+ here.push(part);
228
+ }
229
+ else if (removable) {
230
+ applied[part] = null;
231
+ here.push(part);
232
+ }
233
+ continue;
234
+ }
235
+ if (changedHere) {
236
+ // Only this computer moved: the part goes up.
237
+ if (local[part] !== undefined) {
238
+ merged[part] = snapshot.parts[part];
239
+ applied[part] = snapshot.parts[part];
240
+ there.push(part);
241
+ }
242
+ continue;
243
+ }
244
+ // Neither side moved. This computer's copy is taken when it has one, so a
245
+ // part that is present on only one side is never dropped by accident.
246
+ if (local[part] !== undefined) {
247
+ merged[part] = snapshot.parts[part];
248
+ applied[part] = snapshot.parts[part];
249
+ }
250
+ else if (remoteHas) {
251
+ merged[part] = remotePart();
252
+ applied[part] = remotePart();
253
+ }
254
+ }
255
+ let saved;
256
+ if (conflicted)
257
+ saved = await keepConflict(engine, snapshot, remote);
258
+ else if (first && here.length > 0 && !sameHashes(local, remote.hashes)) {
259
+ // The server's setup is being taken over one this computer had, with no
260
+ // baseline to weigh them: a join, or a state file that was lost. Either way
261
+ // something written here is about to be replaced, so it is kept and the
262
+ // notice says where it went — see {@link keepUnmerged} on why the sync is
263
+ // not refused instead.
264
+ saved = await keepUnmerged(engine, snapshot);
265
+ }
266
+ const mergedHashes = hashesOf(merged);
267
+ // `force` is the card's own button: it uploads even when the merge decided
268
+ // nothing, because a button that appears to do nothing is a button nobody
269
+ // trusts. An ordinary sync leaves the file alone when there is nothing to say.
270
+ // `rebuilt` overrides both: the file on the server is a fragment.
271
+ const pushNeeded = force || remote.rebuilt === true || !sameHashes(mergedHashes, remote.hashes);
272
+ if (!pushNeeded) {
273
+ const written = await applyMerged(engine, snapshot, applied);
274
+ state.remote = remote.hashes;
275
+ state.local = hashesOf((await take(paths)).parts);
276
+ // A part can come in without the file needing to be written: when only the
277
+ // server moved, the merge agrees with it. The person still has to be told
278
+ // what changed here, so the notice is recorded on this path too — clearing
279
+ // it would mean a sync that brought something in said nothing at all.
280
+ state.notice =
281
+ here.length > 0 || there.length > 0
282
+ ? {
283
+ at: new Date().toISOString(),
284
+ here,
285
+ there,
286
+ ...(saved === undefined ? {} : { saved }),
287
+ }
288
+ : undefined;
289
+ return {
290
+ here,
291
+ there,
292
+ ...(saved === undefined ? {} : { saved }),
293
+ pushed: false,
294
+ reloadNeeded: written.reloadNeeded,
295
+ ...(await pendings(engine, applied.plugins)),
296
+ };
297
+ }
298
+ return await push(engine, state, snapshot, {
299
+ merged,
300
+ applied,
301
+ remote: remote.hashes,
302
+ version: remote.version,
303
+ here,
304
+ there,
305
+ ...(saved === undefined ? {} : { saved }),
306
+ });
307
+ }
308
+ /**
309
+ * The first part of a sealed bundle whose runtime shape this version cannot read.
310
+ *
311
+ * A bundle is only as trustworthy as the computer that wrote it. Opening one
312
+ * proves the passphrase was right and the bytes are intact; it says nothing about
313
+ * whether `patch` is an entry list. Another version of this plugin could seal
314
+ * `{ patch: { entries: 5 } }`, and the first thing to notice would be a
315
+ * `TypeError` thrown from the middle of the merge — by which time the local files
316
+ * have already been read and the user is shown a crash rather than a file this
317
+ * version does not understand. The shapes are checked here instead, before
318
+ * anything is decided on.
319
+ *
320
+ * Absent parts are fine: a bundle may carry any of the four.
321
+ */
322
+ function unreadablePart(bundle) {
323
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
324
+ for (const part of PARTS) {
325
+ const value = bundle[part];
326
+ if (value === undefined)
327
+ continue;
328
+ if (!isObject(value))
329
+ return part;
330
+ const record = value;
331
+ if (part === 'models' || part === 'defaults') {
332
+ if (!isObject(record.config))
333
+ return part;
334
+ continue;
335
+ }
336
+ if (part === 'plugins') {
337
+ const dependencies = record.dependencies;
338
+ const bundles = record.bundles;
339
+ if (!isObject(dependencies) || !Array.isArray(bundles))
340
+ return part;
341
+ if (!Object.values(dependencies).every((one) => typeof one === 'string'))
342
+ return part;
343
+ if (!bundles.every((one) => typeof one === 'string'))
344
+ return part;
345
+ continue;
346
+ }
347
+ // The remaining part is the leftover patch rows.
348
+ if (!Array.isArray(record.entries))
349
+ return part;
350
+ if (!record.entries.every((one) => isObject(one) || typeof one === 'string'))
351
+ return part;
352
+ }
353
+ return undefined;
354
+ }
355
+ /**
356
+ * The server's setup, opened.
357
+ *
358
+ * Four things can be wrong with the bytes and they are not the same thing: the
359
+ * wrong passphrase (nothing to do but say so), damage (a relay cut a write short,
360
+ * which is recoverable from the copy kept here), the right file from a version of
361
+ * this plugin whose parts this one cannot read, and something else entirely (a
362
+ * sign-in page, another application's file — never written over without being
363
+ * asked). Every one of them is told apart and reported as itself.
364
+ */
365
+ async function openRemote(engine, state, read) {
366
+ const { paths, config } = engine;
367
+ const empty = {
368
+ hashes: {},
369
+ created: new Date(0).toISOString(),
370
+ version: read.version,
371
+ notModified: false,
372
+ empty: true,
373
+ part: () => undefined,
374
+ };
375
+ if (read.notModified) {
376
+ // The server said the file is the one last read, so the hashes from that sync
377
+ // are still what it holds. No body is needed: nothing can come in from a file
378
+ // that did not change.
379
+ return { ...empty, hashes: state.remote ?? {}, empty: false, notModified: true };
380
+ }
381
+ let data = read.data;
382
+ if (data === undefined)
383
+ data = await cached(paths, state.sum);
384
+ if (data === undefined)
385
+ return empty;
386
+ if (read.redirectedTo !== undefined) {
387
+ throw new Error(`地址最终指到了别处:服务器把请求转到了 ${read.redirectedTo}。请填文件夹本身的地址`);
388
+ }
389
+ try {
390
+ const bundle = (await open(data, config.passphrase));
391
+ if (bundle === null || typeof bundle !== 'object' || bundle.format !== BUNDLE_FORMAT) {
392
+ throw new NotBackupError();
393
+ }
394
+ // A bundle this version cannot read is treated as the damaged case rather
395
+ // than as not-ours: the passphrase opened it, so it is a backup, and refusing
396
+ // outright would leave the user with no way forward but Write over. Route it
397
+ // down the same branch as a half-written file, which keeps the bytes and
398
+ // rebuilds from this computer.
399
+ const unreadable = unreadablePart(bundle);
400
+ if (unreadable !== undefined) {
401
+ throw new CorruptError(`服务器上的文件是一份读不了的 dsh 同步备份:其中「${unreadable}」这一份的形状不是本插件写出来的`);
402
+ }
403
+ return {
404
+ hashes: hashesOf(bundle),
405
+ body: bundle,
406
+ created: typeof bundle.created === 'string' ? bundle.created : new Date(0).toISOString(),
407
+ version: read.version,
408
+ notModified: false,
409
+ empty: false,
410
+ part: (part) => bundle[part],
411
+ };
412
+ }
413
+ catch (cause) {
414
+ if (cause instanceof PassphraseError) {
415
+ throw new Error('口令打不开服务器上的文件:它是用另一个口令加密的——请用你在其他机器上设置的那个口令');
416
+ }
417
+ if (cause instanceof CorruptError && state.local !== undefined) {
418
+ // Half a file, and this computer has synced before: the copy of the last
419
+ // good version kept here is the thing to go on, and the damaged bytes are
420
+ // kept so they can be looked at. Only a body that begins like ours is
421
+ // rebuilt over; anything else might be somebody's work.
422
+ await keepAside(keptDir(paths), 'server-damaged', data).catch(() => undefined);
423
+ // `rebuilt` is what makes the next write happen. The parts here may hash
424
+ // exactly as the last sync left them — nothing about this computer moved —
425
+ // but the bytes on the server are a fragment, so the file has to be
426
+ // written again whatever the hashes say.
427
+ return { ...empty, hashes: state.remote ?? {}, empty: false, rebuilt: true };
428
+ }
429
+ if (cause instanceof CorruptError) {
430
+ // A backup we cannot read is not the same as a file that is not ours, and
431
+ // saying so is what tells the user which of the two buttons to press. The
432
+ // message the reader built says which part gave it away.
433
+ throw new Error(`${cause.message}——如果它应该被本机的配置替换,请用「用本机覆盖服务器」`);
434
+ }
435
+ if (cause instanceof NotBackupError) {
436
+ throw new Error(`服务器上的文件不是 dsh 同步备份(${REMOTE_FILE}):如果它应该被本机的配置替换,请用「用本机覆盖服务器」`);
437
+ }
438
+ throw cause;
439
+ }
440
+ }
441
+ /** Seal the merged setup, write it, then write it here. */
442
+ async function push(engine, state, snapshot, merged) {
443
+ const { paths, config } = engine;
444
+ const bundle = {
445
+ format: BUNDLE_FORMAT,
446
+ version: BUNDLE_VERSION,
447
+ created: new Date().toISOString(),
448
+ app: BUNDLE_APP,
449
+ ...merged.merged,
450
+ };
451
+ const sealed = await seal(bundle, config.passphrase);
452
+ // What the state held before this write, kept for the blind-write guard. The
453
+ // sum recorded with it is the one this write is about to put on the server, not
454
+ // the one that was there before: the guard is asking, next time, whether the
455
+ // bytes on the server are still the ones it wrote.
456
+ const before = { sum: sum(sealed), local: state.local, remote: state.remote };
457
+ const dav = new Dav({ url: config.url, user: config.user, password: config.password });
458
+ const written = await dav.put(sealed, merged.version.etag ?? '');
459
+ // The parts are written here only after the write to the server succeeded, so a
460
+ // failed upload never leaves this computer holding another computer's setup
461
+ // with nothing to say where its own went.
462
+ const applied = await applyMerged(engine, snapshot, merged.applied);
463
+ const now = hashesOf((await take(paths)).parts);
464
+ state.server = written;
465
+ state.sum = sum(sealed);
466
+ state.remote = hashesOf(bundle);
467
+ state.local = now;
468
+ state.blind = written.etag === undefined || written.etag === '' ? { ...before } : undefined;
469
+ state.undo = undefined;
470
+ const notice = merged.here.length > 0 || merged.there.length > 0
471
+ ? {
472
+ at: new Date().toISOString(),
473
+ here: merged.here,
474
+ there: merged.there,
475
+ ...(merged.saved === undefined ? {} : { saved: merged.saved }),
476
+ }
477
+ : undefined;
478
+ state.notice = notice;
479
+ await remember(paths, sealed);
480
+ return {
481
+ here: merged.here,
482
+ there: merged.there,
483
+ ...(merged.saved === undefined ? {} : { saved: merged.saved }),
484
+ pushed: true,
485
+ reloadNeeded: applied.reloadNeeded,
486
+ ...(await pendings(engine, merged.merged.plugins)),
487
+ };
488
+ }
489
+ /**
490
+ * Write the merged parts into the profile's own files.
491
+ *
492
+ * Whatever is about to be replaced is sealed into `sync/` first, so a merge that
493
+ * takes a part away — the mirror of a deletion made on another computer — is
494
+ * always recoverable from this computer. Nothing is written when the file already
495
+ * holds exactly those bytes, so a sync that changed nothing does not touch the
496
+ * profile.
497
+ */
498
+ async function applyMerged(engine, snapshot, applied) {
499
+ const { paths, config } = engine;
500
+ let reloadNeeded = false;
501
+ let kept = false;
502
+ if (applied.models !== undefined || applied.defaults !== undefined || applied.patch !== undefined) {
503
+ // `null` means "take this away", which is how a removal made on another
504
+ // computer reaches this one. The patch part has no row of its own to remove,
505
+ // so it is emptied instead.
506
+ const next = applyPatch(snapshot.patch, {
507
+ models: applied.models,
508
+ defaults: applied.defaults,
509
+ ...(applied.patch === null ? { patch: { entries: [] } } : { patch: applied.patch }),
510
+ });
511
+ if (next !== snapshot.patch.text) {
512
+ if (!kept) {
513
+ kept = true;
514
+ await keepBefore(engine, snapshot);
515
+ }
516
+ if (await writeIfChanged(paths.patchFile, next))
517
+ reloadNeeded = true;
518
+ }
519
+ }
520
+ if (applied.plugins !== undefined) {
521
+ const next = applyManifest(snapshot.manifest, applied.plugins);
522
+ if (next !== snapshot.manifest.text) {
523
+ if (!kept)
524
+ await keepBefore(engine, snapshot);
525
+ if (await writeIfChanged(paths.packageFile, next))
526
+ reloadNeeded = true;
527
+ }
528
+ }
529
+ return { reloadNeeded };
530
+ }
531
+ /** Seal this computer's setup into `sync/before-apply` before a merge writes. */
532
+ async function keepBefore(engine, snapshot) {
533
+ try {
534
+ const bundle = {
535
+ format: BUNDLE_FORMAT,
536
+ version: BUNDLE_VERSION,
537
+ created: new Date().toISOString(),
538
+ app: BUNDLE_APP,
539
+ ...snapshot.parts,
540
+ };
541
+ const sealed = await seal(bundle, engine.config.passphrase);
542
+ return await keepAside(keptDir(engine.paths), 'before-apply', sealed);
543
+ }
544
+ catch {
545
+ // Keeping a copy must never be the reason a sync fails.
546
+ return undefined;
547
+ }
548
+ }
549
+ /** Keep both sides of a conflict, so the one that lost is still readable. */
550
+ async function keepConflict(engine, snapshot, remote) {
551
+ const { paths, config } = engine;
552
+ const dir = keptDir(paths);
553
+ const ours = {
554
+ format: BUNDLE_FORMAT,
555
+ version: BUNDLE_VERSION,
556
+ created: new Date().toISOString(),
557
+ app: BUNDLE_APP,
558
+ ...snapshot.parts,
559
+ };
560
+ await keepAside(dir, 'this-computer', await seal(ours, config.passphrase)).catch(() => undefined);
561
+ if (remote.body !== undefined) {
562
+ await keepAside(dir, 'server', await seal(remote.body, config.passphrase)).catch(() => undefined);
563
+ }
564
+ return dir;
565
+ }
566
+ /**
567
+ * Keep this computer's setup when a sync is about to adopt the server's over it
568
+ * without a baseline to merge against, and name where the copy was left.
569
+ *
570
+ * A first sync is what joining a folder with somebody else's setup in it means:
571
+ * the server wins, and that is right — there is nothing here to weigh against.
572
+ * But a *missing* state file is not the same thing as a first sync, and the two
573
+ * cannot be told apart from the hashes alone: the same empty baseline covers a
574
+ * fresh computer and one whose record of the last sync was deleted, lost or
575
+ * restored from an old backup. What can tell them apart is the cached copy of
576
+ * the server's last file. If it is still here, this computer did sync before,
577
+ * so a merge is exactly what was skipped — and taking the server's answer
578
+ * wholesale is how local work disappears without a word.
579
+ *
580
+ * So the copy is kept and the notice names the folder instead of the sync being
581
+ * refused. Refusing would leave a fresh computer unable to join at all, and the
582
+ * cache is no proof: a stale `sync/` folder left by an older profile is as
583
+ * likely as anything. Keeping the copy is what makes this safe — Restore and
584
+ * Undo already put it back — and saying where it is turns a silent loss into a
585
+ * recoverable one. A join with nothing here to lose keeps nothing and names
586
+ * nothing.
587
+ */
588
+ async function keepUnmerged(engine, snapshot) {
589
+ const kept = await keepBefore(engine, snapshot);
590
+ return kept === undefined ? undefined : keptDir(engine.paths);
591
+ }
592
+ /**
593
+ * Whether a profile now declares plugins that are not installed here.
594
+ *
595
+ * The part handed in is the MERGED one, not the one this computer happened to
596
+ * hold before the merge: the question the card asks is "will loading this profile
597
+ * now need an install", and a plugin list that just arrived from the server is
598
+ * exactly the case where the answer is yes.
599
+ */
600
+ async function pendings(engine, declared) {
601
+ if (!engine.config.plugins)
602
+ return { pendingInstall: false };
603
+ if (declared === undefined)
604
+ return { pendingInstall: false };
605
+ const installed = await installedPackages(engine.paths);
606
+ const missing = missingPlugins(declared, installed);
607
+ // The names travel with the flag: the card says "these are not installed yet",
608
+ // and a bare true would leave it naming nobody.
609
+ return missing.length === 0 ? { pendingInstall: false } : { pendingInstall: true, pendingNames: missing };
610
+ }
611
+ // ---------------------------------------------------------------------------
612
+ // Restore, upload, undo
613
+ // ---------------------------------------------------------------------------
614
+ /**
615
+ * Make this computer's setup the server's, whatever changed here.
616
+ *
617
+ * What was here first is sealed into `sync/` with the passphrase, so Undo puts it
618
+ * back. Afterwards the state claims the server already holds what is here, which
619
+ * is true: the next sync pushes it.
620
+ */
621
+ export async function restore(engine) {
622
+ return guarded(engine, async () => {
623
+ const { paths, config } = engine;
624
+ if (!usable(config))
625
+ throw new Error('同步还没有配置好:请先填写地址和口令');
626
+ await ensureStateDir(paths);
627
+ const state = await freshState(paths, config);
628
+ const dav = new Dav({ url: config.url, user: config.user, password: config.password });
629
+ const read = await dav.get();
630
+ if (read.notModified || read.data === undefined) {
631
+ if (read.data === undefined)
632
+ throw new Error('服务器上还没有 dsh 配置:没有可恢复的内容');
633
+ }
634
+ const remote = await openRemote(engine, state, { ...read, notModified: false });
635
+ if (remote.empty)
636
+ throw new Error('服务器上还没有 dsh 配置:没有可恢复的内容');
637
+ const snapshot = await take(paths);
638
+ const local = hashesOf(snapshot.parts);
639
+ const here = [];
640
+ const merged = {};
641
+ const applied = {};
642
+ for (const part of PARTS) {
643
+ // A part the configuration turned off is not brought in, however much the
644
+ // server's copy differs: "do not sync this" is the instruction, and a
645
+ // restore that wrote it anyway would be the one action that ignores it.
646
+ if (!syncsPart(config, part))
647
+ continue;
648
+ const remotePart = remote.part(part);
649
+ if (remotePart !== undefined) {
650
+ merged[part] = remotePart;
651
+ applied[part] = remotePart;
652
+ }
653
+ // Restore does not weigh who changed what: every part that differs is
654
+ // brought in, which is the whole point of the action.
655
+ if (local[part] !== remote.hashes[part])
656
+ here.push(part);
657
+ }
658
+ const kept = await keepBefore(engine, snapshot);
659
+ const written = await applyMerged(engine, snapshot, applied);
660
+ state.undo = kept ?? '';
661
+ state.remote = remote.hashes;
662
+ state.local = hashesOf((await take(paths)).parts);
663
+ state.sum = read.data === undefined ? state.sum : sum(read.data);
664
+ state.server = read.version;
665
+ state.blind = undefined;
666
+ state.last = new Date().toISOString();
667
+ state.error = '';
668
+ state.notice = { at: new Date().toISOString(), here, there: [], ...(kept === undefined ? {} : { saved: keptDir(paths) }), restored: true };
669
+ if (read.data !== undefined)
670
+ await remember(paths, read.data);
671
+ await saveState(paths, state);
672
+ return {
673
+ here,
674
+ there: [],
675
+ ...(kept === undefined ? {} : { saved: keptDir(paths) }),
676
+ pushed: false,
677
+ reloadNeeded: written.reloadNeeded,
678
+ ...(await pendings(engine, applied.plugins)),
679
+ };
680
+ });
681
+ }
682
+ /**
683
+ * Write over a file on the server that is not a backup of ours.
684
+ *
685
+ * A file that opens as ours, or that was sealed with another passphrase, is never
686
+ * written over: the first is what Sync now merges with, and the second is
687
+ * somebody's backup that this computer has no business replacing. Whatever the
688
+ * server held is kept under `sync/` before anything is written.
689
+ */
690
+ export async function upload(engine) {
691
+ return guarded(engine, async () => {
692
+ const { paths, config } = engine;
693
+ if (!usable(config))
694
+ throw new Error('同步还没有配置好:请先填写地址和口令');
695
+ await ensureStateDir(paths);
696
+ const state = await freshState(paths, config);
697
+ const dav = new Dav({ url: config.url, user: config.user, password: config.password });
698
+ const read = await dav.get();
699
+ if (read.data !== undefined) {
700
+ let readable = true;
701
+ try {
702
+ await open(read.data, config.passphrase);
703
+ }
704
+ catch (cause) {
705
+ if (cause instanceof PassphraseError) {
706
+ throw new Error('服务器上的文件是用另一个口令加密的,所以没有覆盖它:请用你在其他机器上设置的那个口令');
707
+ }
708
+ if (cause instanceof NotBackupError || cause instanceof CorruptError)
709
+ readable = false;
710
+ else
711
+ throw cause;
712
+ }
713
+ if (readable) {
714
+ throw new Error('服务器上的文件是本机能打开的 dsh 同步备份:请改用「立即同步」和它合并,这次没有覆盖任何东西');
715
+ }
716
+ await keepAside(keptDir(paths), 'server-replaced', read.data);
717
+ }
718
+ const snapshot = await take(paths);
719
+ const result = await push(engine, state, snapshot, {
720
+ merged: { ...snapshot.parts },
721
+ applied: {},
722
+ remote: {},
723
+ version: read.version,
724
+ here: [],
725
+ there: [],
726
+ });
727
+ state.last = new Date().toISOString();
728
+ state.error = '';
729
+ await saveState(paths, state);
730
+ return result;
731
+ });
732
+ }
733
+ /** Put back the setup the last restore replaced. */
734
+ export async function undo(engine) {
735
+ return guarded(engine, async () => {
736
+ const { paths, config } = engine;
737
+ const state = await loadState(paths);
738
+ if (state.key !== stateKey(config) || state.undo === undefined || state.undo === '') {
739
+ throw new Error('没有可撤销的恢复');
740
+ }
741
+ let data;
742
+ try {
743
+ data = await readFile(state.undo);
744
+ }
745
+ catch {
746
+ throw new Error('恢复之前留下的那份副本已经不在了,没有东西可以放回去');
747
+ }
748
+ const bundle = (await open(new Uint8Array(data), config.passphrase));
749
+ const snapshot = await take(paths);
750
+ const merged = {};
751
+ const applied = {};
752
+ const back = [];
753
+ for (const part of PARTS) {
754
+ // Undo is a write to this computer's files like any other, so a part the
755
+ // configuration turned off is left alone here too. The copy it came from
756
+ // keeps the whole setup either way.
757
+ if (!syncsPart(config, part))
758
+ continue;
759
+ const kept = bundle[part];
760
+ if (kept === undefined)
761
+ continue;
762
+ merged[part] = kept;
763
+ applied[part] = kept;
764
+ back.push(part);
765
+ }
766
+ const written = await applyMerged(engine, snapshot, applied);
767
+ state.undo = '';
768
+ state.notice = undefined;
769
+ await saveState(paths, state);
770
+ return {
771
+ here: back,
772
+ there: [],
773
+ pushed: false,
774
+ reloadNeeded: written.reloadNeeded,
775
+ ...(await pendings(engine, merged.plugins)),
776
+ };
777
+ });
778
+ }
779
+ // ---------------------------------------------------------------------------
780
+ // Status, dismiss, off
781
+ // ---------------------------------------------------------------------------
782
+ /**
783
+ * What the card shows: the configuration without its secrets, where the files
784
+ * are, and how each part stands against the last sync.
785
+ *
786
+ * `localChanged` and `remoteChanged` are answered from the stored hashes alone,
787
+ * so opening the card costs one file read and no request to the server. A part
788
+ * the configuration does not carry is reported as it stands but never as
789
+ * changed, since nothing about it would move.
790
+ */
791
+ export async function status(engine) {
792
+ const { paths, config } = engine;
793
+ const state = await loadState(paths);
794
+ const fresh = state.key === stateKey(config);
795
+ const snapshot = await take(paths).catch(() => undefined);
796
+ const local = snapshot === undefined ? {} : hashesOf(snapshot.parts);
797
+ const parts = [];
798
+ const known = fresh ? state.local : undefined;
799
+ const knownRemote = fresh ? state.remote : undefined;
800
+ const first = known === undefined;
801
+ for (const part of PARTS) {
802
+ // What the server holds now cannot be known without asking it, and asking it
803
+ // on every card open is a request per glance. So these two flags answer for
804
+ // the last sync, not for now, and they answer about different things:
805
+ // `localChanged` is whether this computer's file has moved since that sync,
806
+ // and `remoteChanged` is whether the two sides were left out of step — which
807
+ // is what the next merge will have to reconcile. Neither is a statement that
808
+ // the server moved; {@link preview} asks the server and answers for real.
809
+ //
810
+ // The two are deliberately independent rather than one flag read twice. After
811
+ // a good sync the recorded sides agree, so the older reading
812
+ // (`here !== there`, i.e. "this file differs from the server") said the same
813
+ // thing as `localChanged` and lit both, and the card then told the user that
814
+ // both sides had changed when only this one had.
815
+ const here = local[part] ?? null;
816
+ const there = knownRemote?.[part] ?? null;
817
+ const was = known?.[part] ?? null;
818
+ parts.push({
819
+ part,
820
+ local: here,
821
+ remote: there,
822
+ localChanged: !first && here !== was,
823
+ remoteChanged: !first && was !== there,
824
+ synced: syncsPart(config, part),
825
+ });
826
+ }
827
+ const plugins = snapshot?.parts.plugins;
828
+ const missing = config.plugins && plugins !== undefined
829
+ ? missingPlugins(plugins, await installedPackages(paths).catch(() => []))
830
+ : [];
831
+ // A failure is reported for the configuration that failed. `fresh` covers the
832
+ // ordinary case; `errorKey` covers the one where the failure itself changed the
833
+ // key — a wrong passphrase — which is exactly when the card most needs to say so.
834
+ const errorApplies = fresh || (state.errorKey !== undefined && state.errorKey === stateKey(config));
835
+ return {
836
+ configured: usable(config),
837
+ config: viewOf(config),
838
+ paths: {
839
+ profileDir: paths.profileDir,
840
+ stateDir: paths.stateDir,
841
+ patchFile: paths.patchFile,
842
+ packageFile: paths.packageFile,
843
+ },
844
+ ...(fresh && state.last !== undefined && state.last !== '' ? { last: state.last } : {}),
845
+ error: errorApplies && typeof state.error === 'string' ? state.error : '',
846
+ ...(fresh && state.notice !== undefined ? { notice: state.notice } : {}),
847
+ parts,
848
+ pendingInstall: missing.length > 0,
849
+ ...(missing.length > 0 ? { pendingNames: missing } : {}),
850
+ undoable: fresh && state.undo !== undefined && state.undo !== '',
851
+ };
852
+ }
853
+ /** The card's "dismiss": forget the notice, and nothing else. */
854
+ export async function dismiss(engine) {
855
+ const state = await loadState(engine.paths);
856
+ if (state.key !== stateKey(engine.config))
857
+ return;
858
+ state.notice = undefined;
859
+ await saveState(engine.paths, state);
860
+ }
861
+ /**
862
+ * Turn sync off, keeping what is known about the server.
863
+ *
864
+ * The state is kept deliberately: turning sync back on to the same address must
865
+ * not read as joining a folder for the first time, which would take the server's
866
+ * older setup over this computer's newer one.
867
+ */
868
+ export async function turnOff(engine) {
869
+ const config = { ...engine.config, url: '', password: '' };
870
+ await saveConfig(engine.paths, config);
871
+ // A password held in memory rather than written down is still a password this
872
+ // host would send, and turning sync off is the way to clear one.
873
+ releasePassword(engine.paths);
874
+ }
875
+ /** The configuration as the card sees it: never a secret, only whether one is set. */
876
+ export function viewOf(config) {
877
+ return {
878
+ url: config.url,
879
+ user: config.user,
880
+ passwordSet: config.password !== '',
881
+ passphraseSet: config.passphrase !== '',
882
+ autoEvery: config.autoEvery,
883
+ models: config.models,
884
+ defaults: config.defaults,
885
+ plugins: config.plugins,
886
+ patch: config.patch,
887
+ rememberPassword: config.rememberPassword,
888
+ };
889
+ }
890
+ // ---------------------------------------------------------------------------
891
+ // Check and preview
892
+ // ---------------------------------------------------------------------------
893
+ /** Reach the server and say what is there, without changing anything. */
894
+ export async function check(engine) {
895
+ const { config } = engine;
896
+ if (normaliseUrl(config.url) === '')
897
+ throw new Error('请先填写同步地址');
898
+ const dav = new Dav({ url: config.url, user: config.user, password: config.password });
899
+ try {
900
+ await dav.check();
901
+ }
902
+ catch (cause) {
903
+ if (cause instanceof LoginError || cause instanceof RateLimitedError) {
904
+ return { ok: false, message: cause.message, hasBackup: false };
905
+ }
906
+ throw cause;
907
+ }
908
+ const found = await dav.stat();
909
+ if (!found.exists) {
910
+ return {
911
+ ok: true,
912
+ message: '服务器有响应,上面还没有你的备份:第一次同步会写一份上去',
913
+ hasBackup: false,
914
+ };
915
+ }
916
+ const read = await dav.get();
917
+ let created;
918
+ if (read.data !== undefined) {
919
+ try {
920
+ const bundle = (await open(read.data, config.passphrase));
921
+ if (typeof bundle.created === 'string')
922
+ created = bundle.created;
923
+ }
924
+ catch (cause) {
925
+ if (cause instanceof PassphraseError) {
926
+ return {
927
+ ok: false,
928
+ message: '服务器有响应,上面也有备份,但口令打不开它——它是用另一个口令加密的',
929
+ hasBackup: true,
930
+ ...(found.size === undefined ? {} : { size: found.size }),
931
+ };
932
+ }
933
+ }
934
+ }
935
+ return {
936
+ ok: true,
937
+ message: '服务器有响应,账号可以写入这个目录,你的备份就在上面',
938
+ hasBackup: true,
939
+ ...(found.size === undefined ? {} : { size: found.size }),
940
+ ...(created === undefined ? {} : { created }),
941
+ };
942
+ }
943
+ /** What one sync would do, decided the same way but writing nothing anywhere. */
944
+ export async function preview(engine) {
945
+ const { paths, config } = engine;
946
+ if (!usable(config))
947
+ throw new Error('同步还没有配置好:请先填写地址和口令');
948
+ const state = await freshState(paths, config);
949
+ const dav = new Dav({ url: config.url, user: config.user, password: config.password });
950
+ const read = await dav.get(state.server);
951
+ const remote = await openRemote(engine, state, read);
952
+ const snapshot = await take(paths);
953
+ const local = hashesOf(snapshot.parts);
954
+ const first = state.local === undefined;
955
+ const changes = [];
956
+ for (const part of PARTS) {
957
+ if (!syncsPart(config, part))
958
+ continue;
959
+ // The same test the sync itself uses, so what the card promises and what the
960
+ // merge does cannot drift apart.
961
+ const changedHere = !first && local[part] !== state.local?.[part];
962
+ const changedThere = first || remote.hashes[part] !== state.remote?.[part];
963
+ if (!changedHere && !changedThere)
964
+ continue;
965
+ if (changedHere && changedThere) {
966
+ const serverAt = Date.parse(remote.created);
967
+ const ours = localChangedAt(snapshot, part);
968
+ changes.push({ part, take: !Number.isFinite(serverAt) || ours >= serverAt ? 'newer-local' : 'newer-remote' });
969
+ continue;
970
+ }
971
+ changes.push({ part, take: changedHere ? 'local' : 'remote' });
972
+ }
973
+ return { changes };
974
+ }
975
+ // ---------------------------------------------------------------------------
976
+ // Small helpers
977
+ // ---------------------------------------------------------------------------
978
+ /** Read this computer's setup and the files it came from, in one pass. */
979
+ export async function take(paths) {
980
+ const patch = await readPatch(paths);
981
+ const manifest = await readManifest(paths);
982
+ return { patch, manifest, parts: collect(patch, manifest) };
983
+ }
984
+ /** Per-part hashes of a setup; a part that is not there has none. */
985
+ export function hashesOf(parts) {
986
+ const source = parts;
987
+ const out = {};
988
+ for (const part of PARTS) {
989
+ const value = source[part];
990
+ if (value !== undefined)
991
+ out[part] = hashJson(value);
992
+ }
993
+ return out;
994
+ }
995
+ /** Whether two sets of part hashes describe the same setup. */
996
+ export function sameHashes(a, b) {
997
+ if (b === undefined)
998
+ return false;
999
+ for (const part of PARTS) {
1000
+ if (a[part] !== b[part])
1001
+ return false;
1002
+ }
1003
+ return true;
1004
+ }
1005
+ /** When this computer last changed a part, in milliseconds, for the tie-break. */
1006
+ export function localChangedAt(snapshot, part) {
1007
+ if (part === 'plugins')
1008
+ return snapshot.manifest.mtimeMs;
1009
+ return snapshot.patch.mtimeMs;
1010
+ }
1011
+ /** The top-level packages present in the profile's node_modules. */
1012
+ export async function installedPackages(paths) {
1013
+ const { readdir } = await import('node:fs/promises');
1014
+ const root = join(paths.profileDir, 'node_modules');
1015
+ let entries;
1016
+ try {
1017
+ entries = await readdir(root);
1018
+ }
1019
+ catch (cause) {
1020
+ if (isMissing(cause))
1021
+ return [];
1022
+ throw cause;
1023
+ }
1024
+ const names = [];
1025
+ for (const entry of entries) {
1026
+ if (entry.startsWith('.'))
1027
+ continue;
1028
+ if (entry.startsWith('@')) {
1029
+ const scoped = await readdir(join(root, entry)).catch(() => []);
1030
+ for (const inner of scoped)
1031
+ names.push(`${entry}/${inner}`);
1032
+ continue;
1033
+ }
1034
+ names.push(entry);
1035
+ }
1036
+ return names;
1037
+ }
1038
+ /** The profile directory a set of paths names, for a caller that only has a config. */
1039
+ export function pathsOf(profileDir) {
1040
+ return resolvePaths(profileDir);
1041
+ }
1042
+ export { loadConfig, sameAccount, saveConfig, usable, missingPlugins };