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/LICENSE +21 -0
- package/README.md +298 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +9 -0
- package/lib/client.js +1144 -0
- package/lib/crypto.d.ts +64 -0
- package/lib/crypto.js +185 -0
- package/lib/dav.d.ts +137 -0
- package/lib/dav.js +548 -0
- package/lib/engine.d.ts +100 -0
- package/lib/engine.js +1042 -0
- package/lib/index.d.ts +74 -0
- package/lib/index.js +329 -0
- package/lib/install.d.ts +28 -0
- package/lib/install.js +269 -0
- package/lib/paths.d.ts +57 -0
- package/lib/paths.js +79 -0
- package/lib/state.d.ts +93 -0
- package/lib/state.js +253 -0
- package/lib/store.d.ts +147 -0
- package/lib/store.js +560 -0
- package/lib/wire.d.ts +346 -0
- package/lib/wire.js +71 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +107 -3
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 };
|