agent-dag 3.20.0 → 3.21.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/README.md +113 -5
- package/dist/web/assets/{index-DVj_M279.css → index-BYMVIG6f.css} +1 -1
- package/dist/web/assets/index-G0ntHhBn.js +266 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/server/activity.mjs +52 -0
- package/src/server/auto-update.mjs +79 -0
- package/src/server/claude-accounts.mjs +32 -0
- package/src/server/cswap-admin.mjs +87 -11
- package/src/server/deck-prefs.mjs +51 -1
- package/src/server/index.mjs +150 -2
- package/src/server/lan-about.mjs +119 -0
- package/src/server/lan-engine.mjs +90 -7
- package/src/server/lan-sync.mjs +5 -1
- package/src/server/presence.mjs +40 -0
- package/dist/web/assets/index-Dr-bVaHM.js +0 -266
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// What a paired deck says about itself: the version it runs and the machine it
|
|
2
|
+
// runs on.
|
|
3
|
+
//
|
|
4
|
+
// ONLY TO A DECK THAT IS PAIRED, AND SEALED. The beacon is shouted at the whole
|
|
5
|
+
// network and the hello is sent before anybody has proved anything, so neither
|
|
6
|
+
// carries this: which build of which operating system a machine runs is the
|
|
7
|
+
// first thing somebody hunting for a known-broken version would want, and
|
|
8
|
+
// nobody on the network is owed it. It rides the manifest exchange, which only
|
|
9
|
+
// happens between two decks that have each accepted the other — and it is
|
|
10
|
+
// sealed with that connection's key, because the frames around it are JSON in
|
|
11
|
+
// the clear on the wire.
|
|
12
|
+
//
|
|
13
|
+
// BOTH WAYS. The deck that dials sends its own card with the question, and the
|
|
14
|
+
// deck that answers sends its own with the answer. A deck that only calls in —
|
|
15
|
+
// paired, with no address this one can reach — is never asked anything, so the
|
|
16
|
+
// card it sends when it calls is the only way this deck ever learns what it is.
|
|
17
|
+
//
|
|
18
|
+
// OLDER DECKS SAY NOTHING, AND NOTHING BREAKS. The field is extra on a frame
|
|
19
|
+
// both versions already send: a deck from before this reads the accounts and
|
|
20
|
+
// never looks at the rest, and a card that is absent is drawn as absent.
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { release as osRelease } from "node:os";
|
|
23
|
+
import { open, seal } from "./lan-sync.mjs";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The operating system, in the words its owner would use.
|
|
27
|
+
*
|
|
28
|
+
* Node reports the KERNEL — `darwin 25.5.0`, `win32 10.0.26100` — and nobody
|
|
29
|
+
* calls their laptop Darwin 25. So the numbers are translated on the machine
|
|
30
|
+
* they describe, where the translation is certain, rather than on the one
|
|
31
|
+
* reading them. Every input is a parameter, so all three platforms are tested
|
|
32
|
+
* on any one of them.
|
|
33
|
+
*/
|
|
34
|
+
export function osLabel({ platform, release = "", prettyName = "" } = {}) {
|
|
35
|
+
const [major, minor, build] = String(release).split(".").map(n => Number.parseInt(n, 10));
|
|
36
|
+
const dot = Number.isInteger(minor) ? `.${minor}` : "";
|
|
37
|
+
if (platform === "darwin") {
|
|
38
|
+
if (!Number.isInteger(major)) return "macOS";
|
|
39
|
+
// Darwin 20 was macOS 11 and every major after it was one more — until 25,
|
|
40
|
+
// which Apple shipped as macOS 26 to match the year.
|
|
41
|
+
if (major >= 25) return `macOS ${major + 1}${dot}`;
|
|
42
|
+
if (major >= 20) return `macOS ${major - 9}${dot}`;
|
|
43
|
+
// The 10.x years, where the kernel's major minus four is the second number.
|
|
44
|
+
return major >= 5 ? `macOS 10.${major - 4}` : "macOS";
|
|
45
|
+
}
|
|
46
|
+
if (platform === "win32") {
|
|
47
|
+
// Windows 11 kept the version 10.0 and moved only the build, and 22000 is
|
|
48
|
+
// the first one. From here it is the only way to tell the two apart.
|
|
49
|
+
if (major === 10 && Number.isInteger(build)) return build >= 22_000 ? "Windows 11" : "Windows 10";
|
|
50
|
+
return "Windows";
|
|
51
|
+
}
|
|
52
|
+
if (platform === "linux") return prettyName || "Linux";
|
|
53
|
+
return typeof platform === "string" && platform ? platform : "unknown";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The distribution's own name for itself, from the file every systemd-era
|
|
57
|
+
* distribution ships, with the fallback path the same spec names. Only ever
|
|
58
|
+
* read on Linux: the paths are Linux's and mean nothing anywhere else. */
|
|
59
|
+
export function linuxPrettyName(read = readFileSync) {
|
|
60
|
+
for (const file of ["/etc/os-release", "/usr/lib/os-release"]) {
|
|
61
|
+
try {
|
|
62
|
+
const line = /^PRETTY_NAME=(.*)$/m.exec(String(read(file, "utf8")))?.[1];
|
|
63
|
+
const name = line?.trim().replace(/^(["'])(.*)\1$/, "$2").trim();
|
|
64
|
+
if (name) return name;
|
|
65
|
+
} catch { /* not this one */ }
|
|
66
|
+
}
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** This deck's own card. Built once, at start: nothing in it changes while the
|
|
71
|
+
* process lives, and an upgrade is a restart. */
|
|
72
|
+
export function aboutThisDeck({
|
|
73
|
+
version = null, platform = process.platform, release = osRelease(), arch = process.arch, read = readFileSync,
|
|
74
|
+
} = {}) {
|
|
75
|
+
return readAbout({
|
|
76
|
+
version,
|
|
77
|
+
os: osLabel({ platform, release, prettyName: platform === "linux" ? linuxPrettyName(read) : "" }),
|
|
78
|
+
arch,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One field of a card, or null. It came off the network, so it is a string of
|
|
83
|
+
* bounded length with no control characters — the rule cleanName applies to a
|
|
84
|
+
* deck's name, for the same reason: it reaches a terminal as well as a page.
|
|
85
|
+
* `\p{Cc}` is the Unicode class for exactly those, C0 and C1 both. */
|
|
86
|
+
function field(v, max, shape = null) {
|
|
87
|
+
if (typeof v !== "string") return null;
|
|
88
|
+
const flat = v.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
|
|
89
|
+
const s = [...flat].slice(0, max).join("").trim();
|
|
90
|
+
return s && (!shape || shape.test(s)) ? s : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A card, or null when there is nothing in it worth drawing. */
|
|
94
|
+
export function readAbout(raw) {
|
|
95
|
+
if (!raw || typeof raw !== "object") return null;
|
|
96
|
+
const version = field(raw.version, 32, /^[0-9A-Za-z][0-9A-Za-z.+-]*$/);
|
|
97
|
+
const os = field(raw.os, 48);
|
|
98
|
+
const arch = field(raw.arch, 16, /^[0-9A-Za-z_]+$/);
|
|
99
|
+
return version || os || arch ? { version, os, arch } : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Bound to one connection AND one direction, so a card cannot be replayed
|
|
103
|
+
* back at the deck that sent it as if it were the other deck's. */
|
|
104
|
+
const aboutAad = (fromFp, toFp) => `${fromFp}->${toFp}|about`;
|
|
105
|
+
|
|
106
|
+
/** This deck's card, sealed for the deck on the other end of `key`. */
|
|
107
|
+
export function sealAbout(key, about, fromFp, toFp) {
|
|
108
|
+
if (!key || !about) return null;
|
|
109
|
+
return seal(key, JSON.stringify(about), aboutAad(fromFp, toFp));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The other deck's card, or null — for a deck that sent none, and for one
|
|
113
|
+
* whose seal does not open, which mean the same thing to the panel. */
|
|
114
|
+
export function openAbout(key, sealed, fromFp, toFp) {
|
|
115
|
+
if (!key || !sealed || typeof sealed !== "object") return null;
|
|
116
|
+
const text = open(key, sealed, aboutAad(fromFp, toFp));
|
|
117
|
+
if (text == null) return null;
|
|
118
|
+
try { return readAbout(JSON.parse(text)); } catch { return null; }
|
|
119
|
+
}
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { accountKey, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
|
|
31
31
|
import { connectToPeer, createBeacon, createSyncServer, sendFrame } from "./lan-socket.mjs";
|
|
32
32
|
import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
|
|
33
|
+
import { openAbout, sealAbout } from "./lan-about.mjs";
|
|
33
34
|
import { randomBytes } from "node:crypto";
|
|
34
35
|
import { hostname, networkInterfaces } from "node:os";
|
|
35
36
|
|
|
@@ -95,6 +96,20 @@ export function localAddresses(faces = networkInterfaces()) {
|
|
|
95
96
|
return out;
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The accounts a peer's manifest listed, as the panel may keep them.
|
|
101
|
+
*
|
|
102
|
+
* It arrived from another machine, so it is read rather than trusted: strings
|
|
103
|
+
* where strings belong, a boolean for the verdict, and no more rows than a
|
|
104
|
+
* manifest may carry. What is kept is only what the deck's dialog draws.
|
|
105
|
+
*/
|
|
106
|
+
export function offered(list) {
|
|
107
|
+
return (Array.isArray(list) ? list : [])
|
|
108
|
+
.filter(a => a && typeof a.key === "string" && typeof a.email === "string")
|
|
109
|
+
.slice(0, 50)
|
|
110
|
+
.map(a => ({ key: a.key.slice(0, 320), email: a.email.slice(0, 254), alive: a.alive === true }));
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
/**
|
|
99
114
|
* One deck's LAN sync, from settings to a healed account.
|
|
100
115
|
*
|
|
@@ -118,10 +133,13 @@ export function createEngine({
|
|
|
118
133
|
* with. Nothing secret leaves, and it is still a test shouting at an office.
|
|
119
134
|
*/
|
|
120
135
|
createSocket,
|
|
136
|
+
/** This deck's own card — its version and its machine — handed to every
|
|
137
|
+
* paired deck and to nobody else. See lan-about.mjs. */
|
|
138
|
+
about = null,
|
|
121
139
|
} = {}) {
|
|
122
140
|
let cfg = {
|
|
123
141
|
enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
|
|
124
|
-
autoAsk: true, autoAccept: true,
|
|
142
|
+
autoAsk: true, autoAccept: true, aliases: {},
|
|
125
143
|
};
|
|
126
144
|
let identity = null;
|
|
127
145
|
let beacon = null;
|
|
@@ -166,6 +184,16 @@ export function createEngine({
|
|
|
166
184
|
/** What the last round did, for the panel. Not a log: one line per peer, most
|
|
167
185
|
* recent only, because "what happened" is a question about now. */
|
|
168
186
|
const lastRound = new Map();
|
|
187
|
+
/** What each paired deck said about itself — version, operating system,
|
|
188
|
+
* architecture — keyed by fingerprint, most recent only. Filled from both
|
|
189
|
+
* directions: the manifest a deck answers with, and the question a deck that
|
|
190
|
+
* calls in asks. See lan-about.mjs. */
|
|
191
|
+
const aboutBy = new Map();
|
|
192
|
+
/** The accounts each paired deck offered in its last manifest, keyed by
|
|
193
|
+
* fingerprint. Kept APART from lastRound on purpose: a round that fails
|
|
194
|
+
* replaces that line, and the list a deck offered a minute ago is still the
|
|
195
|
+
* best answer to "what does it share" while it is unreachable. */
|
|
196
|
+
const offersBy = new Map();
|
|
169
197
|
/** When the last round FINISHED, whatever it did or failed to do. The panel's
|
|
170
198
|
* `↻` fires one on demand and the loop fires one on its own; a reader who
|
|
171
199
|
* pressed it wants to know it happened, and a reader who did not wants to
|
|
@@ -212,6 +240,15 @@ export function createEngine({
|
|
|
212
240
|
}));
|
|
213
241
|
};
|
|
214
242
|
|
|
243
|
+
/** This deck's card for one connection, as a frame field — or nothing, for a
|
|
244
|
+
* deck built without one. Spread into the frame, so a deck from before this
|
|
245
|
+
* existed receives exactly the frame it always did plus one key it never
|
|
246
|
+
* reads. */
|
|
247
|
+
const cardFor = (key, toFp) => {
|
|
248
|
+
const sealed = sealAbout(key, about, identity.fp, toFp);
|
|
249
|
+
return sealed ? { about: sealed } : {};
|
|
250
|
+
};
|
|
251
|
+
|
|
215
252
|
/** Frames from a deck that finished the handshake AND that somebody here has
|
|
216
253
|
* accepted. Nothing reaches this before both, which is the whole point of
|
|
217
254
|
* where the two checks sit. `ctx.key` is this connection's key and no other
|
|
@@ -222,8 +259,16 @@ export function createEngine({
|
|
|
222
259
|
if (ctx?.peerFp) spokeAt.set(ctx.peerFp, now());
|
|
223
260
|
try {
|
|
224
261
|
if (msg.t === "manifest") {
|
|
262
|
+
// THE CALLER'S CARD RIDES THE QUESTION, which is the only way a deck
|
|
263
|
+
// that calls in ever says what it is: nothing here dials it, so nothing
|
|
264
|
+
// here ever asks. A seal that does not open is a deck that said nothing.
|
|
265
|
+
const card = openAbout(ctx.key, msg.about, ctx.peerFp, identity.fp);
|
|
266
|
+
if (card) aboutBy.set(ctx.peerFp, { ...card, at: now() });
|
|
225
267
|
const accounts = await localAccounts();
|
|
226
|
-
return ctx.send({
|
|
268
|
+
return ctx.send({
|
|
269
|
+
t: "manifest", accounts: manifestFor(accounts, cfg.shared),
|
|
270
|
+
...cardFor(ctx.key, ctx.peerFp),
|
|
271
|
+
});
|
|
227
272
|
}
|
|
228
273
|
if (msg.t === "want") {
|
|
229
274
|
// A SECOND PROOF, for the one operation that moves a credential. The
|
|
@@ -313,13 +358,18 @@ export function createEngine({
|
|
|
313
358
|
|
|
314
359
|
if (!trustedPeer(cfg.trusted, conn.peerFp)) {
|
|
315
360
|
const { list, added } = addTrusted(cfg.trusted, {
|
|
316
|
-
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
|
|
361
|
+
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName, at: now(),
|
|
317
362
|
});
|
|
318
363
|
if (added) { cfg = { ...cfg, trusted: list }; onTrust?.(list); }
|
|
319
364
|
}
|
|
320
365
|
|
|
321
|
-
|
|
366
|
+
// Our card goes with the question and theirs comes back with the answer
|
|
367
|
+
// — see lan-about.mjs for why it is here and nowhere earlier.
|
|
368
|
+
const theirs = await ask({ t: "manifest", ...cardFor(conn.key, conn.peerFp) });
|
|
322
369
|
if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
|
|
370
|
+
const card = openAbout(conn.key, theirs.about, conn.peerFp, identity.fp);
|
|
371
|
+
if (card) aboutBy.set(conn.peerFp, { ...card, at: now() });
|
|
372
|
+
offersBy.set(conn.peerFp, { at: now(), accounts: offered(theirs.accounts) });
|
|
323
373
|
const mine = await localAccounts();
|
|
324
374
|
// Only accounts I have also ticked. Sharing is mutual by construction:
|
|
325
375
|
// a peer cannot push an account at me that I never agreed to hold.
|
|
@@ -449,7 +499,7 @@ export function createEngine({
|
|
|
449
499
|
// Somebody used the token. They are pinned, and the token is retired —
|
|
450
500
|
// one that pairs twice is one worth stealing twice.
|
|
451
501
|
onInviteUsed: entry => {
|
|
452
|
-
const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name });
|
|
502
|
+
const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name, at: now() });
|
|
453
503
|
cfg = { ...cfg, trusted: list };
|
|
454
504
|
invite = null;
|
|
455
505
|
// AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
|
|
@@ -612,7 +662,7 @@ export function createEngine({
|
|
|
612
662
|
name: cfg.name, myPort: server.port(), code: inv.code,
|
|
613
663
|
});
|
|
614
664
|
const { list } = addTrusted(cfg.trusted, {
|
|
615
|
-
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name,
|
|
665
|
+
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
|
|
616
666
|
});
|
|
617
667
|
cfg = { ...cfg, trusted: list };
|
|
618
668
|
this.addPeer(at.addr, at.port);
|
|
@@ -667,7 +717,7 @@ export function createEngine({
|
|
|
667
717
|
onChange?.();
|
|
668
718
|
return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
|
|
669
719
|
}
|
|
670
|
-
const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name });
|
|
720
|
+
const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name, at: now() });
|
|
671
721
|
cfg = { ...cfg, trusted: list };
|
|
672
722
|
pending.delete(fp);
|
|
673
723
|
strangers.delete(fp);
|
|
@@ -724,6 +774,24 @@ export function createEngine({
|
|
|
724
774
|
return true;
|
|
725
775
|
},
|
|
726
776
|
round,
|
|
777
|
+
/**
|
|
778
|
+
* One deck, now — the `check now` in that deck's own dialog.
|
|
779
|
+
*
|
|
780
|
+
* Found the way the list found it: heard on the network under its own
|
|
781
|
+
* fingerprint, or dialled at an address whose answer was that fingerprint.
|
|
782
|
+
* Null when it is neither, which is a deck that only calls in — nothing
|
|
783
|
+
* here holds an address for it, so there is nobody to dial.
|
|
784
|
+
*
|
|
785
|
+
* `roundAt` is left alone: it says when EVERY paired deck was last asked,
|
|
786
|
+
* and asking one of them does not make that true.
|
|
787
|
+
*/
|
|
788
|
+
async roundOne(fp) {
|
|
789
|
+
if (!beacon || typeof fp !== "string" || !fp) return null;
|
|
790
|
+
const heard = [...beacon.peers.values()].find(p => p.fp === fp && stillListed(p, now()));
|
|
791
|
+
const typed = [...manual.values()].find(p => learned.get(`${p.addr}:${p.port}`)?.fp === fp);
|
|
792
|
+
const peer = heard ?? typed;
|
|
793
|
+
return peer ? roundWith(peer) : null;
|
|
794
|
+
},
|
|
727
795
|
/** Dial this address on every round from now on. Returns false for an
|
|
728
796
|
* address that is not one, rather than storing a row that can never
|
|
729
797
|
* connect and reports an error every minute forever. */
|
|
@@ -758,6 +826,11 @@ export function createEngine({
|
|
|
758
826
|
// which on a deck that has just started is the honest answer.
|
|
759
827
|
checkedAt: roundAt,
|
|
760
828
|
name: cfg.name,
|
|
829
|
+
// This deck's own card, so the panel can read a peer's version against
|
|
830
|
+
// it; and the names somebody here gave other decks, which the panel
|
|
831
|
+
// and the request dialog draw in place of the ones those decks chose.
|
|
832
|
+
about: about ?? null,
|
|
833
|
+
aliases: { ...(cfg.aliases ?? {}) },
|
|
761
834
|
// Whether this deck asks on its own, and whether a request that
|
|
762
835
|
// arrives is answered here or answered for you.
|
|
763
836
|
autoAsk: !!cfg.autoAsk,
|
|
@@ -820,6 +893,15 @@ export function createEngine({
|
|
|
820
893
|
had.met = had.met || row.met;
|
|
821
894
|
if (row.name && !had.name) had.name = row.name;
|
|
822
895
|
};
|
|
896
|
+
// WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
|
|
897
|
+
// the logins it offered last, and when somebody here said yes. All
|
|
898
|
+
// three are keyed by the fingerprint that proved itself, so both
|
|
899
|
+
// halves of a merged row read the same answer.
|
|
900
|
+
const card = id => ({
|
|
901
|
+
about: aboutBy.get(id) ?? null,
|
|
902
|
+
offers: offersBy.get(id) ?? null,
|
|
903
|
+
pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
|
|
904
|
+
});
|
|
823
905
|
for (const p of [...beacon.peers.values(), ...manual.values()]) {
|
|
824
906
|
if (!stillListed(p, now())) continue;
|
|
825
907
|
const met = p.manual ? learned.get(`${p.addr}:${p.port}`) : null;
|
|
@@ -834,6 +916,7 @@ export function createEngine({
|
|
|
834
916
|
met: !!met,
|
|
835
917
|
paired: !!trustedPeer(cfg.trusted, id),
|
|
836
918
|
last: lastRound.get(p.fp) ?? null,
|
|
919
|
+
...card(id),
|
|
837
920
|
});
|
|
838
921
|
}
|
|
839
922
|
// A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -592,7 +592,11 @@ export function addTrusted(trusted, entry) {
|
|
|
592
592
|
added: false,
|
|
593
593
|
};
|
|
594
594
|
}
|
|
595
|
-
|
|
595
|
+
// WHEN, for the panel's "paired since". Only a new pin gets one: a deck that
|
|
596
|
+
// was already trusted keeps whatever date it had, and one pinned before this
|
|
597
|
+
// field existed keeps having none rather than being given today's.
|
|
598
|
+
const at = Number.isFinite(entry.at) && entry.at > 0 ? { at: entry.at } : {};
|
|
599
|
+
return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "", ...at }], added: true };
|
|
596
600
|
}
|
|
597
601
|
|
|
598
602
|
/** Take one back out. Unpairing stops what has not happened yet and takes back
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Whether somebody is looking at the deck right now, as its tabs say.
|
|
2
|
+
//
|
|
3
|
+
// The server can tell whether a page is CONNECTED — `sseClients.size` — but not
|
|
4
|
+
// whether anybody is looking at one: a tab behind other windows keeps its
|
|
5
|
+
// stream open all day. The away-update (auto-update.mjs) needs the second fact,
|
|
6
|
+
// so each tab reports it (src/web/presence.ts), and a claim expires here unless
|
|
7
|
+
// it is renewed, because a tab that crashed or lost its network never gets to
|
|
8
|
+
// take it back.
|
|
9
|
+
export const PRESENCE_TTL_MS = 45_000;
|
|
10
|
+
/** More tabs than anybody opens on one deck. A bound because the ids arrive
|
|
11
|
+
* from pages; past it the oldest claim goes. */
|
|
12
|
+
const MAX_TABS = 32;
|
|
13
|
+
const TAB_ID = /^[\w-]{1,64}$/;
|
|
14
|
+
|
|
15
|
+
export function createPresence({ ttlMs = PRESENCE_TTL_MS } = {}) {
|
|
16
|
+
/** tab id → when it last said it was looking */
|
|
17
|
+
const seen = new Map();
|
|
18
|
+
return {
|
|
19
|
+
/** A tab saying whether it is being looked at. False for an id no page
|
|
20
|
+
* could have made, which the route answers with a 400. */
|
|
21
|
+
report(tab, looking, now) {
|
|
22
|
+
if (typeof tab !== "string" || !TAB_ID.test(tab)) return false;
|
|
23
|
+
seen.delete(tab);
|
|
24
|
+
if (looking) {
|
|
25
|
+
seen.set(tab, now);
|
|
26
|
+
while (seen.size > MAX_TABS) seen.delete(seen.keys().next().value);
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
},
|
|
30
|
+
/** Whether any tab's claim is still live. A claim stamped by a clock that
|
|
31
|
+
* has since moved either way is as expired as an old one. */
|
|
32
|
+
looking(now) {
|
|
33
|
+
for (const [tab, at] of seen) {
|
|
34
|
+
if (Math.abs(now - at) > ttlMs) seen.delete(tab);
|
|
35
|
+
else return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|