tether-sync 0.2.0 → 0.6.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.
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
import {
|
|
2
2
|
GIT_MODES,
|
|
3
3
|
VERSION,
|
|
4
|
+
blobAt,
|
|
5
|
+
changeFromGit,
|
|
6
|
+
changesBetween,
|
|
7
|
+
fileBlob,
|
|
4
8
|
files,
|
|
5
9
|
findRepo,
|
|
10
|
+
gitBinary,
|
|
6
11
|
gitBusy,
|
|
7
|
-
|
|
12
|
+
head,
|
|
13
|
+
indexBlob,
|
|
8
14
|
ipcEndpoint,
|
|
15
|
+
isAncestor,
|
|
16
|
+
isKept,
|
|
9
17
|
isNewer,
|
|
18
|
+
isTracked,
|
|
10
19
|
isWindows,
|
|
11
20
|
latestVersion,
|
|
12
21
|
listen,
|
|
13
22
|
removeHooks,
|
|
23
|
+
restage,
|
|
24
|
+
setKeep,
|
|
14
25
|
tetherHome
|
|
15
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-KG4OTE34.js";
|
|
16
27
|
|
|
17
28
|
// packages/client/src/daemon.js
|
|
18
29
|
import fs8 from "node:fs";
|
|
@@ -34,6 +45,7 @@ var DEFAULTS = {
|
|
|
34
45
|
// all | errors | off
|
|
35
46
|
};
|
|
36
47
|
var NUMERIC = /* @__PURE__ */ new Set(["tickSeconds", "graceMs"]);
|
|
48
|
+
var MIN_TICK_SECONDS = Number(process.env.TETHER_MIN_TICK_SECONDS) || 10;
|
|
37
49
|
var Config = class {
|
|
38
50
|
constructor(file) {
|
|
39
51
|
this.file = file;
|
|
@@ -52,6 +64,7 @@ var Config = class {
|
|
|
52
64
|
if (NUMERIC.has(key)) {
|
|
53
65
|
value = Number(value);
|
|
54
66
|
if (!Number.isFinite(value) || value <= 0) throw new Error(`${key} must be a positive number`);
|
|
67
|
+
if (key === "tickSeconds" && value < MIN_TICK_SECONDS) throw new Error(`tickSeconds must be at least ${MIN_TICK_SECONDS}`);
|
|
55
68
|
}
|
|
56
69
|
if (key === "notifications" && !["all", "errors", "off"].includes(value)) throw new Error("notifications must be all, errors or off");
|
|
57
70
|
if (key === "scanRoots" && typeof value === "string") value = value.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
|
|
@@ -113,11 +126,16 @@ var COLUMNS = {
|
|
|
113
126
|
gitRoot: "TEXT",
|
|
114
127
|
gitDir: "TEXT",
|
|
115
128
|
gitHooks: "TEXT",
|
|
129
|
+
// unused since 0.2.1 (hooks were replaced by watching the git directory)
|
|
130
|
+
gitWaiting: "INTEGER DEFAULT 0",
|
|
131
|
+
// follow: a newer Tether version came from a git commit not pulled here yet
|
|
132
|
+
gitOverlay: "INTEGER DEFAULT 0",
|
|
133
|
+
// follow: holds a Tether-only version that git does not have (hidden from git)
|
|
116
134
|
lastError: "TEXT",
|
|
117
135
|
createdAt: "REAL",
|
|
118
136
|
updatedAt: "REAL"
|
|
119
137
|
};
|
|
120
|
-
var BOOL = /* @__PURE__ */ new Set(["realtime", "hasBom", "exec", "canDelete", "detached", "needsRestore"]);
|
|
138
|
+
var BOOL = /* @__PURE__ */ new Set(["gitOverlay", "gitWaiting", "realtime", "hasBom", "exec", "canDelete", "detached", "needsRestore"]);
|
|
121
139
|
var LocalDb = class {
|
|
122
140
|
constructor(file) {
|
|
123
141
|
this.db = new DatabaseSync(file);
|
|
@@ -342,7 +360,73 @@ function randomString(len) {
|
|
|
342
360
|
}
|
|
343
361
|
var newInstanceId = () => "i_" + randomString(10);
|
|
344
362
|
|
|
363
|
+
// packages/shared/src/chunks.js
|
|
364
|
+
var CHUNK_MIN = 2 * 1024;
|
|
365
|
+
var CHUNK_AVG = 8 * 1024;
|
|
366
|
+
var CHUNK_MAX = 64 * 1024;
|
|
367
|
+
var MASK_HARD = 4294836224;
|
|
368
|
+
var MASK_EASY = 4292870144;
|
|
369
|
+
var GEAR = (() => {
|
|
370
|
+
let a = 2122219134;
|
|
371
|
+
const t = new Uint32Array(256);
|
|
372
|
+
for (let i = 0; i < 256; i++) {
|
|
373
|
+
a = a + 1831565813 | 0;
|
|
374
|
+
let x = Math.imul(a ^ a >>> 15, 1 | a);
|
|
375
|
+
x = x + Math.imul(x ^ x >>> 7, 61 | x) ^ x;
|
|
376
|
+
t[i] = (x ^ x >>> 14) >>> 0;
|
|
377
|
+
}
|
|
378
|
+
return t;
|
|
379
|
+
})();
|
|
380
|
+
function chunkBoundaries(buf) {
|
|
381
|
+
const out = [];
|
|
382
|
+
const n = buf.length;
|
|
383
|
+
let start = 0;
|
|
384
|
+
while (start < n) {
|
|
385
|
+
if (n - start <= CHUNK_MIN) {
|
|
386
|
+
out.push({ o: start, s: n - start });
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
const end = Math.min(n, start + CHUNK_MAX);
|
|
390
|
+
const normal = Math.min(end, start + CHUNK_AVG);
|
|
391
|
+
let h = 0;
|
|
392
|
+
let cut = end;
|
|
393
|
+
let i = start + CHUNK_MIN;
|
|
394
|
+
for (; i < normal; i++) {
|
|
395
|
+
h = (h << 1) + GEAR[buf[i]] >>> 0;
|
|
396
|
+
if ((h & MASK_HARD) === 0) {
|
|
397
|
+
cut = i + 1;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (cut === end) {
|
|
402
|
+
for (; i < end; i++) {
|
|
403
|
+
h = (h << 1) + GEAR[buf[i]] >>> 0;
|
|
404
|
+
if ((h & MASK_EASY) === 0) {
|
|
405
|
+
cut = i + 1;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
out.push({ o: start, s: cut - start });
|
|
411
|
+
start = cut;
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
function chunkContent(buf) {
|
|
416
|
+
return chunkBoundaries(buf).map(({ o, s }) => {
|
|
417
|
+
const bytes = buf.subarray(o, o + s);
|
|
418
|
+
return { h: sha256(bytes), s, bytes };
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
345
422
|
// packages/client/src/api.js
|
|
423
|
+
var PUT_BATCH_BYTES = 1024 * 1024;
|
|
424
|
+
var PUT_PARALLEL = 4;
|
|
425
|
+
var PUT_BATCH_CHUNKS = 100;
|
|
426
|
+
var FETCH_BATCH_BYTES = 1024 * 1024;
|
|
427
|
+
var FETCH_PARALLEL = 4;
|
|
428
|
+
var kb = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
429
|
+
var describeTransfer = (t, verb) => t ? `${verb} ${t.moved} of ${t.total} chunk${t.total === 1 ? "" : "s"}, ${kb(t.bytes)}` : "";
|
|
346
430
|
var ApiError = class extends Error {
|
|
347
431
|
constructor(status, body) {
|
|
348
432
|
super(body?.message ?? `HTTP ${status}`);
|
|
@@ -375,6 +459,39 @@ var Api = class {
|
|
|
375
459
|
this.config = config;
|
|
376
460
|
this.credentials = credentials;
|
|
377
461
|
this.accessToken = null;
|
|
462
|
+
this.manifests = /* @__PURE__ */ new Map();
|
|
463
|
+
this.transfers = /* @__PURE__ */ new Map();
|
|
464
|
+
}
|
|
465
|
+
/** Authenticated POST with a binary body or a binary response; refreshes the token once on 401. */
|
|
466
|
+
async binary(p, { json, body, headers = {} }) {
|
|
467
|
+
const send = async (token) => {
|
|
468
|
+
let res;
|
|
469
|
+
try {
|
|
470
|
+
res = await fetch(this.base + "/v1" + p, {
|
|
471
|
+
method: "POST",
|
|
472
|
+
headers: { authorization: `Bearer ${token}`, "content-type": json ? "application/json" : "application/octet-stream", ...headers },
|
|
473
|
+
body: json ? JSON.stringify(json) : body,
|
|
474
|
+
signal: AbortSignal.timeout(18e4)
|
|
475
|
+
});
|
|
476
|
+
} catch (e) {
|
|
477
|
+
throw new OfflineError(`cannot reach the Tether server at ${this.base} (${e.cause?.code ?? e.message})`);
|
|
478
|
+
}
|
|
479
|
+
if (!res.ok) throw new ApiError(res.status, (await res.json().catch(() => ({}))).error);
|
|
480
|
+
return Buffer.from(await res.arrayBuffer());
|
|
481
|
+
};
|
|
482
|
+
try {
|
|
483
|
+
return await send(await this.token());
|
|
484
|
+
} catch (e) {
|
|
485
|
+
if (e instanceof ApiError && e.status === 401 && this.accessToken) {
|
|
486
|
+
this.accessToken = null;
|
|
487
|
+
return send(await this.token());
|
|
488
|
+
}
|
|
489
|
+
throw e;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** The chunk list to commit for content uploaded by uploadBlobs (undefined on old servers). */
|
|
493
|
+
manifestFor(sha) {
|
|
494
|
+
return this.manifests.get(sha);
|
|
378
495
|
}
|
|
379
496
|
get base() {
|
|
380
497
|
return this.config.get("server").replace(/\/$/, "");
|
|
@@ -485,10 +602,58 @@ var Api = class {
|
|
|
485
602
|
this.credentials.clear();
|
|
486
603
|
this.accessToken = null;
|
|
487
604
|
}
|
|
488
|
-
/**
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
605
|
+
/**
|
|
606
|
+
* Uploads content as chunks (delta sync): every file is split into content-defined chunks and
|
|
607
|
+
* only the chunks the server does not have are sent. blobs: [{ sha256, bytes }] (canonical).
|
|
608
|
+
* Afterwards manifestFor(sha) gives each file's chunk list for the commit.
|
|
609
|
+
*/
|
|
610
|
+
async uploadBlobs(blobs, { artifactId } = {}) {
|
|
611
|
+
const files2 = [...new Map(blobs.map((b) => [b.sha256, b])).values()];
|
|
612
|
+
if (!files2.length) return;
|
|
613
|
+
const all = /* @__PURE__ */ new Map();
|
|
614
|
+
for (const f of files2) {
|
|
615
|
+
const parts = chunkContent(f.bytes);
|
|
616
|
+
this.manifests.set(f.sha256, parts.map(({ h, s }) => ({ h, s })));
|
|
617
|
+
for (const c of parts) all.set(c.h, c.bytes);
|
|
618
|
+
}
|
|
619
|
+
let missing;
|
|
620
|
+
try {
|
|
621
|
+
({ missing } = await this.call("POST", "/chunks/missing", { hashes: [...all.keys()], artifactId }));
|
|
622
|
+
} catch (e) {
|
|
623
|
+
if (!(e instanceof ApiError && e.status === 404)) throw e;
|
|
624
|
+
for (const f of files2) this.manifests.delete(f.sha256);
|
|
625
|
+
return this.uploadWhole(files2);
|
|
626
|
+
}
|
|
627
|
+
const batches = [];
|
|
628
|
+
let batch = [];
|
|
629
|
+
let size = 0;
|
|
630
|
+
for (const h of missing) {
|
|
631
|
+
const bytes = all.get(h);
|
|
632
|
+
if (batch.length && (batch.length >= PUT_BATCH_CHUNKS || size + bytes.length > PUT_BATCH_BYTES)) {
|
|
633
|
+
batches.push(batch);
|
|
634
|
+
batch = [];
|
|
635
|
+
size = 0;
|
|
636
|
+
}
|
|
637
|
+
batch.push({ h, bytes });
|
|
638
|
+
size += bytes.length;
|
|
639
|
+
}
|
|
640
|
+
if (batch.length) batches.push(batch);
|
|
641
|
+
let next = 0;
|
|
642
|
+
await Promise.all(Array.from({ length: Math.min(PUT_PARALLEL, batches.length) }, async () => {
|
|
643
|
+
while (next < batches.length) {
|
|
644
|
+
const b = batches[next++];
|
|
645
|
+
await this.binary("/chunks/put", { body: Buffer.concat(b.map((c) => c.bytes)), headers: { "x-tether-chunks": b.map((c) => `${c.h}:${c.bytes.length}`).join(",") } });
|
|
646
|
+
}
|
|
647
|
+
}));
|
|
648
|
+
const sent = new Set(missing);
|
|
649
|
+
for (const f of files2) {
|
|
650
|
+
const m = this.manifests.get(f.sha256);
|
|
651
|
+
const moved = m.filter((c) => sent.has(c.h));
|
|
652
|
+
this.transfers.set(f.sha256, { moved: moved.length, total: m.length, bytes: moved.reduce((a, c) => a + c.s, 0) });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/** Whole-file upload through signed URLs, for servers without delta sync. */
|
|
656
|
+
async uploadWhole(unique) {
|
|
492
657
|
const { upload } = await this.call("POST", "/blobs/upload", { blobs: unique.map((b) => ({ sha256: b.sha256, size: b.bytes.length })) });
|
|
493
658
|
const bySha = new Map(unique.map((b) => [b.sha256, b]));
|
|
494
659
|
for (const u of upload) {
|
|
@@ -496,10 +661,63 @@ var Api = class {
|
|
|
496
661
|
if (!res.ok && res.status !== 412) throw new ApiError(res.status, { message: `upload failed (${res.status}): ${(await res.text().catch(() => "")).slice(0, 200)}` });
|
|
497
662
|
}
|
|
498
663
|
}
|
|
499
|
-
/**
|
|
664
|
+
/**
|
|
665
|
+
* Downloads content into the cache, by chunks (delta sync). items: [{ artifactId, version, sha,
|
|
666
|
+
* base? }] where base is content already cached (usually the version on disk): its chunks are
|
|
667
|
+
* reused, so only chunks that changed are downloaded.
|
|
668
|
+
*/
|
|
500
669
|
async fetchBlobs(cache, items) {
|
|
501
|
-
|
|
502
|
-
|
|
670
|
+
const need = [...new Map(items.filter((i) => !i.sha || !cache.has(i.sha)).map((i) => [`${i.artifactId}@${i.version}`, i])).values()];
|
|
671
|
+
if (!need.length) return;
|
|
672
|
+
let manifests;
|
|
673
|
+
try {
|
|
674
|
+
({ manifests } = await this.call("POST", "/chunks/manifest", { items: need.map(({ artifactId, version }) => ({ artifactId, version })) }));
|
|
675
|
+
} catch (e) {
|
|
676
|
+
if (!(e instanceof ApiError && e.status === 404)) throw e;
|
|
677
|
+
return this.fetchWhole(cache, need);
|
|
678
|
+
}
|
|
679
|
+
for (const [k, m] of manifests.entries()) {
|
|
680
|
+
if (cache.has(m.blob)) continue;
|
|
681
|
+
const local = /* @__PURE__ */ new Map();
|
|
682
|
+
const base = need[k].base;
|
|
683
|
+
if (base && cache.has(base)) for (const c of chunkContent(cache.get(base))) local.set(c.h, c.bytes);
|
|
684
|
+
const size = new Map(m.chunks.map((c) => [c.h, c.s]));
|
|
685
|
+
const want = [...size.keys()].filter((h) => !local.has(h));
|
|
686
|
+
let bytesIn = 0;
|
|
687
|
+
const batches = [];
|
|
688
|
+
for (let i = 0; i < want.length; ) {
|
|
689
|
+
const batch = [];
|
|
690
|
+
let total = 0;
|
|
691
|
+
while (i < want.length && (!batch.length || total + size.get(want[i]) <= FETCH_BATCH_BYTES)) {
|
|
692
|
+
batch.push(want[i]);
|
|
693
|
+
total += size.get(want[i++]);
|
|
694
|
+
}
|
|
695
|
+
batches.push(batch);
|
|
696
|
+
bytesIn += total;
|
|
697
|
+
}
|
|
698
|
+
let next = 0;
|
|
699
|
+
await Promise.all(Array.from({ length: Math.min(FETCH_PARALLEL, batches.length) }, async () => {
|
|
700
|
+
while (next < batches.length) {
|
|
701
|
+
const batch = batches[next++];
|
|
702
|
+
const buf = await this.binary("/chunks/fetch", { json: { artifactId: m.artifactId, version: m.version, hashes: batch } });
|
|
703
|
+
let off = 0;
|
|
704
|
+
for (const h of batch) {
|
|
705
|
+
const bytes = buf.subarray(off, off + size.get(h));
|
|
706
|
+
off += size.get(h);
|
|
707
|
+
if (sha256(bytes) !== h) throw new Error("downloaded chunk does not match its hash");
|
|
708
|
+
local.set(h, Buffer.from(bytes));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}));
|
|
712
|
+
const content = Buffer.concat(m.chunks.map((c) => local.get(c.h)));
|
|
713
|
+
if (sha256(content) !== m.blob) throw new Error("downloaded content does not match its hash");
|
|
714
|
+
cache.put(m.blob, content);
|
|
715
|
+
this.transfers.set(m.blob, { moved: want.length, total: size.size, bytes: bytesIn });
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/** Whole-file downloads through signed URLs, for servers without delta sync. */
|
|
719
|
+
async fetchWhole(cache, items) {
|
|
720
|
+
const { downloads } = await this.call("POST", "/blobs/download", { items: items.map(({ artifactId, version }) => ({ artifactId, version })) });
|
|
503
721
|
for (const d of downloads) {
|
|
504
722
|
if (cache.has(d.sha256)) continue;
|
|
505
723
|
const res = await fetch(d.url);
|
|
@@ -771,6 +989,7 @@ function walkFiles(root) {
|
|
|
771
989
|
var UserError = class extends Error {
|
|
772
990
|
};
|
|
773
991
|
var ACTIVE = (i) => i.mode !== "orphaned";
|
|
992
|
+
var FULL_CHECK_MS = 60 * 60 * 1e3;
|
|
774
993
|
var Engine = class {
|
|
775
994
|
constructor({ db, api, cache, tracker, config, log = () => {
|
|
776
995
|
}, notify = () => {
|
|
@@ -819,20 +1038,38 @@ var Engine = class {
|
|
|
819
1038
|
if (!this.cache.has(sha)) throw new Error(`content ${sha.slice(0, 12)} is not in the local cache`);
|
|
820
1039
|
return this.cache.get(sha);
|
|
821
1040
|
}
|
|
1041
|
+
/** Downloads content not yet cached; `base` (content already here) lets only changed chunks travel. */
|
|
822
1042
|
async ensureBlobs(pairs) {
|
|
823
1043
|
const missing = pairs.filter((p) => p.sha && !this.cache.has(p.sha));
|
|
824
|
-
if (missing.length) await this.api.fetchBlobs(this.cache, missing
|
|
1044
|
+
if (missing.length) await this.api.fetchBlobs(this.cache, missing);
|
|
1045
|
+
}
|
|
1046
|
+
/** Uploads content (only chunks the server lacks) and returns the commit fields for it. */
|
|
1047
|
+
async uploadContent(local, artifactId) {
|
|
1048
|
+
await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }], { artifactId });
|
|
1049
|
+
this.cache.put(local.hash, local.canonical);
|
|
1050
|
+
return { blob: local.hash, size: local.canonical.length, chunks: this.api.manifestFor(local.hash) };
|
|
1051
|
+
}
|
|
1052
|
+
/** ", sent 2 of 540 chunks, 17 KB" for log lines (empty when nothing is known). */
|
|
1053
|
+
sentNote(sha) {
|
|
1054
|
+
const t = describeTransfer(this.api.transfers.get(sha), "sent");
|
|
1055
|
+
return t ? `; ${t}` : "";
|
|
825
1056
|
}
|
|
826
1057
|
/** Writes content for an instance at `p`; records the result for the quick change check. */
|
|
827
1058
|
writeInstance(inst, p, sha, eolMode = inst.eolMode, exec = inst.exec) {
|
|
828
1059
|
const style = inst.eolStyle ? { eol: inst.eolStyle, bom: !!inst.hasBom } : null;
|
|
829
|
-
|
|
1060
|
+
const st = writeLocal(p, this.blobFor(sha), eolMode, { exec, style });
|
|
1061
|
+
if (inst.gitMode === "keep" && inst.gitRoot) {
|
|
1062
|
+
restage(inst.gitRoot, p);
|
|
1063
|
+
const after = statOrNull(p);
|
|
1064
|
+
if (after) Object.assign(st, { size: after.size, mtimeMs: after.mtimeMs });
|
|
1065
|
+
}
|
|
1066
|
+
return st;
|
|
830
1067
|
}
|
|
831
1068
|
// ---------- tick ----------
|
|
832
1069
|
async tick({ remote = true } = {}) {
|
|
833
1070
|
this.busyCache = /* @__PURE__ */ new Map();
|
|
834
1071
|
await this.locateAll();
|
|
835
|
-
|
|
1072
|
+
await this.gitPass();
|
|
836
1073
|
if (remote) {
|
|
837
1074
|
await this.checkRemote();
|
|
838
1075
|
await this.syncRealtime();
|
|
@@ -847,16 +1084,26 @@ var Engine = class {
|
|
|
847
1084
|
if (!this.busyCache.has(inst.gitDir)) this.busyCache.set(inst.gitDir, gitBusy(inst.gitDir));
|
|
848
1085
|
return !!this.busyCache.get(inst.gitDir);
|
|
849
1086
|
}
|
|
1087
|
+
gitInstances() {
|
|
1088
|
+
return this.db.all().filter((i) => i.gitMode && i.gitRoot && i.mode !== "orphaned");
|
|
1089
|
+
}
|
|
850
1090
|
/** Sets the git mode on an instance (and a folder's files). Throws if follow is not allowed. */
|
|
851
1091
|
applyGitMode(root, mode) {
|
|
852
1092
|
if (!root) return;
|
|
853
1093
|
if (!GIT_MODES.includes(mode)) throw new UserError(`git mode must be one of ${GIT_MODES.join(", ")}`);
|
|
854
1094
|
if (mode === "follow" && root.role === "viewer") throw new UserError("git follow mode needs editor access");
|
|
1095
|
+
if (mode === "follow" && !gitBinary()) throw new UserError("git follow mode needs git to be installed");
|
|
855
1096
|
const repo = findRepo(root.path);
|
|
856
1097
|
if (!repo) throw new UserError(`${root.path} is not inside a git repository`);
|
|
857
|
-
const fields = { gitMode: mode, gitRoot: repo.root, gitDir: repo.gitDir,
|
|
858
|
-
for (const i of [root, ...root.kind === "folder" ? this.db.childrenOf(root.instanceId) : []])
|
|
859
|
-
|
|
1098
|
+
const fields = { gitMode: mode, gitRoot: repo.root, gitDir: repo.gitDir, gitWaiting: false };
|
|
1099
|
+
for (const i of [root, ...root.kind === "folder" ? this.db.childrenOf(root.instanceId) : []]) {
|
|
1100
|
+
this.db.update(i.instanceId, fields);
|
|
1101
|
+
if (i.kind === "file") setKeep(repo.root, i.path, mode === "keep");
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
/** Lets git see the file normally again (after unmanage, orphaning, or leaving keep mode). */
|
|
1105
|
+
releaseGit(inst) {
|
|
1106
|
+
if (inst.gitMode === "keep" && inst.kind === "file" && inst.gitRoot) setKeep(inst.gitRoot, inst.path, false);
|
|
860
1107
|
}
|
|
861
1108
|
/** `tether git <path> [--mode]`: show or change the git mode of managed files. */
|
|
862
1109
|
gitMode({ path: p, mode }) {
|
|
@@ -865,73 +1112,135 @@ var Engine = class {
|
|
|
865
1112
|
if (mode) for (const i of ts.filter((x) => !x.folderInstanceId || !folders.has(x.folderInstanceId))) this.applyGitMode(i.folderInstanceId ? this.db.get(i.folderInstanceId) : i, mode);
|
|
866
1113
|
return this.targets(p).map((i) => ({ path: i.path, kind: i.kind, role: i.role, gitMode: this.db.get(i.instanceId)?.gitMode ?? null, gitRoot: this.db.get(i.instanceId)?.gitRoot ?? findRepo(i.path)?.root ?? null }));
|
|
867
1114
|
}
|
|
868
|
-
/**
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
for (const dir of want) {
|
|
874
|
-
try {
|
|
875
|
-
installHooks(dir, { home: this.home, node: process.execPath, cli: this.cliPath });
|
|
876
|
-
} catch (e) {
|
|
877
|
-
this.log(`could not install git hooks in ${dir}: ${e.message}`);
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
for (const dir of had) if (!want.has(dir)) removeHooks(dir, this.home);
|
|
881
|
-
this.db.setMeta("gitHooks", [...want]);
|
|
1115
|
+
/** Tether 0.2.0 used git hooks; they are no longer needed. */
|
|
1116
|
+
cleanupLegacyHooks() {
|
|
1117
|
+
const dirs = this.db.getMeta("gitHooks") ?? [];
|
|
1118
|
+
for (const dir of dirs) if (this.home) removeHooks(dir, this.home);
|
|
1119
|
+
if (dirs.length) this.db.setMeta("gitHooks", null);
|
|
882
1120
|
}
|
|
883
|
-
/**
|
|
884
|
-
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
this.
|
|
891
|
-
if (was && !busy) await this.gitReconcile(root);
|
|
1121
|
+
/** Managed files that ended up inside a git repository later (git init, moved into one) get keep mode. */
|
|
1122
|
+
detectNewRepos() {
|
|
1123
|
+
for (const i of this.db.all().filter((x) => !x.gitMode && !x.folderInstanceId && x.mode !== "orphaned" && !x.detached)) {
|
|
1124
|
+
const repo = findRepo(i.path);
|
|
1125
|
+
if (!repo) continue;
|
|
1126
|
+
this.applyGitMode(i, "keep");
|
|
1127
|
+
this.log(`${i.path}: now inside the git repository ${repo.root}; using keep mode`);
|
|
1128
|
+
this.notify({ kind: "git-new", level: "info", short: path4.basename(i.path), title: "Managed file is now in a git repository", body: `${path4.basename(i.path)} is inside ${repo.root}. Tether keeps it as the shared version and git ignores Tether's copy. Run "tether git ${path4.basename(i.path)} --mode follow" to publish git's changes instead.` });
|
|
892
1129
|
}
|
|
893
1130
|
}
|
|
894
1131
|
/**
|
|
895
|
-
*
|
|
896
|
-
*
|
|
1132
|
+
* Runs after git may have changed files (watcher on the git directory, and every tick):
|
|
1133
|
+
* follows renames git made, brings back files git restored, keeps keep-mode files hidden from
|
|
1134
|
+
* git, and publishes follow-mode files whose content now matches git's committed or staged state.
|
|
897
1135
|
*/
|
|
898
|
-
async
|
|
899
|
-
|
|
900
|
-
const
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1136
|
+
async gitPass({ only } = {}) {
|
|
1137
|
+
const repos = /* @__PURE__ */ new Map();
|
|
1138
|
+
for (const i of this.gitInstances()) if (!only || samePath(i.gitRoot, only)) repos.set(i.gitDir, i.gitRoot);
|
|
1139
|
+
if (!repos.size) return;
|
|
1140
|
+
const heads = this.db.getMeta("gitHeads") ?? {};
|
|
1141
|
+
const publish = [];
|
|
1142
|
+
for (const [gitDir, root] of repos) {
|
|
1143
|
+
if (gitBusy(gitDir)) continue;
|
|
1144
|
+
const h = gitBinary() ? head(root) : null;
|
|
1145
|
+
const prev = heads[gitDir];
|
|
1146
|
+
const headMoved = !!h && h !== prev;
|
|
1147
|
+
if (h && prev && h !== prev) await this.followGitHistory(root, prev, h);
|
|
1148
|
+
if (h) heads[gitDir] = h;
|
|
1149
|
+
for (const i of this.gitInstances().filter((x) => x.gitDir === gitDir)) {
|
|
1150
|
+
if (i.kind !== "file") continue;
|
|
1151
|
+
if (i.detached) {
|
|
1152
|
+
if (statOrNull(i.path)?.isFile()) {
|
|
1153
|
+
this.db.update(i.instanceId, { ...this.identity(i.path), detached: false, missingSince: null, size: null, mtimeMs: null });
|
|
1154
|
+
if (i.gitMode === "keep") setKeep(root, i.path, true);
|
|
1155
|
+
this.log(`${i.path}: back (restored by git)`);
|
|
1156
|
+
}
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
if (i.mode !== "normal") continue;
|
|
1160
|
+
if (i.gitMode === "keep") {
|
|
1161
|
+
if ((headMoved || !prev) && gitBinary() && !isKept(root, i.path)) setKeep(root, i.path, true);
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
if (!gitBinary()) continue;
|
|
1165
|
+
let local;
|
|
1166
|
+
try {
|
|
1167
|
+
local = readLocal(i.path, i.eolMode);
|
|
1168
|
+
} catch {
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
if (i.gitOverlay) {
|
|
1172
|
+
if (fileBlob(root, i.path) !== blobAt(root, "HEAD", i.path)) continue;
|
|
1173
|
+
setKeep(root, i.path, false);
|
|
1174
|
+
this.db.update(i.instanceId, { gitOverlay: false });
|
|
1175
|
+
i.gitOverlay = false;
|
|
1176
|
+
}
|
|
1177
|
+
const folder = i.folderInstanceId ? this.folderOf(i) : null;
|
|
1178
|
+
const rel = folder ? this.localRel(i, folder) : null;
|
|
1179
|
+
const moved = folder && rel && rel !== i.relPath;
|
|
1180
|
+
const changed = local && local.hash !== i.baseHash;
|
|
1181
|
+
if (!changed && !moved) continue;
|
|
1182
|
+
const { fromGit, commit } = changed ? changeFromGit(root, i.path) : { fromGit: true, commit: null };
|
|
1183
|
+
if (fromGit) publish.push({ inst: i, local: changed ? local : null, commit, relPath: moved ? rel : void 0 });
|
|
905
1184
|
}
|
|
906
1185
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1186
|
+
this.db.setMeta("gitHeads", heads);
|
|
1187
|
+
if (publish.length) await this.publishGitChanges(publish);
|
|
1188
|
+
}
|
|
1189
|
+
/** Renames and new files git brought in between two commits. */
|
|
1190
|
+
async followGitHistory(root, from, to) {
|
|
1191
|
+
for (const c of changesBetween(root, from, to)) {
|
|
1192
|
+
if (c.status === "R") {
|
|
1193
|
+
const inst = this.gitInstances().find((i) => i.kind === "file" && samePath(i.path, c.from));
|
|
1194
|
+
if (inst && !statOrNull(c.from) && statOrNull(c.to)?.isFile()) {
|
|
1195
|
+
this.db.update(inst.instanceId, { path: canonicalPath(c.to), ...this.identity(c.to), detached: false, missingSince: null, size: null, mtimeMs: null });
|
|
1196
|
+
if (inst.gitMode === "keep") {
|
|
1197
|
+
setKeep(root, c.from, false);
|
|
1198
|
+
setKeep(root, c.to, true);
|
|
1199
|
+
}
|
|
1200
|
+
this.log(`${c.from} -> ${c.to}: followed a rename made in git`);
|
|
1201
|
+
}
|
|
1202
|
+
} else if (c.status === "A" && !this.instanceAt(c.to) && statOrNull(c.to)?.isFile()) {
|
|
1203
|
+
const folder = this.gitInstances().find((i) => i.kind === "folder" && i.gitMode === "follow" && isUnder(c.to, i.path) && !samePath(c.to, i.path));
|
|
1204
|
+
if (!folder) continue;
|
|
1205
|
+
const name = path4.basename(c.to);
|
|
1206
|
+
if (folder.role === "owner") {
|
|
1207
|
+
try {
|
|
1208
|
+
const r = await this.add({ path: c.to, gitCommit: to });
|
|
1209
|
+
this.log(`${c.to}: added to ${folder.path} (new in git)`);
|
|
1210
|
+
this.notify({ kind: "git-published", level: "info", short: name, title: "Published git's changes", body: `${r.relPath} is new in git and was added to ${path4.basename(folder.path)} for everyone.` });
|
|
1211
|
+
} catch (e) {
|
|
1212
|
+
this.log(`${c.to}: could not add (${e.message})`);
|
|
1213
|
+
}
|
|
1214
|
+
} else {
|
|
1215
|
+
this.notify({ kind: "git-new-file", level: "info", short: name, title: "New file in a managed folder", body: `git added ${name} inside ${path4.basename(folder.path)}. Only the owner can add files to it, so it is not shared through Tether.` });
|
|
1216
|
+
}
|
|
914
1217
|
}
|
|
915
1218
|
}
|
|
916
|
-
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Publishes git's version of follow-mode files. If Tether already has exactly that content it is
|
|
1222
|
+
* adopted; if Tether moved on to something else, the file stays in editing mode for `tether done`.
|
|
1223
|
+
*/
|
|
1224
|
+
async publishGitChanges(list) {
|
|
917
1225
|
let results;
|
|
918
1226
|
try {
|
|
919
|
-
({ results } = await this.api.call("POST", "/sync/check", { items:
|
|
1227
|
+
({ results } = await this.api.call("POST", "/sync/check", { items: list.map(({ inst }) => ({ artifactId: inst.artifactId, version: inst.baseVersion })) }));
|
|
920
1228
|
} catch (e) {
|
|
921
1229
|
if (!(e instanceof OfflineError)) throw e;
|
|
922
|
-
for (const { inst } of
|
|
923
|
-
return
|
|
1230
|
+
for (const { inst } of list) this.db.update(inst.instanceId, { mode: "editing", lastError: "git changed this file while offline; run `tether done` to publish it" });
|
|
1231
|
+
return;
|
|
924
1232
|
}
|
|
925
|
-
|
|
926
|
-
for (const [k, { inst, local }] of changed.entries()) {
|
|
1233
|
+
for (const [k, { inst, local, commit, relPath }] of list.entries()) {
|
|
927
1234
|
const r = results[k];
|
|
928
1235
|
const name = path4.basename(inst.path);
|
|
1236
|
+
const hash = local?.hash ?? inst.baseHash;
|
|
929
1237
|
if (r.status === "gone" || r.state?.deleted || r.folderDeleted) {
|
|
930
1238
|
this.orphan(inst, r.reason ?? "deleted");
|
|
931
1239
|
continue;
|
|
932
1240
|
}
|
|
933
1241
|
if (r.role === "viewer") {
|
|
934
1242
|
this.db.update(inst.instanceId, { gitMode: "keep", role: "viewer" });
|
|
1243
|
+
setKeep(inst.gitRoot, inst.path, true);
|
|
935
1244
|
continue;
|
|
936
1245
|
}
|
|
937
1246
|
const conflict = () => {
|
|
@@ -939,18 +1248,32 @@ var Engine = class {
|
|
|
939
1248
|
this.log(`${inst.path}: git changed it but Tether also has a newer version; left in editing mode`);
|
|
940
1249
|
this.notify({ kind: "conflict", level: "warn", title: "Merge conflict (git and Tether)", body: `${name}: git changed it, but someone also pushed a newer version to Tether. Git's version is kept and the file is in editing mode; run "tether done" to choose.`, short: name, artifactId: inst.artifactId });
|
|
941
1250
|
};
|
|
1251
|
+
const adopt = (state) => {
|
|
1252
|
+
this.db.update(inst.instanceId, { baseVersion: state.version, remoteVersion: state.version, baseHash: state.blob, relPath: state.relPath ?? inst.relPath, gitWaiting: false, lastError: null, ...local ? { size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local) } : {} });
|
|
1253
|
+
this.log(`${inst.path}: git's version is already on Tether (v${state.version})`);
|
|
1254
|
+
};
|
|
942
1255
|
if (r.status === "changed") {
|
|
943
|
-
|
|
1256
|
+
if (r.state.blob === hash && (relPath === void 0 || r.state.relPath === relPath)) adopt(r.state);
|
|
1257
|
+
else conflict();
|
|
944
1258
|
continue;
|
|
945
1259
|
}
|
|
946
1260
|
try {
|
|
947
|
-
|
|
948
|
-
this.
|
|
949
|
-
|
|
950
|
-
this.
|
|
951
|
-
this.
|
|
1261
|
+
const change = { gitCommit: commit ?? null };
|
|
1262
|
+
if (local) Object.assign(change, await this.uploadContent(local, inst.artifactId));
|
|
1263
|
+
if (relPath !== void 0) change.relPath = relPath;
|
|
1264
|
+
const c = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: inst.baseVersion, change });
|
|
1265
|
+
this.db.update(inst.instanceId, {
|
|
1266
|
+
baseVersion: c.version,
|
|
1267
|
+
remoteVersion: c.version,
|
|
1268
|
+
baseHash: hash,
|
|
1269
|
+
relPath: relPath ?? inst.relPath,
|
|
1270
|
+
gitWaiting: false,
|
|
1271
|
+
lastError: null,
|
|
1272
|
+
...local ? { size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local) } : {}
|
|
1273
|
+
});
|
|
1274
|
+
const sent = local ? describeTransfer(this.api.transfers.get(local.hash), "sent") : "";
|
|
1275
|
+
this.log(`${inst.path}: published git's version as v${c.version}${sent ? ` (${sent})` : ""}`);
|
|
952
1276
|
this.notify({ kind: "git-published", level: "info", title: "Published git's changes", body: `${name} is now v${c.version} for everyone who has it.`, short: name, artifactId: inst.artifactId });
|
|
953
|
-
published++;
|
|
954
1277
|
} catch (e) {
|
|
955
1278
|
if (e instanceof ApiError && e.status === 409) conflict();
|
|
956
1279
|
else if (e instanceof ApiError) {
|
|
@@ -959,7 +1282,11 @@ var Engine = class {
|
|
|
959
1282
|
} else throw e;
|
|
960
1283
|
}
|
|
961
1284
|
}
|
|
962
|
-
|
|
1285
|
+
}
|
|
1286
|
+
/** Hook calls from Tether 0.2.0 installs, and explicit syncs. */
|
|
1287
|
+
async gitReconcile(root) {
|
|
1288
|
+
await this.locateAll();
|
|
1289
|
+
await this.gitPass({ only: root });
|
|
963
1290
|
}
|
|
964
1291
|
// ---------- realtime editing ----------
|
|
965
1292
|
/** Files this client is editing live (every save is pushed). */
|
|
@@ -1008,7 +1335,7 @@ var Engine = class {
|
|
|
1008
1335
|
const local = readLocal(cur.path, cur.eolMode);
|
|
1009
1336
|
if (r.status === "changed") {
|
|
1010
1337
|
if (!local || local.hash === cur.baseHash) {
|
|
1011
|
-
await this.ensureBlobs([{ artifactId: cur.artifactId, version: r.state.version, sha: r.state.blob }]);
|
|
1338
|
+
await this.ensureBlobs([{ artifactId: cur.artifactId, version: r.state.version, sha: r.state.blob, base: cur.baseHash }]);
|
|
1012
1339
|
this.applyIncoming(cur, r.state);
|
|
1013
1340
|
} else {
|
|
1014
1341
|
if (!cur.lastError?.startsWith("conflict")) {
|
|
@@ -1020,12 +1347,11 @@ var Engine = class {
|
|
|
1020
1347
|
continue;
|
|
1021
1348
|
}
|
|
1022
1349
|
if (!local || local.hash === cur.baseHash) continue;
|
|
1023
|
-
await this.
|
|
1024
|
-
this.cache.put(local.hash, local.canonical);
|
|
1350
|
+
const content = await this.uploadContent(local, cur.artifactId);
|
|
1025
1351
|
try {
|
|
1026
|
-
const c = await this.api.call("POST", `/artifacts/${cur.artifactId}/commit`, { baseVersion: cur.baseVersion, change:
|
|
1352
|
+
const c = await this.api.call("POST", `/artifacts/${cur.artifactId}/commit`, { baseVersion: cur.baseVersion, change: content });
|
|
1027
1353
|
this.db.update(cur.instanceId, { baseVersion: c.version, remoteVersion: c.version, baseHash: local.hash, size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local), lastError: null });
|
|
1028
|
-
this.log(`${cur.path}: pushed v${c.version} (realtime)`);
|
|
1354
|
+
this.log(`${cur.path}: pushed v${c.version} (realtime${this.sentNote(local.hash)})`);
|
|
1029
1355
|
} catch (e) {
|
|
1030
1356
|
if (!(e instanceof ApiError && e.status === 409)) throw e;
|
|
1031
1357
|
this.db.update(cur.instanceId, { lastError: "conflict: the server changed; run `tether done` to resolve" });
|
|
@@ -1098,7 +1424,7 @@ var Engine = class {
|
|
|
1098
1424
|
this.log(`${inst.path}: folder deleted locally; no longer managed here`);
|
|
1099
1425
|
return;
|
|
1100
1426
|
}
|
|
1101
|
-
if (inst.gitMode
|
|
1427
|
+
if (inst.gitMode && inst.mode === "normal") {
|
|
1102
1428
|
this.db.update(inst.instanceId, { detached: true, missingSince: null });
|
|
1103
1429
|
this.log(`${inst.path}: removed (by git?); waiting for git to bring it back`);
|
|
1104
1430
|
} else if (inst.mode === "editing") {
|
|
@@ -1114,11 +1440,36 @@ var Engine = class {
|
|
|
1114
1440
|
}
|
|
1115
1441
|
orphan(inst, reason) {
|
|
1116
1442
|
const targets = inst.kind === "folder" ? [inst, ...this.db.childrenOf(inst.instanceId)] : [inst];
|
|
1117
|
-
for (const t of targets)
|
|
1443
|
+
for (const t of targets) {
|
|
1444
|
+
this.releaseGit(t);
|
|
1445
|
+
this.db.update(t.instanceId, { mode: "orphaned", orphanReason: reason });
|
|
1446
|
+
}
|
|
1118
1447
|
const what = reason === "revoked" ? "access revoked" : reason === "deleted" ? "deleted on the server" : "removed from the server";
|
|
1119
1448
|
this.log(`${inst.path}: ${what}; file kept, no longer tracked`);
|
|
1120
1449
|
this.notify({ kind: "orphaned", level: "warn", title: "No longer synced", body: `${path4.basename(inst.path)}: ${what}. Your copy stays on disk but Tether no longer keeps it up to date.`, short: path4.basename(inst.path) });
|
|
1121
1450
|
}
|
|
1451
|
+
/** What a check asks about; a change cursor is only valid for the same question. */
|
|
1452
|
+
checkKey(insts) {
|
|
1453
|
+
return insts.map((i) => `${i.artifactId}@${i.baseVersion}:${i.mode}`).join(",");
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* The change cursor from the last full check, if it can be reused: same instances, nothing
|
|
1457
|
+
* pending, no live sessions, and a full check at least every FULL_CHECK_MS as a safety net.
|
|
1458
|
+
*/
|
|
1459
|
+
reusableCursor(insts) {
|
|
1460
|
+
const f = this.feed;
|
|
1461
|
+
if (!f || this.realtimeActive || Date.now() - f.at > FULL_CHECK_MS) return void 0;
|
|
1462
|
+
return f.key === this.checkKey(insts) ? f.cursor : void 0;
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Keeps the server's cursor only when nothing is pending here: an incoming version held back
|
|
1466
|
+
* (editing, unmanaged, waiting for git) must be offered again by a full check once it can apply.
|
|
1467
|
+
*/
|
|
1468
|
+
rememberCursor(cursor) {
|
|
1469
|
+
const insts = this.db.all().filter(ACTIVE);
|
|
1470
|
+
const settled = insts.every((i) => i.remoteVersion == null || i.remoteVersion === i.baseVersion);
|
|
1471
|
+
this.feed = cursor !== void 0 && settled ? { cursor, key: this.checkKey(insts), at: Date.now() } : null;
|
|
1472
|
+
}
|
|
1122
1473
|
/** One batched call for all instances (DESIGN §6.1 step 2). */
|
|
1123
1474
|
async checkRemote() {
|
|
1124
1475
|
const insts = this.db.all().filter(ACTIVE);
|
|
@@ -1126,10 +1477,15 @@ var Engine = class {
|
|
|
1126
1477
|
this.offline = null;
|
|
1127
1478
|
return;
|
|
1128
1479
|
}
|
|
1129
|
-
let results;
|
|
1480
|
+
let results, cursor;
|
|
1130
1481
|
try {
|
|
1131
|
-
|
|
1482
|
+
const rootOf = (i) => i.folderInstanceId ? this.db.get(i.folderInstanceId)?.artifactId : void 0;
|
|
1483
|
+
const items = insts.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion, root: rootOf(i) }));
|
|
1484
|
+
const body = { items, cursor: this.reusableCursor(insts) };
|
|
1485
|
+
const res = await this.api.call("POST", "/sync/check", body);
|
|
1132
1486
|
this.offline = null;
|
|
1487
|
+
if (res.unchanged) return;
|
|
1488
|
+
({ results, cursor } = res);
|
|
1133
1489
|
} catch (e) {
|
|
1134
1490
|
if (e instanceof OfflineError || e instanceof ApiError && e.status === 401) {
|
|
1135
1491
|
this.offline = e.message;
|
|
@@ -1142,6 +1498,7 @@ var Engine = class {
|
|
|
1142
1498
|
const folders = [];
|
|
1143
1499
|
insts.forEach((inst, idx) => {
|
|
1144
1500
|
const r = results[idx];
|
|
1501
|
+
if (!r) return;
|
|
1145
1502
|
const cur = this.db.get(inst.instanceId);
|
|
1146
1503
|
if (!cur || cur.mode === "orphaned") return;
|
|
1147
1504
|
if (r.status === "gone") return this.orphan(cur, r.reason);
|
|
@@ -1169,20 +1526,36 @@ var Engine = class {
|
|
|
1169
1526
|
else if (!now.detached) incoming.push({ inst: now, state: r.state });
|
|
1170
1527
|
});
|
|
1171
1528
|
const newChildren = [];
|
|
1529
|
+
const waiting = /* @__PURE__ */ new Set();
|
|
1172
1530
|
for (const f of folders) {
|
|
1173
1531
|
const have = new Set(this.db.childrenOf(f.inst.instanceId).map((c) => c.artifactId));
|
|
1174
|
-
for (const c of f.children)
|
|
1532
|
+
for (const c of f.children) {
|
|
1533
|
+
if (c.deleted || have.has(c.artifactId)) continue;
|
|
1534
|
+
if (f.inst.gitMode === "follow" && c.gitCommit && !isAncestor(f.inst.gitRoot, c.gitCommit)) {
|
|
1535
|
+
waiting.add(f.inst.instanceId);
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
newChildren.push({ folder: f.inst, child: c });
|
|
1539
|
+
}
|
|
1175
1540
|
}
|
|
1176
1541
|
try {
|
|
1177
1542
|
await this.ensureBlobs([
|
|
1178
|
-
...incoming.map(({ state }) => ({ artifactId: state.artifactId, version: state.version, sha: state.blob })),
|
|
1543
|
+
...incoming.map(({ inst, state }) => ({ artifactId: state.artifactId, version: state.version, sha: state.blob, base: inst.baseHash })),
|
|
1179
1544
|
...newChildren.map(({ child }) => ({ artifactId: child.artifactId, version: child.version, sha: child.blob }))
|
|
1180
1545
|
]);
|
|
1181
1546
|
} catch (e) {
|
|
1182
1547
|
if (e instanceof OfflineError) return this.offline = e.message;
|
|
1183
1548
|
throw e;
|
|
1184
1549
|
}
|
|
1185
|
-
for (const { inst, state } of incoming)
|
|
1550
|
+
for (const { inst, state } of incoming) {
|
|
1551
|
+
if (this.isGitBusy(inst)) continue;
|
|
1552
|
+
if (inst.gitMode === "follow" && state.gitCommit && !isAncestor(inst.gitRoot, state.gitCommit)) {
|
|
1553
|
+
if (!inst.gitWaiting) this.log(`${inst.path}: v${state.version} came from git commit ${state.gitCommit.slice(0, 8)}; it will arrive with your next git pull`);
|
|
1554
|
+
this.db.update(inst.instanceId, { gitWaiting: true });
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
this.applyIncoming(inst, state);
|
|
1558
|
+
}
|
|
1186
1559
|
for (const { folder, child } of newChildren) {
|
|
1187
1560
|
this.db.insert({
|
|
1188
1561
|
instanceId: newInstanceId(),
|
|
@@ -1204,10 +1577,13 @@ var Engine = class {
|
|
|
1204
1577
|
});
|
|
1205
1578
|
this.log(`${folder.path}: new file ${child.relPath}`);
|
|
1206
1579
|
}
|
|
1207
|
-
for (const f of folders) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
|
|
1580
|
+
for (const f of folders) if (!waiting.has(f.inst.instanceId)) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
|
|
1581
|
+
if (results.some((r) => r && r.status !== "ok")) this.activityAt = Date.now();
|
|
1582
|
+
this.rememberCursor(cursor);
|
|
1208
1583
|
}
|
|
1209
1584
|
/** Normal mode: write an incoming version (content and/or layout). */
|
|
1210
1585
|
applyIncoming(inst, state) {
|
|
1586
|
+
if (!statOrNull(inst.path)) return;
|
|
1211
1587
|
try {
|
|
1212
1588
|
let p = inst.path;
|
|
1213
1589
|
if (inst.folderInstanceId && state.relPath !== inst.relPath) {
|
|
@@ -1219,11 +1595,18 @@ var Engine = class {
|
|
|
1219
1595
|
p = to;
|
|
1220
1596
|
this.log(`${inst.path} -> ${to}: moved by the server`);
|
|
1221
1597
|
}
|
|
1222
|
-
const patch = { path: p, relPath: state.relPath, baseVersion: state.version, baseHash: state.blob, eolMode: state.eol, exec: state.exec, lastError: null };
|
|
1598
|
+
const patch = { path: p, relPath: state.relPath, baseVersion: state.version, baseHash: state.blob, eolMode: state.eol, exec: state.exec, lastError: null, gitWaiting: false };
|
|
1223
1599
|
const localEdit = !this.matchesBase(inst, p) && readLocal(p, inst.eolMode)?.hash !== state.blob;
|
|
1224
1600
|
if (state.blob !== inst.baseHash || localEdit) {
|
|
1225
1601
|
Object.assign(patch, this.writeInstance(inst, p, state.blob, state.eol, state.exec));
|
|
1226
|
-
this.
|
|
1602
|
+
const got = describeTransfer(this.api.transfers.get(state.blob), "received");
|
|
1603
|
+
this.api.transfers.delete(state.blob);
|
|
1604
|
+
this.log(`${p}: updated to version ${state.version}${localEdit ? " (local change replaced)" : ""}${got ? ` (${got})` : ""}`);
|
|
1605
|
+
if (inst.gitMode === "follow" && inst.gitRoot && !state.gitCommit && blobAt(inst.gitRoot, "HEAD", p) !== fileBlob(inst.gitRoot, p)) {
|
|
1606
|
+
setKeep(inst.gitRoot, p, true);
|
|
1607
|
+
patch.gitOverlay = true;
|
|
1608
|
+
this.notify({ kind: "git-overlay", level: "info", short: path4.basename(p), artifactId: inst.artifactId, title: "Changed through Tether", body: `${path4.basename(p)} now has Tether's v${state.version}, which is not in git. Git does not see this change; the next change to the file in git replaces it.` });
|
|
1609
|
+
}
|
|
1227
1610
|
if (localEdit) {
|
|
1228
1611
|
const name = path4.basename(p);
|
|
1229
1612
|
this.notify({
|
|
@@ -1263,6 +1646,22 @@ var Engine = class {
|
|
|
1263
1646
|
const local = readLocal(p, inst.eolMode);
|
|
1264
1647
|
return local?.hash === inst.baseHash;
|
|
1265
1648
|
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Follow mode, just before a revert: is this content git's own (a git operation that started
|
|
1651
|
+
* after this pass checked, e.g. a cherry-pick between writing the file and moving HEAD)? Asks git
|
|
1652
|
+
* afresh, since the pass's busy check may be stale by now.
|
|
1653
|
+
*/
|
|
1654
|
+
gitWroteIt(inst) {
|
|
1655
|
+
if (inst.gitMode !== "follow" || !inst.gitRoot) return false;
|
|
1656
|
+
if (gitBusy(inst.gitDir)) return true;
|
|
1657
|
+
const blob = fileBlob(inst.gitRoot, inst.path);
|
|
1658
|
+
if (!blob) return false;
|
|
1659
|
+
if (blob === indexBlob(inst.gitRoot, inst.path) || blob === blobAt(inst.gitRoot, "HEAD", inst.path)) {
|
|
1660
|
+
this.gitPassDue = true;
|
|
1661
|
+
return true;
|
|
1662
|
+
}
|
|
1663
|
+
return false;
|
|
1664
|
+
}
|
|
1266
1665
|
/** Normal mode: restore missing files, put moved files back, revert local edits. */
|
|
1267
1666
|
async enforceLocal() {
|
|
1268
1667
|
const restores = this.db.all().filter((i) => ACTIVE(i) && i.needsRestore && i.mode === "normal");
|
|
@@ -1293,6 +1692,7 @@ var Engine = class {
|
|
|
1293
1692
|
continue;
|
|
1294
1693
|
}
|
|
1295
1694
|
if (!this.cache.has(cur.baseHash)) continue;
|
|
1695
|
+
if (this.gitWroteIt(cur)) continue;
|
|
1296
1696
|
this.db.update(cur.instanceId, { ...this.writeInstance(cur, cur.path, cur.baseHash), lastError: null });
|
|
1297
1697
|
this.log(`${cur.path}: local change reverted (use \`tether edit\` to change it)`);
|
|
1298
1698
|
const byGit = cur.gitMode === "keep";
|
|
@@ -1383,7 +1783,7 @@ var Engine = class {
|
|
|
1383
1783
|
if (st.isFile()) {
|
|
1384
1784
|
const f = this.prepareFile(abs, name, rules, eol);
|
|
1385
1785
|
await this.api.uploadBlobs([f]);
|
|
1386
|
-
const r2 = await this.api.call("POST", "/artifacts", { repoId: repo.repoId, kind: "file", name, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec });
|
|
1786
|
+
const r2 = await this.api.call("POST", "/artifacts", { repoId: repo.repoId, kind: "file", name, blob: f.sha256, size: f.size, chunks: this.api.manifestFor(f.sha256), eol: f.eol, exec: f.exec });
|
|
1387
1787
|
this.cache.put(f.sha256, f.bytes);
|
|
1388
1788
|
const local = readLocal(abs, f.eol);
|
|
1389
1789
|
this.db.insert({
|
|
@@ -1422,7 +1822,7 @@ var Engine = class {
|
|
|
1422
1822
|
repoId: repo.repoId,
|
|
1423
1823
|
kind: "folder",
|
|
1424
1824
|
name,
|
|
1425
|
-
files: prepared.map((f) => ({ relPath: f.rel, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec }))
|
|
1825
|
+
files: prepared.map((f) => ({ relPath: f.rel, blob: f.sha256, size: f.size, chunks: this.api.manifestFor(f.sha256), eol: f.eol, exec: f.exec }))
|
|
1426
1826
|
});
|
|
1427
1827
|
const folderInst = this.db.insert({
|
|
1428
1828
|
instanceId: newInstanceId(),
|
|
@@ -1469,7 +1869,7 @@ var Engine = class {
|
|
|
1469
1869
|
return { artifactId: r.artifact.artifactId, shareId: r.artifact.shareId, kind: "folder", path: abs, files: files2.length, skippedSymlinks: symlinks };
|
|
1470
1870
|
}
|
|
1471
1871
|
/** `tether add <path>`: owner adds a new file inside a managed folder. */
|
|
1472
|
-
async add({ path: target }) {
|
|
1872
|
+
async add({ path: target, gitCommit }) {
|
|
1473
1873
|
const abs = canonicalPath(path4.resolve(target));
|
|
1474
1874
|
if (!statOrNull(abs)?.isFile()) throw new UserError(`${abs} is not a file`);
|
|
1475
1875
|
if (this.instanceAt(abs)) throw new UserError(`${abs} is already managed`);
|
|
@@ -1482,7 +1882,7 @@ var Engine = class {
|
|
|
1482
1882
|
const repo = (await this.api.call("GET", "/repos")).find((r) => r.repoId === folder.repoId);
|
|
1483
1883
|
const f = this.prepareFile(abs, rel, repo?.eolDefaults ?? {});
|
|
1484
1884
|
await this.api.uploadBlobs([f]);
|
|
1485
|
-
const child = await this.api.call("POST", `/artifacts/${folder.artifactId}/children`, { relPath: rel, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec });
|
|
1885
|
+
const child = await this.api.call("POST", `/artifacts/${folder.artifactId}/children`, { relPath: rel, blob: f.sha256, size: f.size, chunks: this.api.manifestFor(f.sha256), eol: f.eol, exec: f.exec, gitCommit });
|
|
1486
1886
|
this.cache.put(f.sha256, f.bytes);
|
|
1487
1887
|
const local = readLocal(abs, f.eol);
|
|
1488
1888
|
this.db.insert({
|
|
@@ -1533,8 +1933,21 @@ var Engine = class {
|
|
|
1533
1933
|
const base = { repoId: a.repoId, role: r.role, canDelete: r.canDelete };
|
|
1534
1934
|
if (a.kind === "file") {
|
|
1535
1935
|
await this.ensureBlobs([{ artifactId: a.artifactId, version: a.version, sha: a.blob }]);
|
|
1536
|
-
|
|
1537
|
-
|
|
1936
|
+
let existing = readLocal(target, a.eol);
|
|
1937
|
+
let mode = "normal";
|
|
1938
|
+
let lastError = null;
|
|
1939
|
+
let note;
|
|
1940
|
+
if (existing && existing.hash !== a.blob) {
|
|
1941
|
+
const clash = this.gitClash(target, gitMode, a.relPath ?? a.name);
|
|
1942
|
+
if (clash === "replace") {
|
|
1943
|
+
existing = null;
|
|
1944
|
+
note = "replaced git's version with Tether's (git still has it)";
|
|
1945
|
+
} else {
|
|
1946
|
+
mode = "editing";
|
|
1947
|
+
lastError = "git's version differs from Tether's; run `tether done` to publish it or accept Tether's";
|
|
1948
|
+
note = "kept git's version; run `tether done` to publish it or accept Tether's";
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1538
1951
|
const st2 = existing ? { size: existing.size, mtimeMs: existing.mtimeMs, ...styleOf(existing) } : writeLocal(target, this.blobFor(a.blob), a.eol, { exec: a.exec });
|
|
1539
1952
|
this.db.insert({
|
|
1540
1953
|
...base,
|
|
@@ -1544,7 +1957,8 @@ var Engine = class {
|
|
|
1544
1957
|
name: a.name,
|
|
1545
1958
|
path: canonicalPath(target),
|
|
1546
1959
|
...this.identity(target),
|
|
1547
|
-
mode
|
|
1960
|
+
mode,
|
|
1961
|
+
lastError,
|
|
1548
1962
|
baseVersion: a.version,
|
|
1549
1963
|
baseHash: a.blob,
|
|
1550
1964
|
...st2,
|
|
@@ -1552,18 +1966,17 @@ var Engine = class {
|
|
|
1552
1966
|
exec: a.exec,
|
|
1553
1967
|
remoteVersion: a.version
|
|
1554
1968
|
});
|
|
1555
|
-
return { kind: "file", path: canonicalPath(target), role: r.role, version: a.version };
|
|
1969
|
+
return { kind: "file", path: canonicalPath(target), role: r.role, version: a.version, note };
|
|
1556
1970
|
}
|
|
1557
1971
|
const st = statOrNull(target);
|
|
1558
1972
|
if (st && !st.isDirectory()) throw new UserError(`${target} exists and is not a folder`);
|
|
1559
1973
|
await this.ensureBlobs(r.children.map((c) => ({ artifactId: c.artifactId, version: c.version, sha: c.blob })));
|
|
1560
|
-
const clashes =
|
|
1974
|
+
const clashes = /* @__PURE__ */ new Map();
|
|
1561
1975
|
for (const c of r.children) {
|
|
1562
1976
|
const p = path4.join(target, ...c.relPath.split("/"));
|
|
1563
1977
|
const ex = readLocal(p, c.eol);
|
|
1564
|
-
if (ex && ex.hash !== c.blob) clashes.
|
|
1978
|
+
if (ex && ex.hash !== c.blob) clashes.set(c.relPath, this.gitClash(p, gitMode, c.relPath));
|
|
1565
1979
|
}
|
|
1566
|
-
if (clashes.length) throw new UserError(`${target} already has different files: ${clashes.join(", ")}; choose another destination`);
|
|
1567
1980
|
fs6.mkdirSync(target, { recursive: true });
|
|
1568
1981
|
const folderInst = this.db.insert({
|
|
1569
1982
|
...base,
|
|
@@ -1579,7 +1992,8 @@ var Engine = class {
|
|
|
1579
1992
|
});
|
|
1580
1993
|
for (const c of r.children) {
|
|
1581
1994
|
const p = path4.join(folderInst.path, ...c.relPath.split("/"));
|
|
1582
|
-
const
|
|
1995
|
+
const clash = clashes.get(c.relPath);
|
|
1996
|
+
const ex = clash === "replace" ? null : readLocal(p, c.eol);
|
|
1583
1997
|
const cst = ex ? { size: ex.size, mtimeMs: ex.mtimeMs, ...styleOf(ex) } : writeLocal(p, this.blobFor(c.blob), c.eol, { exec: c.exec });
|
|
1584
1998
|
this.db.insert({
|
|
1585
1999
|
...base,
|
|
@@ -1591,10 +2005,11 @@ var Engine = class {
|
|
|
1591
2005
|
relPath: c.relPath,
|
|
1592
2006
|
path: canonicalPath(p),
|
|
1593
2007
|
...this.identity(p),
|
|
1594
|
-
mode: "normal",
|
|
1595
2008
|
baseVersion: c.version,
|
|
1596
2009
|
baseHash: c.blob,
|
|
1597
2010
|
...cst,
|
|
2011
|
+
mode: clash === "keep-git" ? "editing" : "normal",
|
|
2012
|
+
lastError: clash === "keep-git" ? "git's version differs from Tether's; run `tether done`" : null,
|
|
1598
2013
|
eolMode: c.eol,
|
|
1599
2014
|
exec: c.exec,
|
|
1600
2015
|
remoteVersion: c.version
|
|
@@ -1602,6 +2017,21 @@ var Engine = class {
|
|
|
1602
2017
|
}
|
|
1603
2018
|
return { kind: "folder", path: folderInst.path, role: r.role, files: r.children.length, version: a.version };
|
|
1604
2019
|
}
|
|
2020
|
+
/**
|
|
2021
|
+
* A pulled file already exists with different content. Only allowed when git holds that
|
|
2022
|
+
* content safely (tracked, no uncommitted changes): keep mode replaces it with Tether's version,
|
|
2023
|
+
* follow mode keeps git's version in editing mode. Otherwise the pull is refused.
|
|
2024
|
+
*/
|
|
2025
|
+
gitClash(p, gitMode, label) {
|
|
2026
|
+
const repo = gitMode && findRepo(p);
|
|
2027
|
+
if (repo && gitBinary() && isTracked(repo.root, p)) {
|
|
2028
|
+
const blob = fileBlob(repo.root, p);
|
|
2029
|
+
const clean = blob && (blob === blobAt(repo.root, "HEAD", p) || blob === indexBlob(repo.root, p));
|
|
2030
|
+
if (clean) return gitMode === "keep" ? "replace" : "keep-git";
|
|
2031
|
+
throw new UserError(`${label} differs from Tether's version and has uncommitted git changes; commit or stash them first`);
|
|
2032
|
+
}
|
|
2033
|
+
throw new UserError(`${p} already exists with different content; choose another destination`);
|
|
2034
|
+
}
|
|
1605
2035
|
/** Instances addressed by a path: a file, a managed folder (with its files), or any directory. */
|
|
1606
2036
|
targets(p) {
|
|
1607
2037
|
const abs = canonicalPath(path4.resolve(p));
|
|
@@ -1669,6 +2099,7 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
|
|
|
1669
2099
|
}
|
|
1670
2100
|
async unmanage({ path: p }) {
|
|
1671
2101
|
for (const i of this.targets(p).filter((x) => x.realtime)) await this.releaseRealtime(i);
|
|
2102
|
+
for (const i of this.targets(p)) this.releaseGit(i);
|
|
1672
2103
|
return { changed: this.setMode(p, "unmanaged", ["normal", "editing"]) };
|
|
1673
2104
|
}
|
|
1674
2105
|
/**
|
|
@@ -1796,16 +2227,16 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
|
|
|
1796
2227
|
case "push": {
|
|
1797
2228
|
const local = readLocal(inst.path, inst.eolMode);
|
|
1798
2229
|
const change = {};
|
|
1799
|
-
if (local && local.hash !== inst.baseHash)
|
|
1800
|
-
await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }]);
|
|
1801
|
-
this.cache.put(local.hash, local.canonical);
|
|
1802
|
-
Object.assign(change, { blob: local.hash, size: local.canonical.length });
|
|
1803
|
-
}
|
|
2230
|
+
if (local && local.hash !== inst.baseHash) Object.assign(change, await this.uploadContent(local, inst.artifactId));
|
|
1804
2231
|
if (folder) {
|
|
1805
2232
|
const rel = this.localRel(inst, folder);
|
|
1806
2233
|
if (rel && rel !== inst.relPath) change.relPath = rel;
|
|
1807
2234
|
}
|
|
1808
2235
|
const baseVersion = item.incoming ? item.serverVersion : inst.baseVersion;
|
|
2236
|
+
if (inst.gitMode === "follow" && inst.gitRoot && change.blob) {
|
|
2237
|
+
const g = changeFromGit(inst.gitRoot, inst.path);
|
|
2238
|
+
if (g.fromGit && g.commit) change.gitCommit = g.commit;
|
|
2239
|
+
}
|
|
1809
2240
|
const r = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion, change });
|
|
1810
2241
|
const state = r.state;
|
|
1811
2242
|
this.db.update(inst.instanceId, {
|
|
@@ -1818,7 +2249,7 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
|
|
|
1818
2249
|
mtimeMs: local?.mtimeMs ?? null,
|
|
1819
2250
|
...local ? styleOf(local) : {}
|
|
1820
2251
|
});
|
|
1821
|
-
return { version: r.version };
|
|
2252
|
+
return { version: r.version, sent: change.blob ? describeTransfer(this.api.transfers.get(change.blob), "sent") : "" };
|
|
1822
2253
|
}
|
|
1823
2254
|
case "delete": {
|
|
1824
2255
|
await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: item.serverVersion, change: { deleted: true } });
|
|
@@ -1844,7 +2275,7 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
|
|
|
1844
2275
|
if (!inst || inst.kind !== "file") throw new UserError("diff needs a managed file");
|
|
1845
2276
|
const { artifact } = await this.api.call("GET", `/artifacts/${inst.artifactId}`);
|
|
1846
2277
|
if (artifact.deleted) throw new UserError("the artifact has been deleted on the server");
|
|
1847
|
-
await this.ensureBlobs([{ artifactId: inst.artifactId, version: artifact.version, sha: artifact.blob }]);
|
|
2278
|
+
await this.ensureBlobs([{ artifactId: inst.artifactId, version: artifact.version, sha: artifact.blob, base: inst.baseHash }]);
|
|
1848
2279
|
const server = this.cache.get(artifact.blob);
|
|
1849
2280
|
const local = inst.detached ? null : readLocal(inst.path, inst.eolMode);
|
|
1850
2281
|
const label = path4.basename(inst.path);
|
|
@@ -1878,6 +2309,8 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
|
|
|
1878
2309
|
if (i.orphanReason) flags.push(i.orphanReason);
|
|
1879
2310
|
if (i.realtime && i.mode === "editing") flags.push("realtime: your saves are pushed live");
|
|
1880
2311
|
if (i.gitMode) flags.push(`git: ${i.gitMode}`);
|
|
2312
|
+
if (i.gitWaiting) flags.push("newer version arrives with your next git pull");
|
|
2313
|
+
if (i.gitOverlay) flags.push("Tether-only version (hidden from git)");
|
|
1881
2314
|
if (i.remoteRealtime && i.mode !== "orphaned") flags.push(`realtime edits by ${JSON.parse(i.remoteRealtime).email}`);
|
|
1882
2315
|
return {
|
|
1883
2316
|
instanceId: i.instanceId,
|
|
@@ -1933,6 +2366,68 @@ import { spawn } from "node:child_process";
|
|
|
1933
2366
|
var AUMID = "Tether.Sync";
|
|
1934
2367
|
var FLUSH_MS = 3e3;
|
|
1935
2368
|
var LEVELS = { info: 0, warn: 1, error: 2 };
|
|
2369
|
+
var SHORTCUT_SCRIPT = String.raw`Add-Type -TypeDefinition @'
|
|
2370
|
+
using System;
|
|
2371
|
+
using System.Text;
|
|
2372
|
+
using System.Runtime.InteropServices;
|
|
2373
|
+
using System.Runtime.InteropServices.ComTypes;
|
|
2374
|
+
|
|
2375
|
+
namespace TetherShortcut {
|
|
2376
|
+
[ComImport, Guid("00021401-0000-0000-C000-000000000046")] class CShellLink {}
|
|
2377
|
+
|
|
2378
|
+
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")]
|
|
2379
|
+
interface IShellLinkW {
|
|
2380
|
+
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder f, int cch, IntPtr fd, uint flags);
|
|
2381
|
+
void GetIDList(out IntPtr pidl);
|
|
2382
|
+
void SetIDList(IntPtr pidl);
|
|
2383
|
+
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
|
|
2384
|
+
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string s);
|
|
2385
|
+
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
|
|
2386
|
+
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string s);
|
|
2387
|
+
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
|
|
2388
|
+
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string s);
|
|
2389
|
+
void GetHotkey(out short k);
|
|
2390
|
+
void SetHotkey(short k);
|
|
2391
|
+
void GetShowCmd(out int c);
|
|
2392
|
+
void SetShowCmd(int c);
|
|
2393
|
+
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch, out int i);
|
|
2394
|
+
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string s, int i);
|
|
2395
|
+
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string s, uint r);
|
|
2396
|
+
void Resolve(IntPtr hwnd, uint flags);
|
|
2397
|
+
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string s);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
[StructLayout(LayoutKind.Sequential, Pack = 4)] public struct PropertyKey { public Guid fmtid; public uint pid; }
|
|
2401
|
+
[StructLayout(LayoutKind.Explicit)] public struct PropVariant { [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr p; }
|
|
2402
|
+
|
|
2403
|
+
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")]
|
|
2404
|
+
interface IPropertyStore {
|
|
2405
|
+
void GetCount(out uint c);
|
|
2406
|
+
void GetAt(uint i, out PropertyKey k);
|
|
2407
|
+
void GetValue(ref PropertyKey k, out PropVariant v);
|
|
2408
|
+
void SetValue(ref PropertyKey k, ref PropVariant v);
|
|
2409
|
+
void Commit();
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
public static class Maker {
|
|
2413
|
+
public static void Create(string lnk, string aumid, string url) {
|
|
2414
|
+
var link = (IShellLinkW)new CShellLink();
|
|
2415
|
+
link.SetPath(Environment.ExpandEnvironmentVariables("%SystemRoot%\\explorer.exe"));
|
|
2416
|
+
link.SetArguments(url);
|
|
2417
|
+
link.SetDescription("Tether");
|
|
2418
|
+
link.SetIconLocation(Environment.ExpandEnvironmentVariables("%SystemRoot%\\System32\\imageres.dll"), 228);
|
|
2419
|
+
var store = (IPropertyStore)link;
|
|
2420
|
+
var key = new PropertyKey { fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), pid = 5 };
|
|
2421
|
+
var v = new PropVariant { vt = 31, p = Marshal.StringToCoTaskMemUni(aumid) };
|
|
2422
|
+
store.SetValue(ref key, ref v);
|
|
2423
|
+
store.Commit();
|
|
2424
|
+
((IPersistFile)link).Save(lnk, true);
|
|
2425
|
+
Marshal.FreeCoTaskMem(v.p);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
'@
|
|
2430
|
+
`;
|
|
1936
2431
|
var Notifier = class {
|
|
1937
2432
|
/** setting: 'all' | 'errors' | 'off' (config key `notifications`). */
|
|
1938
2433
|
constructor({ setting = () => "all", consoleUrl = () => "", log = () => {
|
|
@@ -1992,7 +2487,12 @@ Also: ${events.slice(1, 4).map((e) => e.short ?? e.title).join("; ")}${more > 3
|
|
|
1992
2487
|
const esc = (s) => s.replace(/[<>&"']/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c]);
|
|
1993
2488
|
const launch = url ? ` activationType="protocol" launch="${esc(url)}"` : "";
|
|
1994
2489
|
const xml = `<toast${launch}><visual><binding template="ToastGeneric"><text>${esc(title)}</text><text>${esc(body)}</text></binding></visual></toast>`;
|
|
1995
|
-
const
|
|
2490
|
+
const consoleUrl = (this.consoleUrl() || "https://tether-b10cf.web.app").replace(/'/g, "''");
|
|
2491
|
+
const register = this.registered ? "" : `$k='HKCU:\\Software\\Classes\\AppUserModelId\\${AUMID}'; if (-not (Test-Path $k)) { New-Item -Path $k -Force | Out-Null }; Set-ItemProperty -Path $k -Name DisplayName -Value 'Tether';
|
|
2492
|
+
$lnk = Join-Path $env:APPDATA 'Microsoft\\Windows\\Start Menu\\Programs\\Tether.lnk'
|
|
2493
|
+
if (-not (Test-Path $lnk)) { try {
|
|
2494
|
+
${SHORTCUT_SCRIPT}
|
|
2495
|
+
[TetherShortcut.Maker]::Create($lnk, '${AUMID}', '${consoleUrl}') } catch {} }`;
|
|
1996
2496
|
this.registered = true;
|
|
1997
2497
|
const script = `${register}
|
|
1998
2498
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
@@ -2004,9 +2504,90 @@ $x = New-Object Windows.Data.Xml.Dom.XmlDocument; $x.LoadXml('${xml.replace(/'/g
|
|
|
2004
2504
|
}
|
|
2005
2505
|
};
|
|
2006
2506
|
|
|
2507
|
+
// packages/client/src/feed.js
|
|
2508
|
+
var RETRY_MS = 5 * 60 * 1e3;
|
|
2509
|
+
var FeedListener = class {
|
|
2510
|
+
/** onChange(seq) is called with the feed counter on every snapshot from the server. */
|
|
2511
|
+
constructor({ api, log, onChange }) {
|
|
2512
|
+
this.api = api;
|
|
2513
|
+
this.log = log;
|
|
2514
|
+
this.onChange = onChange;
|
|
2515
|
+
this.live = false;
|
|
2516
|
+
this.unsupported = false;
|
|
2517
|
+
this.session = 0;
|
|
2518
|
+
}
|
|
2519
|
+
async start() {
|
|
2520
|
+
if (this.unsupported || this.unsub || this.starting) return;
|
|
2521
|
+
this.starting = true;
|
|
2522
|
+
const session = ++this.session;
|
|
2523
|
+
try {
|
|
2524
|
+
const t = await this.api.call("POST", "/feed/token");
|
|
2525
|
+
const [{ initializeApp }, A, F] = await Promise.all([import("firebase/app"), import("firebase/auth"), import("firebase/firestore")]);
|
|
2526
|
+
if (session !== this.session) return;
|
|
2527
|
+
this.app = initializeApp(t.firebase, `tether-feed-${session}-${Date.now()}`);
|
|
2528
|
+
const auth = A.getAuth(this.app);
|
|
2529
|
+
const db = F.getFirestore(this.app);
|
|
2530
|
+
if (t.emulators?.auth) A.connectAuthEmulator(auth, `http://${t.emulators.auth}`, { disableWarnings: true });
|
|
2531
|
+
if (t.emulators?.firestore) {
|
|
2532
|
+
const [host, port] = t.emulators.firestore.split(":");
|
|
2533
|
+
F.connectFirestoreEmulator(db, host, Number(port));
|
|
2534
|
+
}
|
|
2535
|
+
await A.signInWithCustomToken(auth, t.customToken);
|
|
2536
|
+
if (session !== this.session) return this.dispose();
|
|
2537
|
+
this.unsub = F.onSnapshot(F.doc(db, "feeds", t.uid), { includeMetadataChanges: true }, (snap) => {
|
|
2538
|
+
const wasLive = this.live;
|
|
2539
|
+
this.live = !snap.metadata.fromCache;
|
|
2540
|
+
if (this.live && !wasLive) this.log("push channel connected");
|
|
2541
|
+
if (this.live) this.onChange(snap.get("seq") ?? 0);
|
|
2542
|
+
}, (e) => this.fail(e, session));
|
|
2543
|
+
} catch (e) {
|
|
2544
|
+
this.fail(e, session);
|
|
2545
|
+
} finally {
|
|
2546
|
+
this.starting = false;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
fail(e, session) {
|
|
2550
|
+
if (session !== this.session) return;
|
|
2551
|
+
if (e instanceof ApiError && e.status === 404) {
|
|
2552
|
+
this.unsupported = true;
|
|
2553
|
+
this.stop();
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
this.log(`push channel unavailable (${e.message}); polling until it reconnects`);
|
|
2557
|
+
this.stop();
|
|
2558
|
+
clearTimeout(this.retry);
|
|
2559
|
+
this.retry = setTimeout(() => this.start(), RETRY_MS);
|
|
2560
|
+
this.retry.unref?.();
|
|
2561
|
+
}
|
|
2562
|
+
stop() {
|
|
2563
|
+
this.session++;
|
|
2564
|
+
clearTimeout(this.retry);
|
|
2565
|
+
this.unsub?.();
|
|
2566
|
+
this.unsub = null;
|
|
2567
|
+
if (this.live) this.log("push channel closed");
|
|
2568
|
+
this.live = false;
|
|
2569
|
+
this.dispose();
|
|
2570
|
+
}
|
|
2571
|
+
dispose() {
|
|
2572
|
+
const app = this.app;
|
|
2573
|
+
this.app = null;
|
|
2574
|
+
if (app) import("firebase/app").then(({ deleteApp }) => deleteApp(app)).catch(() => {
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
/** After a login, logout or server change: start over (or stay off when signed out). */
|
|
2578
|
+
restart() {
|
|
2579
|
+
this.unsupported = false;
|
|
2580
|
+
this.stop();
|
|
2581
|
+
this.start();
|
|
2582
|
+
}
|
|
2583
|
+
};
|
|
2584
|
+
|
|
2007
2585
|
// packages/client/src/daemon.js
|
|
2008
2586
|
import { fileURLToPath } from "node:url";
|
|
2009
2587
|
var REALTIME_TICK_SECONDS = 3;
|
|
2588
|
+
var IDLE_AFTER_MS = 10 * 60 * 1e3;
|
|
2589
|
+
var IDLE_MAX_SECONDS = 180;
|
|
2590
|
+
var PUSH_TICK_SECONDS = 15 * 60;
|
|
2010
2591
|
var UPDATE_CHECK_MS = 24 * 60 * 60 * 1e3;
|
|
2011
2592
|
async function runDaemon() {
|
|
2012
2593
|
const home = tetherHome();
|
|
@@ -2044,23 +2625,54 @@ async function runDaemon() {
|
|
|
2044
2625
|
let realtimeTimer = null;
|
|
2045
2626
|
let stopping = false;
|
|
2046
2627
|
let latest = null;
|
|
2047
|
-
|
|
2628
|
+
let remoteAt = 0;
|
|
2629
|
+
async function runTick({ remote } = {}) {
|
|
2630
|
+
remote ??= !feed.live || engine.realtimeActive || Date.now() - remoteAt >= PUSH_TICK_SECONDS * 1e3;
|
|
2631
|
+
if (remote) remoteAt = Date.now();
|
|
2048
2632
|
try {
|
|
2049
|
-
await engine.locked(() => engine.tick(
|
|
2633
|
+
await engine.locked(() => engine.tick({ remote }));
|
|
2050
2634
|
} catch (e) {
|
|
2051
2635
|
log(`tick failed: ${e.stack || e.message}`);
|
|
2052
2636
|
notifier.notify({ kind: "error", level: "error", key: "tick-failed", cooldownMs: 3600 * 1e3, title: "Tether hit an error", body: `${e.message}. Run "tether log" for details.` });
|
|
2053
2637
|
}
|
|
2638
|
+
if (engine.gitPassDue) {
|
|
2639
|
+
engine.gitPassDue = false;
|
|
2640
|
+
gitPassSoon();
|
|
2641
|
+
}
|
|
2054
2642
|
refreshWatchers();
|
|
2055
2643
|
scheduleGrace();
|
|
2056
2644
|
schedule();
|
|
2057
2645
|
}
|
|
2646
|
+
let activeAt = Date.now();
|
|
2647
|
+
let timerDue = 0;
|
|
2648
|
+
function interval() {
|
|
2649
|
+
const base = Math.max(MIN_TICK_SECONDS, config.get("tickSeconds"));
|
|
2650
|
+
if (engine.realtimeActive) return Math.min(base, REALTIME_TICK_SECONDS);
|
|
2651
|
+
const idle = Date.now() - Math.max(activeAt, engine.activityAt ?? 0);
|
|
2652
|
+
if (idle < IDLE_AFTER_MS) return base;
|
|
2653
|
+
return Math.max(base, Math.min(IDLE_MAX_SECONDS, base * 2 ** Math.floor(idle / IDLE_AFTER_MS)));
|
|
2654
|
+
}
|
|
2058
2655
|
function schedule() {
|
|
2059
2656
|
clearTimeout(timer);
|
|
2060
2657
|
if (stopping) return;
|
|
2061
|
-
const seconds =
|
|
2658
|
+
const seconds = interval();
|
|
2659
|
+
timerDue = Date.now() + seconds * 1e3;
|
|
2062
2660
|
timer = setTimeout(() => runTick(), seconds * 1e3);
|
|
2063
2661
|
}
|
|
2662
|
+
const feed = new FeedListener({
|
|
2663
|
+
api,
|
|
2664
|
+
log,
|
|
2665
|
+
onChange: (seq) => {
|
|
2666
|
+
if (stopping || engine.feed?.cursor === seq) return;
|
|
2667
|
+
clearTimeout(timer);
|
|
2668
|
+
timerDue = Date.now() + 300;
|
|
2669
|
+
timer = setTimeout(() => runTick({ remote: true }), 300);
|
|
2670
|
+
}
|
|
2671
|
+
});
|
|
2672
|
+
function markActive() {
|
|
2673
|
+
activeAt = Date.now();
|
|
2674
|
+
if (timer && timerDue - activeAt > interval() * 1e3) schedule();
|
|
2675
|
+
}
|
|
2064
2676
|
function realtimePass(delay = 1e3) {
|
|
2065
2677
|
if (stopping || !engine.realtimeInstances().length) return;
|
|
2066
2678
|
clearTimeout(realtimeTimer);
|
|
@@ -2101,10 +2713,29 @@ async function runDaemon() {
|
|
|
2101
2713
|
if (engine.graceDue) quickPass(Math.max(50, engine.graceDue - Date.now() + 50));
|
|
2102
2714
|
}
|
|
2103
2715
|
const watchers = /* @__PURE__ */ new Map();
|
|
2716
|
+
let gitTimer = null;
|
|
2717
|
+
function gitPassSoon() {
|
|
2718
|
+
clearTimeout(gitTimer);
|
|
2719
|
+
gitTimer = setTimeout(async () => {
|
|
2720
|
+
gitTimer = null;
|
|
2721
|
+
try {
|
|
2722
|
+
await engine.locked(async () => {
|
|
2723
|
+
engine.busyCache = /* @__PURE__ */ new Map();
|
|
2724
|
+
await engine.locateAll();
|
|
2725
|
+
await engine.gitPass();
|
|
2726
|
+
await engine.enforceLocal();
|
|
2727
|
+
});
|
|
2728
|
+
} catch (e) {
|
|
2729
|
+
log(`git pass failed: ${e.message}`);
|
|
2730
|
+
}
|
|
2731
|
+
}, 600);
|
|
2732
|
+
}
|
|
2104
2733
|
function refreshWatchers() {
|
|
2105
2734
|
const want = /* @__PURE__ */ new Map();
|
|
2106
2735
|
for (const i of db.all()) {
|
|
2107
|
-
if (i.mode === "orphaned"
|
|
2736
|
+
if (i.mode === "orphaned") continue;
|
|
2737
|
+
if (i.gitDir && !want.has(i.gitDir)) want.set(i.gitDir, "git");
|
|
2738
|
+
if (i.detached) continue;
|
|
2108
2739
|
if (i.kind === "folder") want.set(i.path, true);
|
|
2109
2740
|
else if (!i.folderInstanceId) want.set(path5.dirname(i.path), false);
|
|
2110
2741
|
}
|
|
@@ -2114,10 +2745,11 @@ async function runDaemon() {
|
|
|
2114
2745
|
watchers.delete(dir);
|
|
2115
2746
|
}
|
|
2116
2747
|
}
|
|
2117
|
-
for (const [dir,
|
|
2748
|
+
for (const [dir, kind] of want) {
|
|
2118
2749
|
if (watchers.has(dir)) continue;
|
|
2119
2750
|
try {
|
|
2120
|
-
const w = fs8.watch(dir, { recursive, persistent: false }, () => {
|
|
2751
|
+
const w = kind === "git" ? fs8.watch(dir, { persistent: false }, () => gitPassSoon()) : fs8.watch(dir, { recursive: kind, persistent: false }, () => {
|
|
2752
|
+
markActive();
|
|
2121
2753
|
quickPass(250);
|
|
2122
2754
|
realtimePass();
|
|
2123
2755
|
});
|
|
@@ -2162,7 +2794,7 @@ async function runDaemon() {
|
|
|
2162
2794
|
return { state, url: `${config.consoleUrl.replace(/\/$/, "")}/cli-login?port=${port}&state=${state}` };
|
|
2163
2795
|
}
|
|
2164
2796
|
const L = (fn) => (params) => engine.locked(() => fn(params));
|
|
2165
|
-
const soon = () => setTimeout(() => runTick(), 10);
|
|
2797
|
+
const soon = () => setTimeout(() => runTick({ remote: true }), 10);
|
|
2166
2798
|
const handlers = {
|
|
2167
2799
|
ping: () => ({ pid: process.pid, version: VERSION, home, tracker: tracker.name, server: config.get("server") }),
|
|
2168
2800
|
async status(p) {
|
|
@@ -2178,11 +2810,13 @@ async function runDaemon() {
|
|
|
2178
2810
|
};
|
|
2179
2811
|
},
|
|
2180
2812
|
sync: async () => {
|
|
2181
|
-
await runTick();
|
|
2813
|
+
await runTick({ remote: true });
|
|
2182
2814
|
return { offline: engine.offline };
|
|
2183
2815
|
},
|
|
2184
2816
|
async "login.password"({ email, password, signup }) {
|
|
2185
2817
|
const user = await api.passwordLogin({ email, password, signup });
|
|
2818
|
+
engine.feed = null;
|
|
2819
|
+
feed.restart();
|
|
2186
2820
|
soon();
|
|
2187
2821
|
return user;
|
|
2188
2822
|
},
|
|
@@ -2192,13 +2826,19 @@ async function runDaemon() {
|
|
|
2192
2826
|
if (!p) throw new UserError("no login in progress");
|
|
2193
2827
|
try {
|
|
2194
2828
|
const user = await p;
|
|
2829
|
+
engine.feed = null;
|
|
2830
|
+
feed.restart();
|
|
2195
2831
|
soon();
|
|
2196
2832
|
return user;
|
|
2197
2833
|
} finally {
|
|
2198
2834
|
logins.delete(state);
|
|
2199
2835
|
}
|
|
2200
2836
|
},
|
|
2201
|
-
logout: async () =>
|
|
2837
|
+
logout: async () => {
|
|
2838
|
+
engine.feed = null;
|
|
2839
|
+
feed.stop();
|
|
2840
|
+
return api.logout();
|
|
2841
|
+
},
|
|
2202
2842
|
whoami: () => api.call("GET", "/me"),
|
|
2203
2843
|
manage: L((p) => engine.manage(p)),
|
|
2204
2844
|
add: L((p) => engine.add(p)),
|
|
@@ -2258,6 +2898,10 @@ async function runDaemon() {
|
|
|
2258
2898
|
"config.get": ({ key }) => key ? { [key]: config.get(key) } : config.values,
|
|
2259
2899
|
"config.set": ({ key, value }) => {
|
|
2260
2900
|
config.set(key, value);
|
|
2901
|
+
if (key === "server") {
|
|
2902
|
+
engine.feed = null;
|
|
2903
|
+
feed.restart();
|
|
2904
|
+
}
|
|
2261
2905
|
if (key === "tickSeconds") schedule();
|
|
2262
2906
|
return { [key]: config.get(key) };
|
|
2263
2907
|
},
|
|
@@ -2267,11 +2911,15 @@ async function runDaemon() {
|
|
|
2267
2911
|
return { stopping: true };
|
|
2268
2912
|
}
|
|
2269
2913
|
};
|
|
2914
|
+
const CHANGES_WATCHED = /* @__PURE__ */ new Set(["manage", "add", "pull", "edit", "unmanage", "git.mode", "resolve", "locate"]);
|
|
2270
2915
|
async function handle(method, params) {
|
|
2271
2916
|
const h = handlers[method];
|
|
2272
2917
|
if (!h) throw new UserError(`unknown command ${method}`);
|
|
2918
|
+
if (method !== "ping") markActive();
|
|
2273
2919
|
try {
|
|
2274
|
-
|
|
2920
|
+
const result = await h(params);
|
|
2921
|
+
if (CHANGES_WATCHED.has(method)) refreshWatchers();
|
|
2922
|
+
return result;
|
|
2275
2923
|
} catch (e) {
|
|
2276
2924
|
if (e instanceof ApiError || e instanceof UserError) throw e;
|
|
2277
2925
|
if (e.name === "OfflineError") throw e;
|
|
@@ -2289,6 +2937,9 @@ async function runDaemon() {
|
|
|
2289
2937
|
clearTimeout(quickTimer);
|
|
2290
2938
|
clearTimeout(realtimeTimer);
|
|
2291
2939
|
clearInterval(updateTimer);
|
|
2940
|
+
clearInterval(repoTimer);
|
|
2941
|
+
clearTimeout(gitTimer);
|
|
2942
|
+
feed.stop();
|
|
2292
2943
|
for (const w of watchers.values()) w.close();
|
|
2293
2944
|
server.close();
|
|
2294
2945
|
engine.locked(() => db.close()).finally(() => {
|
|
@@ -2299,10 +2950,16 @@ async function runDaemon() {
|
|
|
2299
2950
|
}
|
|
2300
2951
|
process.on("SIGINT", () => shutdown(0));
|
|
2301
2952
|
process.on("SIGTERM", () => shutdown(0));
|
|
2302
|
-
await engine.locked(() =>
|
|
2953
|
+
await engine.locked(() => {
|
|
2954
|
+
engine.cleanupLegacyHooks();
|
|
2955
|
+
engine.detectNewRepos();
|
|
2956
|
+
}).catch((e) => log(`git setup: ${e.message}`));
|
|
2957
|
+
const repoTimer = setInterval(() => engine.locked(() => engine.detectNewRepos()).catch(() => {
|
|
2958
|
+
}), 60 * 60 * 1e3);
|
|
2303
2959
|
const updateTimer = setInterval(checkForUpdate, UPDATE_CHECK_MS);
|
|
2304
2960
|
setTimeout(checkForUpdate, 5e3);
|
|
2305
2961
|
await runTick();
|
|
2962
|
+
feed.start();
|
|
2306
2963
|
}
|
|
2307
2964
|
export {
|
|
2308
2965
|
VERSION,
|