aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/marmot-client.js69
-rw-r--r--src/marmot.js15
2 files changed, 67 insertions, 17 deletions
diff --git a/src/marmot-client.js b/src/marmot-client.js
index d63cb04..4a79b21 100644
--- a/src/marmot-client.js
+++ b/src/marmot-client.js
@@ -30,8 +30,8 @@ const REACH_TTL = 10 * 60_000;
const now = () => Math.floor(Date.now() / 1000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAYS, subClosed }) {
- const store = marmotStore(scope);
+// `store` is injectable for tests outside a browser (no IndexedDB there).
+export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAYS, subClosed, store = marmotStore(scope) }) {
const groups = new Map(); // mls group id (hex) -> record
const subs = new Map(); // route id -> { unsub, gid }
const reach = new Map(); // pubkey -> { at, p }
@@ -150,8 +150,11 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
return true;
}
- // Somebody else's KeyPackage, or null when they cannot be invited.
- function lookup(pk) {
+ // Somebody's KeyPackages: one per device (a slot is one installation), the
+ // best first, at most ten. An invitation adds every one of them, so a person
+ // with a phone and a laptop gets the group on both — the rule Marmot's
+ // multi-device design settles on. `lookup` is the best one, or null.
+ function lookupAll(pk) {
const hit = reach.get(pk);
if (hit && Date.now() - hit.at < REACH_TTL) return hit.p;
const p = (async () => {
@@ -159,11 +162,12 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
const newest = lists.sort((a, b) => b.created_at - a.created_at)[0];
const where = [...new Set([...(newest ? writeRelays(newest).slice(0, 5) : []), ...PROFILE_RELAYS])];
const evs = await queryOn(where, { kinds: [M.KIND.KEY_PACKAGE], authors: [pk] }, 2500);
- return M.pickKeyPackage(evs.map((e) => M.readKeyPackageEvent(e)).filter(Boolean));
- })().catch(() => null);
+ return M.pickKeyPackages(evs.map((e) => M.readKeyPackageEvent(e)).filter(Boolean));
+ })().catch(() => []);
reach.set(pk, { at: Date.now(), p });
return p;
}
+ const lookup = (pk) => lookupAll(pk).then((all) => all[0] || null);
// ---- relay traffic ----
@@ -250,8 +254,8 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
return p;
}
- async function deliverWelcome(g, welcome, cands) {
- const s = await signer({ interactive: true });
+ async function deliverWelcome(g, welcome, cands, { interactive = true } = {}) {
+ const s = await signer({ interactive });
if (!s) throw new Error('no signer');
const where = routeRelays(g);
for (const c of cands) {
@@ -264,10 +268,47 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
// ---- what the app can do ----
async function candidates(pks) {
- const cands = await Promise.all(pks.map(lookup));
- const missing = pks.filter((_, i) => !cands[i]);
+ const lists = await Promise.all(pks.map(lookupAll));
+ const missing = pks.filter((_, i) => !lists[i].length);
if (missing.length) { const e = new Error('not reachable'); e.missing = missing; throw e; }
- return cands;
+ return lists.flat();
+ }
+
+ // Our account's other devices. Each installation publishes its own
+ // KeyPackage under its own slot; where this account is an admin, a device
+ // that finds a sibling's fresh package not yet in a group adds it, and the
+ // Welcome reaches the sibling through our own inbox. The same-account rule
+ // of Marmot's multi-device direction, applied to the groups we already have
+ // (a non-admin cannot add anyone, so those groups wait for the spec).
+ let siblingTimer = 0, siblingBusy = false;
+ async function siblingAdds() {
+ if (stopped || !dev || siblingBusy) return;
+ siblingBusy = true;
+ try {
+ const s = await signer();
+ if (!s) return;
+ const mine = await lookupAll(pubkey);
+ const own = new Set((dev.kps || []).map((k) => k.ref));
+ const others = mine.filter((c) => c.slot !== dev.slot && !own.has(c.ref));
+ if (!others.length) return;
+ for (const g of [...groups.values()]) {
+ if (stopped || g.removed || g.leaving || g.pending || g.fresh || !M.isAdmin(g)) continue;
+ const have = new Set(M.leafKeys(g.tip));
+ const cands = others.filter((c) => !have.has(c.sigKey) && M.fits(g.tip, c));
+ if (!cands.length || M.leafKeys(g.tip).length + cands.length > 64) continue;
+ try {
+ const p = await commit(g, M.invite(cands));
+ await deliverWelcome(g, p.welcome, cands, { interactive: false });
+ dlog('marmot: sibling add', g.id.slice(0, 8), cands.length);
+ } catch (e) { dlog('marmot: sibling add', e); }
+ await sleep(1500);
+ }
+ } catch (e) { dlog('marmot: siblings', e); }
+ finally { siblingBusy = false; }
+ }
+ function scheduleSiblings(delay = 25_000) {
+ clearTimeout(siblingTimer);
+ siblingTimer = setTimeout(() => { siblingAdds().then(() => { if (!stopped) scheduleSiblings(6 * 3600_000); }); }, delay);
}
// A direct chat (no name, both sides admins) or a named group.
@@ -355,6 +396,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
const g = groups.get(j.g.id);
if (g && !stopped && !g.removed && !g.pending) commit(g, []).catch((e) => dlog('marmot: self-update', e));
}, 4000 + Math.random() * 6000);
+ scheduleSiblings(40_000);
return j.g;
}
@@ -378,6 +420,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
}
}
emit('loaded', null);
+ scheduleSiblings();
}
function resubscribe() {
@@ -390,14 +433,14 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
function stop() {
stopped = true;
rebuilding = true;
- clearTimeout(rotateTimer);
+ clearTimeout(rotateTimer); clearTimeout(siblingTimer);
for (const s of subs.values()) { try { s.unsub(); } catch {} }
subs.clear();
flush();
}
return {
- pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, welcome, accept,
+ pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept,
create, send, add, kick, rename, leave, forget,
event: (kind, content, tags) => M.appEvent(pubkey, kind, content, tags),
// attachments: seal for the epoch a message is about to go out in, and
diff --git a/src/marmot.js b/src/marmot.js
index 7dd55d6..f6153a0 100644
--- a/src/marmot.js
+++ b/src/marmot.js
@@ -382,6 +382,7 @@ export function readKeyPackageEvent(ev, at = now()) {
return {
id: ev.id, pubkey: ev.pubkey, slot: d, ref: i, created_at: ev.created_at, lastResort,
keyPackage: kp.keyPackage,
+ sigKey: bytesToHex(kp.leaf.signatureKey), // which device: the leaf it would occupy
components: idList.dec(comp(kp.leaf.extensions, COMP.APP_COMPONENTS)),
extensions: caps.extensions, proposals: caps.proposals,
client: ev.tags.find((t) => t[0] === "client")?.[1] || "",
@@ -389,17 +390,23 @@ export function readKeyPackageEvent(ev, at = now()) {
} catch { return null; }
}
-// One candidate per (author, slot) — the newest, lower id on a tie — then
-// single-use packages before last-resort ones, fresher first, lower ref last.
-export function pickKeyPackage(candidates) {
+// One candidate per (author, slot) — the newest, lower id on a tie: a slot
+// is one installation, so this is one package per device. Ordered single-use
+// before last-resort, fresher first, lower ref last; at most `max` of them
+// (Marmot's multi-device direction caps an account at ten leaves).
+export const MAX_DEVICES = 10;
+export function pickKeyPackages(candidates, max = MAX_DEVICES) {
const slots = new Map();
for (const c of candidates) {
const k = c.pubkey + c.slot, cur = slots.get(k);
if (!cur || c.created_at > cur.created_at || (c.created_at === cur.created_at && c.id < cur.id)) slots.set(k, c);
}
return [...slots.values()].sort((a, b) =>
- (a.lastResort - b.lastResort) || (b.created_at - a.created_at) || (a.ref < b.ref ? -1 : 1))[0] || null;
+ (a.lastResort - b.lastResort) || (b.created_at - a.created_at) || (a.ref < b.ref ? -1 : 1)).slice(0, max);
}
+export const pickKeyPackage = (candidates) => pickKeyPackages(candidates, 1)[0] || null;
+// the signature keys of every leaf in a state: which devices are already in
+export const leafKeys = (state) => mls.members(state).map((m) => bytesToHex(m.signatureKey));
// Can this KeyPackage join a group in this state?
export function fits(state, cand) {