ccdeck 3.28.1 → 3.29.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 +4 -0
- package/THIRD_PARTY_NOTICES.md +148 -4
- package/dist/web/assets/{BrowserWatchModal-CIla-_hm.js → BrowserWatchModal-CxfWHmjb.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-CSgh5kjS.js → UsageHistoryModal-D-XRttte.js} +1 -1
- package/dist/web/assets/hls-BFpXDghs.js +42 -0
- package/dist/web/assets/index-DrbRdXDD.js +298 -0
- package/dist/web/assets/index-GTZ73XfY.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/release-notes.json +26 -0
- package/src/server/account-health.mjs +38 -0
- package/src/server/claude-accounts.mjs +84 -26
- package/src/server/cswap-admin.mjs +136 -8
- package/src/server/deck-prefs.mjs +2 -0
- package/src/server/fm-station.mjs +186 -0
- package/src/server/index.mjs +204 -20
- package/src/server/lan-engine.mjs +244 -38
- package/src/server/lan-socket.mjs +101 -15
- package/src/server/lan-sync.mjs +86 -9
- package/dist/web/assets/index-CsHTYwoD.js +0 -297
- package/dist/web/assets/index-DbwzUUcd.css +0 -1
|
@@ -36,6 +36,10 @@ import { looksLikeTunnel } from "./route-via.mjs";
|
|
|
36
36
|
const REFUSALS = Object.freeze({
|
|
37
37
|
pending: "waiting for the other deck to accept this one",
|
|
38
38
|
declined: "that deck said no",
|
|
39
|
+
"invite only": "that deck pairs only by invite",
|
|
40
|
+
// Sent back to a caller that said it was not asking, so the far end's answer
|
|
41
|
+
// is about the caller's own setting — the same words its own round uses.
|
|
42
|
+
"not asking": "this deck pairs only by invite",
|
|
39
43
|
impostor: "that deck has this one pinned under a different key",
|
|
40
44
|
"bad proof": "the other deck refused this one's proof",
|
|
41
45
|
});
|
|
@@ -291,6 +295,9 @@ export function createBeacon({
|
|
|
291
295
|
let told = null;
|
|
292
296
|
let rebind = null;
|
|
293
297
|
let stopped = true;
|
|
298
|
+
// Socket binds finish asynchronously. A stopped start must not attach its
|
|
299
|
+
// late socket to a subsequent start or leave an outbound socket open.
|
|
300
|
+
let startGeneration = 0;
|
|
294
301
|
/** When this deck last answered a deck it had not heard, so answering cannot
|
|
295
302
|
* become a storm, and which decks it has already answered — without the
|
|
296
303
|
* second, a deck that is never accepted is answered again on every packet
|
|
@@ -302,6 +309,7 @@ export function createBeacon({
|
|
|
302
309
|
|
|
303
310
|
const announce = (also = []) => {
|
|
304
311
|
if (!sock && !out) return;
|
|
312
|
+
const announcedIn = startGeneration;
|
|
305
313
|
// EVERY BROADCAST ADDRESS THIS MACHINE HAS, not one.
|
|
306
314
|
//
|
|
307
315
|
// This used to send only to 255.255.255.255, on the argument that the
|
|
@@ -335,6 +343,9 @@ export function createBeacon({
|
|
|
335
343
|
const failed = [];
|
|
336
344
|
for (const to of targets) {
|
|
337
345
|
from.send(payload(), DISCOVERY_PORT, to, err => {
|
|
346
|
+
// A send may finish after this socket has closed and a new LAN
|
|
347
|
+
// session has started. Its result says nothing about the new session.
|
|
348
|
+
if (stopped || announcedIn !== startGeneration) return;
|
|
338
349
|
if (err) failed.push(`${to} (${err.code ?? err.message})`);
|
|
339
350
|
// REPORTED ONLY WHEN EVERY ONE FAILED. One address being unreachable is
|
|
340
351
|
// the ordinary state of a machine with a VPN up, and a panel that said
|
|
@@ -358,6 +369,9 @@ export function createBeacon({
|
|
|
358
369
|
};
|
|
359
370
|
|
|
360
371
|
const onMessage = (msg, rinfo) => {
|
|
372
|
+
// A queued UDP callback can run after close(). It belongs to the stopped
|
|
373
|
+
// discovery session and must not repopulate peers or invite strangers.
|
|
374
|
+
if (stopped) return;
|
|
361
375
|
// Everything about whether to care lives in lan-sync.mjs. This hands it
|
|
362
376
|
// the bytes and the address and does what it is told.
|
|
363
377
|
if (msg.length > MAX_BEACON_BYTES) return;
|
|
@@ -421,16 +435,24 @@ export function createBeacon({
|
|
|
421
435
|
|
|
422
436
|
/** One try at the discovery port: the listening socket, or the error that
|
|
423
437
|
* kept it. A bind that fails never calls back; it arrives as an error. */
|
|
424
|
-
const listen = () => new Promise(resolve => {
|
|
438
|
+
const listen = (startedIn) => new Promise(resolve => {
|
|
425
439
|
let s;
|
|
426
440
|
try { s = createSocket({ type: "udp4", reuseAddr: true }); } catch (err) { resolve({ err }); return; }
|
|
427
441
|
let bound = false;
|
|
428
442
|
s.on("error", err => {
|
|
429
|
-
if (bound) {
|
|
443
|
+
if (bound) {
|
|
444
|
+
if (!stopped && startedIn === startGeneration) onError?.("socket", err);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
430
447
|
try { s.close(); } catch { /* never opened */ }
|
|
431
448
|
resolve({ err });
|
|
432
449
|
});
|
|
433
|
-
|
|
450
|
+
// close() can leave a message callback queued. After restart, stopped is
|
|
451
|
+
// false again, so the socket's own generation must also be checked.
|
|
452
|
+
s.on("message", (msg, rinfo) => {
|
|
453
|
+
if (startedIn !== startGeneration) return;
|
|
454
|
+
onMessage(msg, rinfo);
|
|
455
|
+
});
|
|
434
456
|
s.bind(DISCOVERY_PORT, "0.0.0.0", () => {
|
|
435
457
|
bound = true;
|
|
436
458
|
try { s.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
|
|
@@ -438,9 +460,10 @@ export function createBeacon({
|
|
|
438
460
|
});
|
|
439
461
|
});
|
|
440
462
|
|
|
441
|
-
const tryListen = async () => {
|
|
442
|
-
|
|
443
|
-
|
|
463
|
+
const tryListen = async (startedIn) => {
|
|
464
|
+
if (stopped || startedIn !== startGeneration) return;
|
|
465
|
+
const got = await listen(startedIn);
|
|
466
|
+
if (stopped || startedIn !== startGeneration) { try { got.sock?.close(); } catch { /* gone */ } return; }
|
|
444
467
|
if (got.sock) {
|
|
445
468
|
sock = got.sock;
|
|
446
469
|
hearing = true;
|
|
@@ -451,7 +474,7 @@ export function createBeacon({
|
|
|
451
474
|
hearing = false;
|
|
452
475
|
deafError = got.err;
|
|
453
476
|
tell(false);
|
|
454
|
-
rebind = setTimeout(() => { rebind = null; void tryListen(); }, rebindMs);
|
|
477
|
+
rebind = setTimeout(() => { rebind = null; void tryListen(startedIn); }, rebindMs);
|
|
455
478
|
rebind.unref?.();
|
|
456
479
|
};
|
|
457
480
|
|
|
@@ -487,11 +510,17 @@ export function createBeacon({
|
|
|
487
510
|
});
|
|
488
511
|
|
|
489
512
|
const start = async () => {
|
|
513
|
+
const startedIn = ++startGeneration;
|
|
490
514
|
stopped = false;
|
|
491
515
|
told = null;
|
|
492
|
-
await tryListen();
|
|
493
|
-
|
|
494
|
-
|
|
516
|
+
await tryListen(startedIn);
|
|
517
|
+
if (stopped || startedIn !== startGeneration) return;
|
|
518
|
+
const opened = await openOut();
|
|
519
|
+
if (stopped || startedIn !== startGeneration) {
|
|
520
|
+
try { opened?.close(); } catch { /* already closed */ }
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
out = opened;
|
|
495
524
|
// Immediately, not on the next tick. Syncthing's rule: a deck that just
|
|
496
525
|
// came up should appear now rather than up to thirty seconds later, which
|
|
497
526
|
// is the difference between "it works" and "it seems broken" for anybody
|
|
@@ -512,6 +541,7 @@ export function createBeacon({
|
|
|
512
541
|
tunneled: () => tunneled,
|
|
513
542
|
stop() {
|
|
514
543
|
stopped = true;
|
|
544
|
+
startGeneration++;
|
|
515
545
|
if (timer) clearInterval(timer);
|
|
516
546
|
timer = null;
|
|
517
547
|
if (rebind) clearTimeout(rebind);
|
|
@@ -605,6 +635,10 @@ export function createSyncServer({
|
|
|
605
635
|
* silent wait — they are the same frame otherwise, and only one of them ever
|
|
606
636
|
* comes right by waiting. */
|
|
607
637
|
declined = () => false,
|
|
638
|
+
/** Does this deck pair only by invite? Then a deck it has not met, holding no
|
|
639
|
+
* invite, is told so instead of "pending": nobody here will ever be shown a
|
|
640
|
+
* request to accept, and a caller left waiting on one waits for good. */
|
|
641
|
+
inviteOnly = () => false,
|
|
608
642
|
/** The invite this deck is currently offering, or null. A caller that proves
|
|
609
643
|
* it holds the code is somebody the owner handed a token to, so it is paired
|
|
610
644
|
* on arrival rather than queued behind a press. */
|
|
@@ -645,6 +679,7 @@ export function createSyncServer({
|
|
|
645
679
|
ephemeral = true,
|
|
646
680
|
} = {}) {
|
|
647
681
|
let server = null;
|
|
682
|
+
let cancelStart = null;
|
|
648
683
|
const live = new Set();
|
|
649
684
|
|
|
650
685
|
/** Which host a socket came from, in one spelling. Node reports an IPv4 peer
|
|
@@ -690,6 +725,9 @@ export function createSyncServer({
|
|
|
690
725
|
let peerPub = null;
|
|
691
726
|
let peerName = "";
|
|
692
727
|
let peerPort = null;
|
|
728
|
+
// Whether the caller said it is NOT asking to pair — an invite-only deck
|
|
729
|
+
// reaching an address it already had. See the unknown-deck branch below.
|
|
730
|
+
let peerNoAsk = false;
|
|
693
731
|
let key = null;
|
|
694
732
|
// Carrying this deck's marks — that it seals, and that it mixes a key pair
|
|
695
733
|
// of its own into the key — inside the one field the handshake already
|
|
@@ -815,6 +853,7 @@ export function createSyncServer({
|
|
|
815
853
|
// of a fixed 288px column and left a complete, plausible sentence.
|
|
816
854
|
peerName = cleanName(msg.name, "");
|
|
817
855
|
peerPort = Number.isInteger(msg.port) && msg.port > 0 && msg.port < 65_536 ? msg.port : null;
|
|
856
|
+
peerNoAsk = msg.ask === false;
|
|
818
857
|
// MIXED WHEN BOTH CHALLENGES SAY SO, and only then: the two strings
|
|
819
858
|
// the proofs bind decide it, never whether a field turned up. Once
|
|
820
859
|
// both say so, a hello with no usable key is refused rather than
|
|
@@ -925,6 +964,19 @@ export function createSyncServer({
|
|
|
925
964
|
// do — the deck that asked is told, so its own panel can stop saying
|
|
926
965
|
// "waiting" about a question that has been answered.
|
|
927
966
|
if (declined(peerFp)) return refuse("declined");
|
|
967
|
+
// AN INVITE-ONLY DECK, and this caller brought none (a caller that
|
|
968
|
+
// did was paired above). Answered rather than queued: the engine
|
|
969
|
+
// records no request in this mode, so "pending" would leave the other
|
|
970
|
+
// deck's panel saying "waiting for them to say yes" about a question
|
|
971
|
+
// nobody here will ever see. After "declined", which is the more
|
|
972
|
+
// specific answer about this one deck.
|
|
973
|
+
if (inviteOnly()) return refuse("invite only");
|
|
974
|
+
// A CALLER THAT IS NOT ASKING. An invite-only deck still dials the
|
|
975
|
+
// addresses it already had, because an invite-paired deck is one of
|
|
976
|
+
// them; one that turns out not to know it must not become a request
|
|
977
|
+
// here — with the accept switch on, that request would have pinned a
|
|
978
|
+
// deck whose owner said it pairs only by invite.
|
|
979
|
+
if (peerNoAsk) return refuse("not asking");
|
|
928
980
|
|
|
929
981
|
// A REAL DECK WE HAVE NOT MET. It finished a handshake, so it is not
|
|
930
982
|
// a port scan, and it told us a name and an address a person can
|
|
@@ -1001,23 +1053,51 @@ export function createSyncServer({
|
|
|
1001
1053
|
start: () => new Promise((resolve, reject) => {
|
|
1002
1054
|
const wanted = Number.isInteger(prefer) && prefer > 0 && prefer < 65_536 ? prefer : 0;
|
|
1003
1055
|
let retried = wanted === 0;
|
|
1004
|
-
|
|
1005
|
-
server
|
|
1056
|
+
const listener = net.createServer(onConnection);
|
|
1057
|
+
server = listener;
|
|
1058
|
+
let pending = true;
|
|
1059
|
+
const finish = (done, result) => {
|
|
1060
|
+
if (!pending) return;
|
|
1061
|
+
pending = false;
|
|
1062
|
+
if (cancelStart === cancel) cancelStart = null;
|
|
1063
|
+
done(result);
|
|
1064
|
+
};
|
|
1065
|
+
// A close before the listening callback otherwise leaves start() pending
|
|
1066
|
+
// forever. Null tells the engine that this startup was cancelled.
|
|
1067
|
+
let cancelled = false;
|
|
1068
|
+
const cancel = () => { cancelled = true; finish(resolve, null); };
|
|
1069
|
+
// A bind that lands after the cancel is closed by its own callback. With
|
|
1070
|
+
// a host, `listen` binds after a dns.lookup that current Node drops on
|
|
1071
|
+
// close(); a runtime that still binds would leave a listener nobody owns.
|
|
1072
|
+
const bound = () => {
|
|
1073
|
+
if (cancelled) { try { listener.close(); } catch { /* already closed */ } return; }
|
|
1074
|
+
finish(resolve, listener.address()?.port ?? null);
|
|
1075
|
+
};
|
|
1076
|
+
cancelStart = cancel;
|
|
1077
|
+
listener.on("error", err => {
|
|
1078
|
+
// A listener can also fail after its initial bind succeeded. Continue
|
|
1079
|
+
// reporting those errors while it is the active server.
|
|
1080
|
+
if (!pending) {
|
|
1081
|
+
if (server === listener) onError?.("listen", err);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1006
1084
|
if (!retried) {
|
|
1007
1085
|
// Somebody else has it — another deck on this machine, or something
|
|
1008
1086
|
// unrelated. The pin is not worth failing to start over.
|
|
1009
1087
|
retried = true;
|
|
1010
1088
|
onError?.("listen", err);
|
|
1011
|
-
try {
|
|
1089
|
+
try { listener.listen(0, host, bound); } catch { finish(reject, err); }
|
|
1012
1090
|
return;
|
|
1013
1091
|
}
|
|
1014
1092
|
onError?.("listen", err);
|
|
1015
|
-
reject
|
|
1093
|
+
finish(reject, err);
|
|
1016
1094
|
});
|
|
1017
|
-
|
|
1095
|
+
try { listener.listen(wanted, host, bound); }
|
|
1096
|
+
catch (err) { finish(reject, err); }
|
|
1018
1097
|
}),
|
|
1019
1098
|
port: () => server?.address()?.port ?? null,
|
|
1020
1099
|
stop() {
|
|
1100
|
+
cancelStart?.();
|
|
1021
1101
|
for (const s of live) s.destroy();
|
|
1022
1102
|
live.clear();
|
|
1023
1103
|
try { server?.close(); } catch { /* not listening */ }
|
|
@@ -1038,6 +1118,11 @@ export function createSyncServer({
|
|
|
1038
1118
|
*/
|
|
1039
1119
|
export function connectToPeer({
|
|
1040
1120
|
host, port, fp, pub, secret, name, myPort = null, code = null, timeoutMs = HANDSHAKE_MS,
|
|
1121
|
+
/** False when this deck is reaching an address it already had WITHOUT asking
|
|
1122
|
+
* to pair — an invite-only deck's round. Sent only when false, so every other
|
|
1123
|
+
* hello is byte-for-byte what it was, and a deck that predates the field
|
|
1124
|
+
* reads past it the way it reads past `epk`. */
|
|
1125
|
+
ask = true,
|
|
1041
1126
|
/** The public key we pinned for this deck the first time, or null for a deck
|
|
1042
1127
|
* we are meeting — an address somebody typed. */
|
|
1043
1128
|
expectPub = null,
|
|
@@ -1089,6 +1174,7 @@ export function connectToPeer({
|
|
|
1089
1174
|
// reads past a field it does not know, and one of this version answers.
|
|
1090
1175
|
sendFrame(sock, {
|
|
1091
1176
|
t: "hello", fp, pub, name, port: myPort, challenge: myChallenge, ...(myEpk ? { epk: myEpk } : {}),
|
|
1177
|
+
...(ask === false ? { ask: false } : {}),
|
|
1092
1178
|
});
|
|
1093
1179
|
});
|
|
1094
1180
|
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -1332,6 +1332,47 @@ export function accountKey(email, orgUuid) {
|
|
|
1332
1332
|
return `${String(email ?? "").trim().toLowerCase()}@@${String(orgUuid ?? "")}`;
|
|
1333
1333
|
}
|
|
1334
1334
|
|
|
1335
|
+
// ── why a login did not move ────────────────────────────────────────────────
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* The reason a sending deck gives for a login it holds and cannot read: on a
|
|
1339
|
+
* Mac, claude-swap could not open the Keychain from the session this deck runs
|
|
1340
|
+
* in. A fixed code, never the CLI's words — export's stdout IS the credential,
|
|
1341
|
+
* and nothing it printed is any business of the deck asking.
|
|
1342
|
+
*/
|
|
1343
|
+
export const SENDER_UNREADABLE = "keychain_unavailable";
|
|
1344
|
+
|
|
1345
|
+
/** Every reason `serve` answers a `want` with. */
|
|
1346
|
+
const WIRE_REFUSALS = new Set(["proof", "not shared", "not mine to give", "export failed", SENDER_UNREADABLE, "error"]);
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* A peer's refusal, as this deck records it. The panel prints these, so a peer
|
|
1350
|
+
* must not be able to put words in its mouth — least of all one of the HERE
|
|
1351
|
+
* codes, which are sentences about this machine. Anything outside the closed
|
|
1352
|
+
* set is "refused", which is all it ever proved.
|
|
1353
|
+
*/
|
|
1354
|
+
export function peerWhy(why) {
|
|
1355
|
+
return typeof why === "string" && WIRE_REFUSALS.has(why) ? why : "refused";
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* What this deck found wrong with a login it received. Produced by the local
|
|
1360
|
+
* adapters only and never read off a frame — see peerWhy — so a code here is
|
|
1361
|
+
* always about THIS machine, and on an arrived row it is a warning rather than
|
|
1362
|
+
* a failure: the credential landed and something about this machine stops it
|
|
1363
|
+
* being used.
|
|
1364
|
+
*/
|
|
1365
|
+
export const HERE = Object.freeze({
|
|
1366
|
+
// claude-swap cannot open this Mac's Keychain from the deck's session.
|
|
1367
|
+
unreadable: "unreadable_here",
|
|
1368
|
+
// It landed, and claude-swap still holds no login for it.
|
|
1369
|
+
noLogin: "no_credentials_here",
|
|
1370
|
+
// It landed, and the login it carried was rejected.
|
|
1371
|
+
expired: "relogin_required_here",
|
|
1372
|
+
// It landed, and claude-swap could not be asked whether it is readable.
|
|
1373
|
+
unverified: "unverified_here",
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1335
1376
|
/**
|
|
1336
1377
|
* What to do about one account, given what I have and what a peer has.
|
|
1337
1378
|
*
|
|
@@ -1364,7 +1405,7 @@ export function accountKey(email, orgUuid) {
|
|
|
1364
1405
|
* nothing here ever asks for that.
|
|
1365
1406
|
*/
|
|
1366
1407
|
export function syncAction(mine, theirs) {
|
|
1367
|
-
if (!theirs || !theirs.alive) return null;
|
|
1408
|
+
if (!theirs || !theirs.alive || theirs.shareable === false) return null;
|
|
1368
1409
|
if (!mine) return "add";
|
|
1369
1410
|
return mine.alive ? null : "heal";
|
|
1370
1411
|
}
|
|
@@ -1378,7 +1419,10 @@ export function syncAction(mine, theirs) {
|
|
|
1378
1419
|
* different half each time.
|
|
1379
1420
|
*/
|
|
1380
1421
|
export function plan(local, remote) {
|
|
1381
|
-
|
|
1422
|
+
// Through onePerKey, not array order: a Map built straight from the rows
|
|
1423
|
+
// keeps the LAST one for a key, so an expired second slot hid a live first
|
|
1424
|
+
// one and every round "healed" a login that works here.
|
|
1425
|
+
const mine = new Map(onePerKey(local).map(a => [a.key, a]));
|
|
1382
1426
|
const out = [];
|
|
1383
1427
|
for (const theirs of remote) {
|
|
1384
1428
|
const action = syncAction(mine.get(theirs.key), theirs);
|
|
@@ -1387,14 +1431,43 @@ export function plan(local, remote) {
|
|
|
1387
1431
|
return out.sort((a, b) => a.key.localeCompare(b.key));
|
|
1388
1432
|
}
|
|
1389
1433
|
|
|
1434
|
+
/**
|
|
1435
|
+
* One row per identity, and THE rule for which one when claude-swap holds two
|
|
1436
|
+
* slots for the same login: a live copy beats an expired one, then one this
|
|
1437
|
+
* process can read beats one it cannot, then the earlier slot.
|
|
1438
|
+
*
|
|
1439
|
+
* ONE RULE, FOUR PLACES. The manifest this deck sends, the peer manifest it
|
|
1440
|
+
* reads, the export it answers a `want` with, and plan's view of this deck's
|
|
1441
|
+
* own copy all pick through this, so the slot a peer was told about is the
|
|
1442
|
+
* slot it is then handed, and the copy this deck advertises as working is the
|
|
1443
|
+
* one it plans from. Each used to take array order on its own and agreed only
|
|
1444
|
+
* by coincidence — and two slots for one identity became two imports of it in
|
|
1445
|
+
* a single round.
|
|
1446
|
+
*/
|
|
1447
|
+
export function onePerKey(rows) {
|
|
1448
|
+
const rank = a => (a.alive ? 2 : 0) + (a.readable === false ? 0 : 1);
|
|
1449
|
+
const best = new Map();
|
|
1450
|
+
for (const a of rows) {
|
|
1451
|
+
const had = best.get(a.key);
|
|
1452
|
+
if (!had || rank(a) > rank(had)) best.set(a.key, a);
|
|
1453
|
+
}
|
|
1454
|
+
return [...best.values()];
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** This deck's own slot for one identity, by onePerKey's rule, or null. */
|
|
1458
|
+
export function slotFor(accounts, key) {
|
|
1459
|
+
return onePerKey(accounts.filter(a => a.key === key))[0] ?? null;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1390
1462
|
/**
|
|
1391
1463
|
* What this deck publishes about its own accounts — to the group, and only to
|
|
1392
1464
|
* the group.
|
|
1393
1465
|
*
|
|
1394
|
-
*
|
|
1395
|
-
*
|
|
1396
|
-
*
|
|
1397
|
-
* stops a
|
|
1466
|
+
* `alive` says whether the stored copy is still valid. `shareable: false` is
|
|
1467
|
+
* the separate, temporary condition where this process cannot read that valid
|
|
1468
|
+
* copy (for example a locked macOS Keychain). Keeping those facts separate is
|
|
1469
|
+
* what stops another deck treating a healthy-but-inaccessible copy as expired
|
|
1470
|
+
* and repeatedly trying to heal it.
|
|
1398
1471
|
*
|
|
1399
1472
|
* Emails are in it, and the manifest is sealed on its way. This said "in the
|
|
1400
1473
|
* clear inside the encrypted channel" until #810, and there was no such
|
|
@@ -1417,9 +1490,13 @@ export function plan(local, remote) {
|
|
|
1417
1490
|
*/
|
|
1418
1491
|
export function manifestFor(accounts, shared) {
|
|
1419
1492
|
const want = new Set(shared);
|
|
1420
|
-
return accounts
|
|
1421
|
-
.
|
|
1422
|
-
|
|
1493
|
+
return onePerKey(accounts.filter(a => want.has(a.key)))
|
|
1494
|
+
.map(a => ({
|
|
1495
|
+
key: a.key,
|
|
1496
|
+
email: a.email,
|
|
1497
|
+
alive: !!a.alive,
|
|
1498
|
+
...(a.readable === false ? { shareable: false } : {}),
|
|
1499
|
+
}))
|
|
1423
1500
|
.sort((a, b) => a.key.localeCompare(b.key));
|
|
1424
1501
|
}
|
|
1425
1502
|
|