dsh-webdav-sync 1.0.0 → 1.0.2
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/README.md +119 -25
- package/lib/client.js +563 -65
- package/lib/dav.d.ts +27 -7
- package/lib/dav.js +134 -28
- package/lib/dirs.d.ts +114 -0
- package/lib/dirs.js +259 -0
- package/lib/engine.d.ts +5 -1
- package/lib/engine.js +207 -44
- package/lib/index.d.ts +7 -18
- package/lib/index.js +98 -40
- package/lib/relay.d.ts +119 -0
- package/lib/relay.js +683 -0
- package/lib/state.d.ts +14 -2
- package/lib/state.js +47 -2
- package/lib/store.d.ts +1 -1
- package/lib/store.js +8 -10
- package/lib/wire.d.ts +228 -17
- package/lib/wire.js +65 -10
- package/package.json +1 -1
package/lib/engine.js
CHANGED
|
@@ -17,10 +17,27 @@ import { readFile } from 'node:fs/promises';
|
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { BUNDLE_APP, BUNDLE_FORMAT, BUNDLE_VERSION, PARTS, REMOTE_FILE, } from './wire.js';
|
|
19
19
|
import { CorruptError, NotBackupError, PassphraseError, hashJson, open, seal, sum } from './crypto.js';
|
|
20
|
-
import { Dav, LoginError, RateLimitedError } from './dav.js';
|
|
20
|
+
import { Dav, LoginError, RateLimitedError, parseHeaders } from './dav.js';
|
|
21
|
+
import { dirsDiffer, dirsPartOf, readDirs, writeDirs } from './dirs.js';
|
|
21
22
|
import { applyManifest, applyPatch, collect, isMissing, keepAside, message, missingPlugins, readManifest, readPatch, writeIfChanged, } from './store.js';
|
|
22
23
|
import { cached, ensureStateDir, keptDir, loadConfig, loadState, normaliseUrl, releasePassword, remember, sameAccount, saveConfig, saveState, stateKey, syncsPart, usable, } from './state.js';
|
|
23
24
|
import { resolvePaths } from './paths.js';
|
|
25
|
+
/**
|
|
26
|
+
* This computer's whole setup, as parts.
|
|
27
|
+
*
|
|
28
|
+
* The four profile parts are read once; the extra directories have to be walked,
|
|
29
|
+
* so they arrive separately and are put back together here. `carried` is the
|
|
30
|
+
* server's `dirs` part, and it is used for the directories this computer could not
|
|
31
|
+
* read — see {@link dirsPartOf} for why carrying those beats reading them as
|
|
32
|
+
* empty ones.
|
|
33
|
+
*/
|
|
34
|
+
function partsOf(snapshot, carried) {
|
|
35
|
+
const dirs = dirsPartOf(snapshot.dirs, carried);
|
|
36
|
+
// No directories named and none carried: the part is left out entirely rather
|
|
37
|
+
// than sent as an empty one, so a computer that syncs none of them can never be
|
|
38
|
+
// the one that empties the part for the computers that do.
|
|
39
|
+
return dirs === undefined ? { ...snapshot.parts } : { ...snapshot.parts, dirs };
|
|
40
|
+
}
|
|
24
41
|
/**
|
|
25
42
|
* One operation at a time, per process.
|
|
26
43
|
*
|
|
@@ -58,8 +75,15 @@ export async function sync(engine, options = {}) {
|
|
|
58
75
|
await ensureStateDir(paths);
|
|
59
76
|
const state = await freshState(paths, config);
|
|
60
77
|
const outcome = await syncOnce(engine, state, options.force === true);
|
|
61
|
-
|
|
78
|
+
const at = new Date().toISOString();
|
|
79
|
+
state.last = at;
|
|
62
80
|
state.error = '';
|
|
81
|
+
// `here` and `there` name the parts that moved, and `pushed` says the sealed
|
|
82
|
+
// file went up: any of the three means this attempt moved bytes. An attempt
|
|
83
|
+
// that only looked leaves `moved` where it was, which is what tells a check
|
|
84
|
+
// apart from a sync on the card. See docs/decisions/0039-the-card-says-check-and-sync-apart.md.
|
|
85
|
+
if (outcome.pushed || outcome.here.length > 0 || outcome.there.length > 0)
|
|
86
|
+
state.moved = at;
|
|
63
87
|
await saveState(paths, state);
|
|
64
88
|
return outcome;
|
|
65
89
|
});
|
|
@@ -114,7 +138,7 @@ async function recordFailure(engine, cause) {
|
|
|
114
138
|
}
|
|
115
139
|
async function syncOnce(engine, state, force) {
|
|
116
140
|
const { paths, config } = engine;
|
|
117
|
-
const dav =
|
|
141
|
+
const dav = davOf(config);
|
|
118
142
|
const read = await dav.get(state.server);
|
|
119
143
|
// A write that went out without the server checking its version may have
|
|
120
144
|
// overlapped another computer's, and which of the two landed cannot be told
|
|
@@ -138,11 +162,17 @@ async function syncOnce(engine, state, force) {
|
|
|
138
162
|
}
|
|
139
163
|
state.blind = undefined;
|
|
140
164
|
}
|
|
141
|
-
const snapshot = await take(paths);
|
|
142
|
-
const local = hashesOf(snapshot.parts);
|
|
165
|
+
const snapshot = await take(paths, config);
|
|
143
166
|
let remote = await openRemote(engine, state, read);
|
|
144
167
|
const first = state.local === undefined;
|
|
145
|
-
|
|
168
|
+
// A directory could not be read this run: its part stands in for the server's
|
|
169
|
+
// copy of it, and the server's copy cannot be known from a `notModified` answer
|
|
170
|
+
// — so the comparison below is made without it and comes out false, which takes
|
|
171
|
+
// this sync the long way round and reads the file. That is the right price for
|
|
172
|
+
// not confusing "could not measure" with "measured, and it is empty".
|
|
173
|
+
if (remote.notModified
|
|
174
|
+
&& sameHashes(hashesOf(partsOf(snapshot, remote.part('dirs'))), state.local)
|
|
175
|
+
&& !force) {
|
|
146
176
|
// Neither side moved: the last sync is still the state of the world.
|
|
147
177
|
return { here: [], there: [], pushed: false, reloadNeeded: false, ...(await pendings(engine, snapshot.parts.plugins)) };
|
|
148
178
|
}
|
|
@@ -152,12 +182,15 @@ async function syncOnce(engine, state, force) {
|
|
|
152
182
|
// needed to merge against and it is read again, this time in full.
|
|
153
183
|
remote = await openRemote(engine, state, await dav.get());
|
|
154
184
|
}
|
|
185
|
+
const carried = remote.part('dirs');
|
|
186
|
+
const localParts = partsOf(snapshot, carried);
|
|
187
|
+
const local = hashesOf(localParts);
|
|
155
188
|
// The server holds no file of ours. This computer's setup becomes the first
|
|
156
189
|
// version, which is what joining an empty folder means.
|
|
157
190
|
if (remote.empty) {
|
|
158
191
|
return await push(engine, state, snapshot, {
|
|
159
|
-
merged: { ...
|
|
160
|
-
applied: { ...
|
|
192
|
+
merged: { ...localParts },
|
|
193
|
+
applied: { ...localParts },
|
|
161
194
|
remote: remote.hashes,
|
|
162
195
|
version: remote.version,
|
|
163
196
|
here: [],
|
|
@@ -198,8 +231,8 @@ async function syncOnce(engine, state, force) {
|
|
|
198
231
|
const ours = localChangedAt(snapshot, part);
|
|
199
232
|
if (!Number.isFinite(serverAt) || ours >= serverAt) {
|
|
200
233
|
if (local[part] !== undefined) {
|
|
201
|
-
merged[part] =
|
|
202
|
-
applied[part] =
|
|
234
|
+
merged[part] = localParts[part];
|
|
235
|
+
applied[part] = localParts[part];
|
|
203
236
|
there.push(part);
|
|
204
237
|
}
|
|
205
238
|
}
|
|
@@ -226,6 +259,15 @@ async function syncOnce(engine, state, force) {
|
|
|
226
259
|
applied[part] = remotePart();
|
|
227
260
|
here.push(part);
|
|
228
261
|
}
|
|
262
|
+
else if (part === 'dirs' && local[part] !== undefined) {
|
|
263
|
+
// The one removal this computer does not take. Its own directories are still
|
|
264
|
+
// named here, and the part does nothing but say which paths travel: taking it
|
|
265
|
+
// away would leave them unsynced for no gain, so the copy here is what goes
|
|
266
|
+
// back up.
|
|
267
|
+
merged[part] = localParts[part];
|
|
268
|
+
applied[part] = localParts[part];
|
|
269
|
+
there.push(part);
|
|
270
|
+
}
|
|
229
271
|
else if (removable) {
|
|
230
272
|
applied[part] = null;
|
|
231
273
|
here.push(part);
|
|
@@ -235,26 +277,39 @@ async function syncOnce(engine, state, force) {
|
|
|
235
277
|
if (changedHere) {
|
|
236
278
|
// Only this computer moved: the part goes up.
|
|
237
279
|
if (local[part] !== undefined) {
|
|
238
|
-
merged[part] =
|
|
239
|
-
applied[part] =
|
|
280
|
+
merged[part] = localParts[part];
|
|
281
|
+
applied[part] = localParts[part];
|
|
240
282
|
there.push(part);
|
|
241
283
|
}
|
|
284
|
+
else if (part === 'dirs' && remoteHas) {
|
|
285
|
+
// The list of directories was emptied here. That means "stop syncing these
|
|
286
|
+
// on this computer", not "everybody delete them": the part is carried
|
|
287
|
+
// through as the server has it, so the computers that still name a
|
|
288
|
+
// directory keep syncing it between themselves and no file anywhere is
|
|
289
|
+
// removed for it.
|
|
290
|
+
merged[part] = remotePart();
|
|
291
|
+
}
|
|
242
292
|
continue;
|
|
243
293
|
}
|
|
244
294
|
// Neither side moved. This computer's copy is taken when it has one, so a
|
|
245
295
|
// part that is present on only one side is never dropped by accident.
|
|
246
296
|
if (local[part] !== undefined) {
|
|
247
|
-
merged[part] =
|
|
248
|
-
applied[part] =
|
|
297
|
+
merged[part] = localParts[part];
|
|
298
|
+
applied[part] = localParts[part];
|
|
249
299
|
}
|
|
250
300
|
else if (remoteHas) {
|
|
251
301
|
merged[part] = remotePart();
|
|
252
|
-
|
|
302
|
+
// A computer that names no directories leaves its disk alone here: writing
|
|
303
|
+
// the server's list back would put files into a folder the user has just
|
|
304
|
+
// taken off that list. Restore is the action that lands in such a folder,
|
|
305
|
+
// and it says so on the card; an ordinary sync has nothing to write.
|
|
306
|
+
if (part !== 'dirs' || config.dirs.length > 0)
|
|
307
|
+
applied[part] = remotePart();
|
|
253
308
|
}
|
|
254
309
|
}
|
|
255
310
|
let saved;
|
|
256
311
|
if (conflicted)
|
|
257
|
-
saved = await keepConflict(engine, snapshot, remote);
|
|
312
|
+
saved = await keepConflict(engine, snapshot, remote, here.includes('dirs') || there.includes('dirs'));
|
|
258
313
|
else if (first && here.length > 0 && !sameHashes(local, remote.hashes)) {
|
|
259
314
|
// The server's setup is being taken over one this computer had, with no
|
|
260
315
|
// baseline to weigh them: a join, or a state file that was lost. Either way
|
|
@@ -272,7 +327,10 @@ async function syncOnce(engine, state, force) {
|
|
|
272
327
|
if (!pushNeeded) {
|
|
273
328
|
const written = await applyMerged(engine, snapshot, applied);
|
|
274
329
|
state.remote = remote.hashes;
|
|
275
|
-
|
|
330
|
+
// Read back rather than assume: a directory whose file was just written — or
|
|
331
|
+
// removed — has to be hashed as it now is, or the next sync would see an edit
|
|
332
|
+
// that this one made itself and call it a conflict.
|
|
333
|
+
state.local = hashesOf(partsOf(await take(paths, config), undefined));
|
|
276
334
|
// A part can come in without the file needing to be written: when only the
|
|
277
335
|
// server moved, the merge agrees with it. The person still has to be told
|
|
278
336
|
// what changed here, so the notice is recorded on this path too — clearing
|
|
@@ -317,7 +375,7 @@ async function syncOnce(engine, state, force) {
|
|
|
317
375
|
* version does not understand. The shapes are checked here instead, before
|
|
318
376
|
* anything is decided on.
|
|
319
377
|
*
|
|
320
|
-
* Absent parts are fine: a bundle may carry any of the
|
|
378
|
+
* Absent parts are fine: a bundle may carry any of the five.
|
|
321
379
|
*/
|
|
322
380
|
function unreadablePart(bundle) {
|
|
323
381
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -325,6 +383,31 @@ function unreadablePart(bundle) {
|
|
|
325
383
|
const value = bundle[part];
|
|
326
384
|
if (value === undefined)
|
|
327
385
|
continue;
|
|
386
|
+
// The extra directories are an object holding the list, and an empty list is a
|
|
387
|
+
// setup with none rather than a part that cannot be read.
|
|
388
|
+
if (part === 'dirs') {
|
|
389
|
+
if (!isObject(value))
|
|
390
|
+
return part;
|
|
391
|
+
const record = value;
|
|
392
|
+
if (!Array.isArray(record.dirs))
|
|
393
|
+
return part;
|
|
394
|
+
const readable = (one) => {
|
|
395
|
+
if (!isObject(one))
|
|
396
|
+
return false;
|
|
397
|
+
const dir = one;
|
|
398
|
+
if (typeof dir.path !== 'string' || !Array.isArray(dir.files))
|
|
399
|
+
return false;
|
|
400
|
+
return dir.files.every((file) => {
|
|
401
|
+
if (!isObject(file))
|
|
402
|
+
return false;
|
|
403
|
+
const inner = file;
|
|
404
|
+
return typeof inner.path === 'string' && typeof inner.data === 'string';
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
if (!record.dirs.every(readable))
|
|
408
|
+
return part;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
328
411
|
if (!isObject(value))
|
|
329
412
|
return part;
|
|
330
413
|
const record = value;
|
|
@@ -454,13 +537,13 @@ async function push(engine, state, snapshot, merged) {
|
|
|
454
537
|
// the one that was there before: the guard is asking, next time, whether the
|
|
455
538
|
// bytes on the server are still the ones it wrote.
|
|
456
539
|
const before = { sum: sum(sealed), local: state.local, remote: state.remote };
|
|
457
|
-
const dav =
|
|
540
|
+
const dav = davOf(config);
|
|
458
541
|
const written = await dav.put(sealed, merged.version.etag ?? '');
|
|
459
542
|
// The parts are written here only after the write to the server succeeded, so a
|
|
460
543
|
// failed upload never leaves this computer holding another computer's setup
|
|
461
544
|
// with nothing to say where its own went.
|
|
462
545
|
const applied = await applyMerged(engine, snapshot, merged.applied);
|
|
463
|
-
const now = hashesOf((await take(paths))
|
|
546
|
+
const now = hashesOf(partsOf(await take(paths, config), undefined));
|
|
464
547
|
state.server = written;
|
|
465
548
|
state.sum = sum(sealed);
|
|
466
549
|
state.remote = hashesOf(bundle);
|
|
@@ -496,9 +579,35 @@ async function push(engine, state, snapshot, merged) {
|
|
|
496
579
|
* profile.
|
|
497
580
|
*/
|
|
498
581
|
async function applyMerged(engine, snapshot, applied) {
|
|
499
|
-
const { paths
|
|
582
|
+
const { paths } = engine;
|
|
500
583
|
let reloadNeeded = false;
|
|
501
584
|
let kept = false;
|
|
585
|
+
// The directories are written first so that, when they are written at all, the
|
|
586
|
+
// copy kept for the way back is the one that carries them ({@link keepBefore}).
|
|
587
|
+
// `null` is "the other computer took this part away" — the instruction the other
|
|
588
|
+
// parts obey by deleting a row. Here it means nothing at all on disk: a sync
|
|
589
|
+
// never removes a file from somebody's own directory, so a removed part leaves
|
|
590
|
+
// what is there exactly as it is.
|
|
591
|
+
if (applied.dirs !== undefined && applied.dirs !== null) {
|
|
592
|
+
const part = applied.dirs;
|
|
593
|
+
// A directory this run could not read is not written into: what is there now
|
|
594
|
+
// was never read, so replacing it would be writing blind. A directory that is
|
|
595
|
+
// merely absent is not in this set — creating it is what a restore is for. See
|
|
596
|
+
// {@link writeDirs}.
|
|
597
|
+
const skip = new Set(snapshot.dirs.problems.filter((problem) => problem.missing !== true).map((problem) => problem.path));
|
|
598
|
+
// Compared with the disk rather than with the configured list, so that a
|
|
599
|
+
// computer which syncs no directories of its own — but is sent somebody's, which
|
|
600
|
+
// is how a restore lands in the same folder — does not seal a copy before a write
|
|
601
|
+
// that changes nothing. The copy is what Undo puts back, so it has to carry the
|
|
602
|
+
// directories whenever they really are about to be written to.
|
|
603
|
+
if (await dirsDiffer(part, skip)) {
|
|
604
|
+
kept = true;
|
|
605
|
+
await keepBefore(engine, snapshot, true);
|
|
606
|
+
const wrote = await writeDirs(part, skip);
|
|
607
|
+
if (wrote.changed)
|
|
608
|
+
reloadNeeded = true;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
502
611
|
if (applied.models !== undefined || applied.defaults !== undefined || applied.patch !== undefined) {
|
|
503
612
|
// `null` means "take this away", which is how a removal made on another
|
|
504
613
|
// computer reaches this one. The patch part has no row of its own to remove,
|
|
@@ -528,15 +637,23 @@ async function applyMerged(engine, snapshot, applied) {
|
|
|
528
637
|
}
|
|
529
638
|
return { reloadNeeded };
|
|
530
639
|
}
|
|
531
|
-
/**
|
|
532
|
-
|
|
640
|
+
/**
|
|
641
|
+
* Seal this computer's setup into `sync/before-apply` before a merge writes.
|
|
642
|
+
*
|
|
643
|
+
* `withDirs` is on when the write about to happen touches the extra directories,
|
|
644
|
+
* because that copy is what Undo puts back and a directory left out of it could not
|
|
645
|
+
* be put back. It is off otherwise: a directory tree can run to megabytes, and a
|
|
646
|
+
* copy taken so that one patch row can be undone has no business carrying it.
|
|
647
|
+
*/
|
|
648
|
+
async function keepBefore(engine, snapshot, withDirs = false) {
|
|
533
649
|
try {
|
|
650
|
+
const parts = withDirs ? partsOf(snapshot, undefined) : { ...snapshot.parts };
|
|
534
651
|
const bundle = {
|
|
535
652
|
format: BUNDLE_FORMAT,
|
|
536
653
|
version: BUNDLE_VERSION,
|
|
537
654
|
created: new Date().toISOString(),
|
|
538
655
|
app: BUNDLE_APP,
|
|
539
|
-
...
|
|
656
|
+
...parts,
|
|
540
657
|
};
|
|
541
658
|
const sealed = await seal(bundle, engine.config.passphrase);
|
|
542
659
|
return await keepAside(keptDir(engine.paths), 'before-apply', sealed);
|
|
@@ -547,15 +664,16 @@ async function keepBefore(engine, snapshot) {
|
|
|
547
664
|
}
|
|
548
665
|
}
|
|
549
666
|
/** Keep both sides of a conflict, so the one that lost is still readable. */
|
|
550
|
-
async function keepConflict(engine, snapshot, remote) {
|
|
667
|
+
async function keepConflict(engine, snapshot, remote, withDirs = false) {
|
|
551
668
|
const { paths, config } = engine;
|
|
552
669
|
const dir = keptDir(paths);
|
|
670
|
+
const parts = withDirs ? partsOf(snapshot, undefined) : { ...snapshot.parts };
|
|
553
671
|
const ours = {
|
|
554
672
|
format: BUNDLE_FORMAT,
|
|
555
673
|
version: BUNDLE_VERSION,
|
|
556
674
|
created: new Date().toISOString(),
|
|
557
675
|
app: BUNDLE_APP,
|
|
558
|
-
...
|
|
676
|
+
...parts,
|
|
559
677
|
};
|
|
560
678
|
await keepAside(dir, 'this-computer', await seal(ours, config.passphrase)).catch(() => undefined);
|
|
561
679
|
if (remote.body !== undefined) {
|
|
@@ -586,7 +704,7 @@ async function keepConflict(engine, snapshot, remote) {
|
|
|
586
704
|
* nothing.
|
|
587
705
|
*/
|
|
588
706
|
async function keepUnmerged(engine, snapshot) {
|
|
589
|
-
const kept = await keepBefore(engine, snapshot);
|
|
707
|
+
const kept = await keepBefore(engine, snapshot, true);
|
|
590
708
|
return kept === undefined ? undefined : keptDir(engine.paths);
|
|
591
709
|
}
|
|
592
710
|
/**
|
|
@@ -625,7 +743,7 @@ export async function restore(engine) {
|
|
|
625
743
|
throw new Error('同步还没有配置好:请先填写地址和口令');
|
|
626
744
|
await ensureStateDir(paths);
|
|
627
745
|
const state = await freshState(paths, config);
|
|
628
|
-
const dav =
|
|
746
|
+
const dav = davOf(config);
|
|
629
747
|
const read = await dav.get();
|
|
630
748
|
if (read.notModified || read.data === undefined) {
|
|
631
749
|
if (read.data === undefined)
|
|
@@ -634,8 +752,13 @@ export async function restore(engine) {
|
|
|
634
752
|
const remote = await openRemote(engine, state, { ...read, notModified: false });
|
|
635
753
|
if (remote.empty)
|
|
636
754
|
throw new Error('服务器上还没有 dsh 配置:没有可恢复的内容');
|
|
637
|
-
const snapshot = await take(paths);
|
|
638
|
-
|
|
755
|
+
const snapshot = await take(paths, config);
|
|
756
|
+
// What is here *on disk* is what the notice is about, so the directories are
|
|
757
|
+
// hashed without standing in the server's copy for the ones this computer could
|
|
758
|
+
// not read: a folder that is merely absent reads as nothing here, differs from
|
|
759
|
+
// the server's copy, and is therefore named as brought in — which is what a
|
|
760
|
+
// restore that creates it has just done.
|
|
761
|
+
const local = hashesOf(partsOf(snapshot, undefined));
|
|
639
762
|
const here = [];
|
|
640
763
|
const merged = {};
|
|
641
764
|
const applied = {};
|
|
@@ -655,16 +778,20 @@ export async function restore(engine) {
|
|
|
655
778
|
if (local[part] !== remote.hashes[part])
|
|
656
779
|
here.push(part);
|
|
657
780
|
}
|
|
658
|
-
const kept = await keepBefore(engine, snapshot);
|
|
781
|
+
const kept = await keepBefore(engine, snapshot, true);
|
|
659
782
|
const written = await applyMerged(engine, snapshot, applied);
|
|
660
783
|
state.undo = kept ?? '';
|
|
661
784
|
state.remote = remote.hashes;
|
|
662
|
-
state.local = hashesOf((await take(paths))
|
|
785
|
+
state.local = hashesOf(partsOf(await take(paths, config), undefined));
|
|
663
786
|
state.sum = read.data === undefined ? state.sum : sum(read.data);
|
|
664
787
|
state.server = read.version;
|
|
665
788
|
state.blind = undefined;
|
|
666
789
|
state.last = new Date().toISOString();
|
|
667
790
|
state.error = '';
|
|
791
|
+
// Every part named in `here` was written over this computer's copy, or was
|
|
792
|
+
// taken away for Undo; a restore that named none found the two sides alike.
|
|
793
|
+
if (here.length > 0)
|
|
794
|
+
state.moved = state.last;
|
|
668
795
|
state.notice = { at: new Date().toISOString(), here, there: [], ...(kept === undefined ? {} : { saved: keptDir(paths) }), restored: true };
|
|
669
796
|
if (read.data !== undefined)
|
|
670
797
|
await remember(paths, read.data);
|
|
@@ -694,7 +821,7 @@ export async function upload(engine) {
|
|
|
694
821
|
throw new Error('同步还没有配置好:请先填写地址和口令');
|
|
695
822
|
await ensureStateDir(paths);
|
|
696
823
|
const state = await freshState(paths, config);
|
|
697
|
-
const dav =
|
|
824
|
+
const dav = davOf(config);
|
|
698
825
|
const read = await dav.get();
|
|
699
826
|
if (read.data !== undefined) {
|
|
700
827
|
let readable = true;
|
|
@@ -715,9 +842,9 @@ export async function upload(engine) {
|
|
|
715
842
|
}
|
|
716
843
|
await keepAside(keptDir(paths), 'server-replaced', read.data);
|
|
717
844
|
}
|
|
718
|
-
const snapshot = await take(paths);
|
|
845
|
+
const snapshot = await take(paths, config);
|
|
719
846
|
const result = await push(engine, state, snapshot, {
|
|
720
|
-
merged: { ...snapshot
|
|
847
|
+
merged: { ...partsOf(snapshot, undefined) },
|
|
721
848
|
applied: {},
|
|
722
849
|
remote: {},
|
|
723
850
|
version: read.version,
|
|
@@ -726,6 +853,9 @@ export async function upload(engine) {
|
|
|
726
853
|
});
|
|
727
854
|
state.last = new Date().toISOString();
|
|
728
855
|
state.error = '';
|
|
856
|
+
// An upload is this computer's setup going up wholesale, whatever the server
|
|
857
|
+
// held before: it moved something even when it was asked for as a rescue.
|
|
858
|
+
state.moved = state.last;
|
|
729
859
|
await saveState(paths, state);
|
|
730
860
|
return result;
|
|
731
861
|
});
|
|
@@ -746,7 +876,7 @@ export async function undo(engine) {
|
|
|
746
876
|
throw new Error('恢复之前留下的那份副本已经不在了,没有东西可以放回去');
|
|
747
877
|
}
|
|
748
878
|
const bundle = (await open(new Uint8Array(data), config.passphrase));
|
|
749
|
-
const snapshot = await take(paths);
|
|
879
|
+
const snapshot = await take(paths, config);
|
|
750
880
|
const merged = {};
|
|
751
881
|
const applied = {};
|
|
752
882
|
const back = [];
|
|
@@ -792,8 +922,8 @@ export async function status(engine) {
|
|
|
792
922
|
const { paths, config } = engine;
|
|
793
923
|
const state = await loadState(paths);
|
|
794
924
|
const fresh = state.key === stateKey(config);
|
|
795
|
-
const snapshot = await take(paths).catch(() => undefined);
|
|
796
|
-
const local = snapshot === undefined ? {} : hashesOf(snapshot
|
|
925
|
+
const snapshot = await take(paths, config).catch(() => undefined);
|
|
926
|
+
const local = snapshot === undefined ? {} : hashesOf(partsOf(snapshot, undefined));
|
|
797
927
|
const parts = [];
|
|
798
928
|
const known = fresh ? state.local : undefined;
|
|
799
929
|
const knownRemote = fresh ? state.remote : undefined;
|
|
@@ -842,11 +972,17 @@ export async function status(engine) {
|
|
|
842
972
|
packageFile: paths.packageFile,
|
|
843
973
|
},
|
|
844
974
|
...(fresh && state.last !== undefined && state.last !== '' ? { last: state.last } : {}),
|
|
975
|
+
...(fresh && state.moved !== undefined && state.moved !== '' ? { moved: state.moved } : {}),
|
|
845
976
|
error: errorApplies && typeof state.error === 'string' ? state.error : '',
|
|
846
977
|
...(fresh && state.notice !== undefined ? { notice: state.notice } : {}),
|
|
847
978
|
parts,
|
|
848
979
|
pendingInstall: missing.length > 0,
|
|
849
980
|
...(missing.length > 0 ? { pendingNames: missing } : {}),
|
|
981
|
+
// A directory that could not be read is said here rather than kept quiet: it
|
|
982
|
+
// is the one thing about this feature the person has to act on.
|
|
983
|
+
...(snapshot === undefined || snapshot.dirs.problems.length === 0
|
|
984
|
+
? {}
|
|
985
|
+
: { dirProblems: snapshot.dirs.problems }),
|
|
850
986
|
undoable: fresh && state.undo !== undefined && state.undo !== '',
|
|
851
987
|
};
|
|
852
988
|
}
|
|
@@ -872,6 +1008,19 @@ export async function turnOff(engine) {
|
|
|
872
1008
|
// host would send, and turning sync off is the way to clear one.
|
|
873
1009
|
releasePassword(engine.paths);
|
|
874
1010
|
}
|
|
1011
|
+
/**
|
|
1012
|
+
* The client one configuration reaches: its address, its account, and the extra
|
|
1013
|
+
* headers the card configured. Parsed once here rather than at every call, so a
|
|
1014
|
+
* malformed configured line is refused before the first request goes out.
|
|
1015
|
+
*/
|
|
1016
|
+
function davOf(config) {
|
|
1017
|
+
return new Dav({
|
|
1018
|
+
url: config.url,
|
|
1019
|
+
user: config.user,
|
|
1020
|
+
password: config.password,
|
|
1021
|
+
headers: parseHeaders(config.headers),
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
875
1024
|
/** The configuration as the card sees it: never a secret, only whether one is set. */
|
|
876
1025
|
export function viewOf(config) {
|
|
877
1026
|
return {
|
|
@@ -884,7 +1033,12 @@ export function viewOf(config) {
|
|
|
884
1033
|
defaults: config.defaults,
|
|
885
1034
|
plugins: config.plugins,
|
|
886
1035
|
patch: config.patch,
|
|
1036
|
+
dirs: [...config.dirs],
|
|
1037
|
+
dirsEnabled: config.dirsEnabled,
|
|
887
1038
|
rememberPassword: config.rememberPassword,
|
|
1039
|
+
headers: config.headers,
|
|
1040
|
+
relayEnabled: config.relayEnabled,
|
|
1041
|
+
relayPort: config.relayPort,
|
|
888
1042
|
};
|
|
889
1043
|
}
|
|
890
1044
|
// ---------------------------------------------------------------------------
|
|
@@ -895,7 +1049,7 @@ export async function check(engine) {
|
|
|
895
1049
|
const { config } = engine;
|
|
896
1050
|
if (normaliseUrl(config.url) === '')
|
|
897
1051
|
throw new Error('请先填写同步地址');
|
|
898
|
-
const dav =
|
|
1052
|
+
const dav = davOf(config);
|
|
899
1053
|
try {
|
|
900
1054
|
await dav.check();
|
|
901
1055
|
}
|
|
@@ -946,11 +1100,11 @@ export async function preview(engine) {
|
|
|
946
1100
|
if (!usable(config))
|
|
947
1101
|
throw new Error('同步还没有配置好:请先填写地址和口令');
|
|
948
1102
|
const state = await freshState(paths, config);
|
|
949
|
-
const dav =
|
|
1103
|
+
const dav = davOf(config);
|
|
950
1104
|
const read = await dav.get(state.server);
|
|
951
1105
|
const remote = await openRemote(engine, state, read);
|
|
952
|
-
const snapshot = await take(paths);
|
|
953
|
-
const local = hashesOf(snapshot.
|
|
1106
|
+
const snapshot = await take(paths, config);
|
|
1107
|
+
const local = hashesOf(partsOf(snapshot, remote.part('dirs')));
|
|
954
1108
|
const first = state.local === undefined;
|
|
955
1109
|
const changes = [];
|
|
956
1110
|
for (const part of PARTS) {
|
|
@@ -976,10 +1130,15 @@ export async function preview(engine) {
|
|
|
976
1130
|
// Small helpers
|
|
977
1131
|
// ---------------------------------------------------------------------------
|
|
978
1132
|
/** Read this computer's setup and the files it came from, in one pass. */
|
|
979
|
-
export async function take(paths) {
|
|
1133
|
+
export async function take(paths, config) {
|
|
980
1134
|
const patch = await readPatch(paths);
|
|
981
1135
|
const manifest = await readManifest(paths);
|
|
982
|
-
|
|
1136
|
+
// Switched off, the directories are not walked at all: reading them is the one
|
|
1137
|
+
// part of a snapshot that can cost a great deal, and a part this configuration
|
|
1138
|
+
// does not carry has nothing to say. An empty read still tells the merge there is
|
|
1139
|
+
// nothing here to compare, which is exactly true.
|
|
1140
|
+
const dirs = await readDirs(config.dirsEnabled ? config.dirs : []);
|
|
1141
|
+
return { patch, manifest, parts: collect(patch, manifest), dirs };
|
|
983
1142
|
}
|
|
984
1143
|
/** Per-part hashes of a setup; a part that is not there has none. */
|
|
985
1144
|
export function hashesOf(parts) {
|
|
@@ -1006,6 +1165,10 @@ export function sameHashes(a, b) {
|
|
|
1006
1165
|
export function localChangedAt(snapshot, part) {
|
|
1007
1166
|
if (part === 'plugins')
|
|
1008
1167
|
return snapshot.manifest.mtimeMs;
|
|
1168
|
+
// The extra directories have no file of their own to point at, so the newest
|
|
1169
|
+
// file inside any of them stands for the whole part.
|
|
1170
|
+
if (part === 'dirs')
|
|
1171
|
+
return snapshot.dirs.mtimeMs;
|
|
1009
1172
|
return snapshot.patch.mtimeMs;
|
|
1010
1173
|
}
|
|
1011
1174
|
/** The top-level packages present in the profile's node_modules. */
|
package/lib/index.d.ts
CHANGED
|
@@ -20,8 +20,6 @@ import { sameAccount, usable, type Engine } from './engine.js';
|
|
|
20
20
|
import { defaultConfig, normaliseConfig } from './state.js';
|
|
21
21
|
export declare const name = "dsh-webdav-sync";
|
|
22
22
|
export declare const inject: never[];
|
|
23
|
-
/** The settings namespace this plugin serves; its browser card spells the same string. */
|
|
24
|
-
export declare const WEBDAV_SYNC_NS = "dsh-webdav-sync";
|
|
25
23
|
export interface Config {
|
|
26
24
|
/** Profile directory to sync; absent selects the running profile. */
|
|
27
25
|
profileDir?: Volatile<string | undefined> | string;
|
|
@@ -36,13 +34,6 @@ export interface Config {
|
|
|
36
34
|
export declare const Config: z;
|
|
37
35
|
/** Reject stale or misspelled config keys before defaults can hide them. */
|
|
38
36
|
export declare function validateConfig(config: Config): void;
|
|
39
|
-
/** The settings-facing subset of the config. */
|
|
40
|
-
export interface SectionConfig {
|
|
41
|
-
profileDir?: string;
|
|
42
|
-
autoEvery: number;
|
|
43
|
-
}
|
|
44
|
-
/** The section-shaped view of a config: absent keys stay absent. */
|
|
45
|
-
export declare function sectionOf(config: Config): SectionConfig;
|
|
46
37
|
/**
|
|
47
38
|
* Apply one configuration change from the card.
|
|
48
39
|
*
|
|
@@ -59,16 +50,14 @@ export declare function configure(engine: Engine, body: ConfigureBody): Promise<
|
|
|
59
50
|
/**
|
|
60
51
|
* Plugin apply: mount the settings presentation policy, the HTTP routes and the
|
|
61
52
|
* auto-sync timer on any webServer that comes and goes.
|
|
62
|
-
*/
|
|
63
|
-
/**
|
|
64
|
-
* Passwords that are in use but deliberately not on disk.
|
|
65
53
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
54
|
+
* Passwords that are in use but deliberately not on disk are read back here:
|
|
55
|
+
* "remember the password" off means it must not be written to `sync.json`, and it
|
|
56
|
+
* does not mean it should stop working. Every route rebuilds its engine from that
|
|
57
|
+
* file, so a password emptied there would be one no request could carry and sync
|
|
58
|
+
* would be unable to sign in until the process restarted. It is held for as long
|
|
59
|
+
* as the host runs, keyed by the state directory so two profiles do not share
|
|
60
|
+
* one, and put back into the configuration each time it is read.
|
|
72
61
|
*/
|
|
73
62
|
export declare function apply(ctx: Context, config?: Config): void;
|
|
74
63
|
export { defaultConfig, normaliseConfig, sameAccount, usable };
|