@gryt/core 0.16.1 → 0.17.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.
@@ -155,7 +155,8 @@ export class FakeDeliveryService {
155
155
  if (conversationId === undefined) {
156
156
  const devices = this.devicesOf([me]).map((d) => {
157
157
  const at = this.deviceAddedAt.get(d.deviceId);
158
- return at ? { ...d, createdAt: at, lastSeenAt: at } : d;
158
+ const lastResort = [...this.keyPackages].reverse().find((k) => k.deviceId === d.deviceId && k.lastResort)?.data ?? null;
159
+ return { ...d, ...(at ? { createdAt: at, lastSeenAt: at } : {}), lastResortKeyPackage: lastResort };
159
160
  });
160
161
  return { ok: true, devices };
161
162
  }
@@ -263,6 +264,15 @@ export class FakeDeliveryService {
263
264
  this.welcomes = this.welcomes.filter((w) => !(w.serverUserId === me && w.deviceId === deviceId && welcomeIds.includes(w.welcomeId)));
264
265
  return { ok: true, deleted: before - this.welcomes.length };
265
266
  },
267
+ requestAdd: async ({ conversationId, deviceId }) => {
268
+ await tick();
269
+ if (!member(conversationId))
270
+ return fail("not_found", "No such conversation.");
271
+ if (!this.isDevice(me, deviceId))
272
+ return fail("unknown_device", "Publish KeyPackages from this device first.");
273
+ this.push(this.conversations.get(conversationId), (d) => d.handleAddRequest({ conversationId, serverUserId: me, deviceId }));
274
+ return { ok: true };
275
+ },
266
276
  };
267
277
  // socket.io-parser closes the connection on a packet with more than ten binary parts.
268
278
  const guarded = {};
@@ -295,8 +305,13 @@ export class FakeDeliveryService {
295
305
  return guarded;
296
306
  }
297
307
  checkParts(socket, event, value) {
298
- const count = (v) => v instanceof Uint8Array ? 1 : Array.isArray(v) ? v.reduce((n, x) => n + count(x), 0)
299
- : v && typeof v === "object" ? Object.values(v).reduce((n, x) => n + count(x), 0) : 0;
308
+ const count = (v) => {
309
+ if (v instanceof Uint8Array)
310
+ return 1;
311
+ if (v && typeof v === "object")
312
+ return Object.values(v).reduce((n, x) => n + count(x), 0);
313
+ return 0;
314
+ };
300
315
  const parts = count(value);
301
316
  this.stats.mostBinaryParts = Math.max(this.stats.mostBinaryParts, parts);
302
317
  if (parts > 10) {
@@ -9,6 +9,10 @@ const PUBLISH_BATCH = MAX_BINARY_PARTS - 1;
9
9
  /** The server's cap on files in one message. */
10
10
  const MAX_ATTACHMENT_IDS = 10;
11
11
  const WELCOME_WAIT_MS = 10_000;
12
+ /** How often a device out of a group asks to be added, unless the person asks. */
13
+ const ASK_AGAIN_MS = 60_000;
14
+ /** A message can beat the Welcome that comes with it, so a missing group is only news after this. */
15
+ const NOT_ADDED_GRACE_MS = 3_000;
12
16
  /** The same cap as a message that isn't MLS: after this a send fails and the text goes back. */
13
17
  const SEND_GIVE_UP_MS = 5 * 60_000;
14
18
  /** A send with no answer goes again after this, or sooner if the connection comes back. */
@@ -85,6 +89,7 @@ export function createMlsDmDriver(options) {
85
89
  };
86
90
  }
87
91
  const raw = options.transport;
92
+ const rawRequestAdd = raw.requestAdd?.bind(raw);
88
93
  const transport = {
89
94
  publishKeyPackages: stopOnRemoval((req) => raw.publishKeyPackages(req)),
90
95
  claimKeyPackages: stopOnRemoval((req) => raw.claimKeyPackages(req)),
@@ -96,6 +101,7 @@ export function createMlsDmDriver(options) {
96
101
  fetchLog: stopOnRemoval((req) => raw.fetchLog(req)),
97
102
  sync: stopOnRemoval((req) => raw.sync(req)),
98
103
  ackWelcomes: stopOnRemoval((req) => raw.ackWelcomes(req)),
104
+ ...(rawRequestAdd ? { requestAdd: stopOnRemoval(rawRequestAdd) } : {}),
99
105
  };
100
106
  const scope = asIdentityScope(options.scope);
101
107
  const trusts = new Map();
@@ -116,6 +122,11 @@ export function createMlsDmDriver(options) {
116
122
  const reconciled = new Set();
117
123
  const seen = new Set();
118
124
  const welcomeWaiters = new Map();
125
+ /** Conversations with a Welcome for this device that it couldn't join yet, most likely an unpinned member. */
126
+ const unjoinable = new Set();
127
+ const asked = new Map();
128
+ /** Groups this device was taken out of here. It doesn't ask its way back into those. */
129
+ const left = new Set();
119
130
  const flights = new Set();
120
131
  let waitingForServer = false;
121
132
  let stopped = false;
@@ -269,6 +280,7 @@ export function createMlsDmDriver(options) {
269
280
  const r = await processMlsMessage(open.state, e.data);
270
281
  if (r.kind === "handshake" && r.removed) {
271
282
  await store.deleteGroup(open.rec.conversationId);
283
+ left.add(open.rec.conversationId);
272
284
  events.onGroupLost?.({ conversationId: open.rec.conversationId, reason: "removed" });
273
285
  return false;
274
286
  }
@@ -514,12 +526,38 @@ export function createMlsDmDriver(options) {
514
526
  welcomeWaiters.set(conversationId, [...(welcomeWaiters.get(conversationId) ?? []), done]);
515
527
  });
516
528
  }
529
+ /* Only a member can add a device, so one that's out asks the members' apps to. An old
530
+ server doesn't answer, which is why nothing waits on it. */
531
+ async function askToBeAdded(conversationId, force = false) {
532
+ events.onGroupLost?.({ conversationId, reason: "not_added" });
533
+ if (!transport.requestAdd || (!force && Date.now() - (asked.get(conversationId) ?? 0) < ASK_AGAIN_MS))
534
+ return;
535
+ asked.set(conversationId, Date.now());
536
+ const r = await transport.requestAdd({ conversationId, deviceId: (await ownDevice()).deviceId });
537
+ if (!r.ok)
538
+ throw refused("Asking to be added", r);
539
+ }
540
+ function notAddedLater(conversationId) {
541
+ setTimeout(() => {
542
+ if (stopped || left.has(conversationId) || unjoinable.has(conversationId))
543
+ return;
544
+ void store
545
+ .loadGroup(conversationId)
546
+ .then((rec) => (rec ? undefined : askToBeAdded(conversationId)))
547
+ .catch(() => undefined);
548
+ }, NOT_ADDED_GRACE_MS);
549
+ }
517
550
  async function ensureOpen(conversationId) {
518
551
  if ((await run(conversationId, () => openGroup(conversationId))) === "open")
519
552
  return;
520
553
  await takeWaitingWelcomes();
521
- if (!(await store.loadGroup(conversationId)))
522
- await waitForWelcome(conversationId);
554
+ if (await store.loadGroup(conversationId))
555
+ return;
556
+ if (unjoinable.has(conversationId)) {
557
+ throw new MlsDriverError("peer_unverified", "This device was added, and a member's key isn't pinned here to join with.");
558
+ }
559
+ void askToBeAdded(conversationId).catch(() => undefined);
560
+ await waitForWelcome(conversationId);
523
561
  }
524
562
  /** "joined", "not_ours" (ack it and forget it), or "retry" (keep it for next time). */
525
563
  async function joinFrom(w) {
@@ -569,12 +607,16 @@ export function createMlsDmDriver(options) {
569
607
  return "retry";
570
608
  const conversationId = w.conversationId;
571
609
  const { result, usedLastResort } = await run(conversationId, () => joinFrom(w));
572
- if (result === "retry")
610
+ if (result === "retry") {
611
+ unjoinable.add(conversationId);
573
612
  return "retry";
613
+ }
574
614
  await transport.ackWelcomes({ deviceId: d.deviceId, welcomeIds: [w.welcomeId] });
575
615
  if (result !== "joined")
576
616
  return "done";
577
617
  reconciled.delete(conversationId);
618
+ unjoinable.delete(conversationId);
619
+ left.delete(conversationId);
578
620
  events.onJoined?.({ conversationId, groupId: w.groupId });
579
621
  for (const done of welcomeWaiters.get(conversationId) ?? [])
580
622
  done();
@@ -702,7 +744,13 @@ export function createMlsDmDriver(options) {
702
744
  async function handleMessage(entry) {
703
745
  await run(entry.conversationId, async () => {
704
746
  const open = await load(entry.conversationId);
705
- if (!open || entry.seq <= open.rec.cursor)
747
+ if (!open) {
748
+ // A message for a group this device isn't in: without this it would show nothing.
749
+ if (entry.kind === "application" && entry.senderDeviceId !== (await ownDevice()).deviceId)
750
+ notAddedLater(entry.conversationId);
751
+ return;
752
+ }
753
+ if (entry.seq <= open.rec.cursor)
706
754
  return;
707
755
  if (entry.seq === open.rec.cursor + 1 && entry.groupId === open.rec.groupId)
708
756
  await applyEntry(open, entry);
@@ -730,8 +778,24 @@ export function createMlsDmDriver(options) {
730
778
  });
731
779
  }
732
780
  }
733
- /** Names from certificates under your own person key: this device's, then your leaves in each group. */
734
- async function ownDeviceNames(d, wanted) {
781
+ /** Adds whatever is missing, as a send would. Who asked only says which group to look at. */
782
+ async function handleAddRequest(push) {
783
+ if (push.deviceId === (await ownDevice()).deviceId)
784
+ return;
785
+ await run(push.conversationId, async () => {
786
+ const open = await load(push.conversationId);
787
+ if (!open || !(await catchUp(open)))
788
+ return;
789
+ const peer = (await peopleIn(push.conversationId, open.state)).find((p) => !!p && p !== self);
790
+ if (!peer || (push.serverUserId !== self && push.serverUserId !== peer))
791
+ return;
792
+ await reconcile(open, peer, true);
793
+ });
794
+ }
795
+ /** Names from certificates under your own person key: this device's, your leaves in each group,
796
+ then the last-resort KeyPackage the server lists with each of your devices. */
797
+ async function ownDeviceNames(d, listed) {
798
+ const wanted = new Set(listed.map((x) => x.deviceId));
735
799
  const mine = readDeviceCertificate(d.certificate, scope);
736
800
  const names = new Map([[mine.deviceId, mine.deviceName]]);
737
801
  const unnamed = () => [...wanted].some((id) => !names.has(id));
@@ -751,6 +815,20 @@ export function createMlsDmDriver(options) {
751
815
  }
752
816
  }
753
817
  }
818
+ // Checked here, not trusted: a package that doesn't verify, or isn't under your person key, names nothing.
819
+ for (const x of listed) {
820
+ if (names.has(x.deviceId) || !x.lastResortKeyPackage)
821
+ continue;
822
+ try {
823
+ const bytes = x.lastResortKeyPackage instanceof Uint8Array ? x.lastResortKeyPackage : new Uint8Array(x.lastResortKeyPackage);
824
+ const { certificate: c } = await readMlsKeyPackage(bytes, scope);
825
+ if (c.deviceId === x.deviceId && sameBytes(c.personPublicKey, mine.personPublicKey))
826
+ names.set(x.deviceId, c.deviceName);
827
+ }
828
+ catch {
829
+ // Expired or malformed: the device stays unnamed rather than misnamed.
830
+ }
831
+ }
754
832
  return names;
755
833
  }
756
834
  async function ownDevices() {
@@ -759,7 +837,7 @@ export function createMlsDmDriver(options) {
759
837
  throw refused("Listing devices", r);
760
838
  const d = await ownDevice();
761
839
  const listed = r.devices.filter((x) => x.serverUserId === self);
762
- const names = await ownDeviceNames(d, new Set(listed.map((x) => x.deviceId)));
840
+ const names = await ownDeviceNames(d, listed);
763
841
  return listed.map((x) => ({
764
842
  serverUserId: x.serverUserId,
765
843
  deviceId: x.deviceId,
@@ -905,6 +983,8 @@ export function createMlsDmDriver(options) {
905
983
  handleMessage,
906
984
  handleWelcome,
907
985
  handleDevicesChanged,
986
+ handleAddRequest,
987
+ requestAdd: (conversationId) => askToBeAdded(conversationId, true),
908
988
  ownDevices,
909
989
  async removeOwnDevice(deviceId) {
910
990
  const r = await transport.removeDevice({ deviceId });
@@ -17,6 +17,8 @@ export interface MlsDeviceRef {
17
17
  export interface MlsListedDevice extends MlsDeviceRef {
18
18
  createdAt?: string;
19
19
  lastSeenAt?: string;
20
+ /** On your own list: the device's last-resort KeyPackage, whose certificate names it (GRYT-1764). */
21
+ lastResortKeyPackage?: Uint8Array | ArrayBuffer | null;
20
22
  }
21
23
  /** The server's view of a group. `groupId` is lower-case hex. */
22
24
  export interface MlsGroupView {
@@ -138,6 +140,12 @@ export interface MlsTransport {
138
140
  }): Promise<MlsReply<{
139
141
  deleted: number;
140
142
  }>>;
143
+ /** This device isn't in that conversation's group: the server asks the members' apps to add it.
144
+ Optional, since a server from before GRYT-1858 doesn't take it. */
145
+ requestAdd?(req: {
146
+ conversationId: string;
147
+ deviceId: string;
148
+ }): Promise<MlsReply<object>>;
141
149
  }
142
150
  /** `server:info.mls`. Absent means the server can't do MLS. */
143
151
  export interface MlsServerCapability {
@@ -235,7 +243,7 @@ export interface MlsDmEvents {
235
243
  /** This device is out of the group: removed, or its state couldn't keep up. */
236
244
  onGroupLost?(info: {
237
245
  conversationId: string;
238
- reason: "removed" | "out_of_sync" | "gap";
246
+ reason: "removed" | "out_of_sync" | "gap" | "not_added";
239
247
  }): void;
240
248
  onJoined?(info: {
241
249
  conversationId: string;
@@ -333,6 +341,14 @@ export interface MlsDmDriver {
333
341
  handleDevicesChanged(push: {
334
342
  serverUserId: string;
335
343
  }): Promise<void>;
344
+ /** `mls:add:requested`: a device of somebody in the conversation asked to be added. */
345
+ handleAddRequest(push: {
346
+ conversationId: string;
347
+ serverUserId: string;
348
+ deviceId: string;
349
+ }): Promise<void>;
350
+ /** Asks the other members' apps to add this device, as the driver does when it finds it's out. */
351
+ requestAdd(conversationId: string): Promise<void>;
336
352
  /** Oldest first, as the server lists them. */
337
353
  ownDevices(): Promise<MlsOwnDevice[]>;
338
354
  /** Your own devices only. Every group you share drops it on the next pass. */
@@ -54,6 +54,12 @@ export function createApproverPairing(options) {
54
54
  await closeQuietly();
55
55
  };
56
56
  const fail = (e) => (abort.signal.aborted ? undefined : end(reasonFor(e)));
57
+ // Only reached past "confirming", which claim() enters with all three set.
58
+ const confirmed = () => {
59
+ if (!current?.session || !device)
60
+ throw new Error("Nothing confirmed yet.");
61
+ return { ...current, session: current.session, device };
62
+ };
57
63
  async function claim(input) {
58
64
  let by;
59
65
  let scanned;
@@ -122,7 +128,7 @@ export function createApproverPairing(options) {
122
128
  const res = await fetch(`${issuer.replace(/\/+$/, "")}/gryt-pairing/approve`, {
123
129
  method: "POST",
124
130
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
125
- body: JSON.stringify({ user_code: userCode, binding: current.session.keycloakNonce }),
131
+ body: JSON.stringify({ user_code: userCode, binding: confirmed().session.keycloakNonce }),
126
132
  }).catch(() => {
127
133
  throw new PairingEnded("approve:network");
128
134
  });
@@ -143,7 +149,7 @@ export function createApproverPairing(options) {
143
149
  for (let tries = 0;; tries++) {
144
150
  try {
145
151
  added[host] = await driver.addOwnDevice(deviceId, {
146
- onProgress: ({ done, total }) => set({ phase: "adding", device: device, host, done, total }),
152
+ onProgress: ({ done, total }) => set({ phase: "adding", device: confirmed().device, host, done, total }),
147
153
  });
148
154
  break;
149
155
  }
@@ -168,7 +174,7 @@ export function createApproverPairing(options) {
168
174
  return snap;
169
175
  }
170
176
  function startHistory(archive) {
171
- const { id, token, session } = current;
177
+ const { id, token, session } = confirmed();
172
178
  return createHistorySender({
173
179
  relay, id, token, archive, clock, signal: abort.signal,
174
180
  post: (message) => relay.post(id, token, { type: "sealed", body: sealJson(session, message) }),
@@ -176,19 +182,20 @@ export function createApproverPairing(options) {
176
182
  });
177
183
  }
178
184
  async function approve(envelope, accessToken) {
179
- const { id, token, box, session } = current;
180
- set({ phase: envelope.account ? "signing_in" : "waiting_ready", device: device });
185
+ const { id, token, box, session, device } = confirmed();
186
+ set({ phase: envelope.account ? "signing_in" : "waiting_ready", device });
181
187
  let sender = null;
182
188
  let snap = new Map();
189
+ let sealed = envelope;
183
190
  if (options.history) {
184
191
  // Recording starts before the positions are read, so nothing lands between the two unseen.
185
192
  tail.start();
186
193
  snap = await positions(envelope);
187
194
  sender = startHistory(options.history);
188
195
  const chunks = await sender.forEnvelope(Math.min(clock.now() + ENVELOPE_BUDGET_MS, deadline - 10_000));
189
- envelope = { ...envelope, history: { key: sender.key, manifest: { v: 1, chunks } } };
196
+ sealed = { ...envelope, history: { key: sender.key, manifest: { v: 1, chunks } } };
190
197
  }
191
- await relay.post(id, token, { type: "sealed", body: base64Url(session.seal(encodePairingEnvelope(envelope))) });
198
+ await relay.post(id, token, { type: "sealed", body: base64Url(session.seal(encodePairingEnvelope(sealed))) });
192
199
  if (envelope.account && accessToken) {
193
200
  const message = await box.next(abort.signal, clock.now() + approvalMs);
194
201
  if (!message)
@@ -196,27 +203,27 @@ export function createApproverPairing(options) {
196
203
  const userCode = String(openJson(session, message, "user_code").userCode ?? "");
197
204
  if (!(await approveExtension(envelope.account.issuer, userCode, accessToken))) {
198
205
  const url = `${envelope.account.issuer.replace(/\/+$/, "")}/device?user_code=${encodeURIComponent(userCode)}`;
199
- set({ phase: "browser", device: device, url });
206
+ set({ phase: "browser", device, url });
200
207
  }
201
208
  }
202
209
  if (state.phase === "signing_in")
203
- set({ phase: "waiting_ready", device: device });
210
+ set({ phase: "waiting_ready", device });
204
211
  const ready = openJson(session, await box.next(abort.signal), "ready");
205
212
  const list = Array.isArray(ready.devices) ? ready.devices : [];
206
213
  const named = list.filter((d) => typeof d?.host === "string" && typeof d?.deviceId === "string");
207
214
  sender?.startPaging();
208
215
  const added = await addEverywhere(named);
209
216
  if (!sender) {
210
- set({ phase: "done", device: device, added });
217
+ set({ phase: "done", device, added });
211
218
  session.close();
212
219
  return void (await closeQuietly());
213
220
  }
214
- set({ phase: "sending", device: device, added });
221
+ set({ phase: "sending", device, added });
215
222
  await sender.send(tail.take(snap, added), false);
216
223
  await clock.sleep(options.lateWindowMs ?? HISTORY_LATE_WINDOW_MS, abort.signal);
217
224
  await sender.send(tail.take(snap, added), true);
218
225
  // N closes the session once it has every chunk; closing here would delete them.
219
- set({ phase: "done", device: device, added });
226
+ set({ phase: "done", device, added });
220
227
  session.close();
221
228
  }
222
229
  return {
@@ -8,7 +8,7 @@ export declare class PairingEnded extends Error {
8
8
  }
9
9
  export declare const systemClock: PairingClock;
10
10
  export declare function reasonFor(error: unknown): PairingEndReason;
11
- export declare function sealJson(session: PairingSession, value: Record<string, unknown>): string;
11
+ export declare function sealJson(session: PairingSession, value: object): string;
12
12
  /** The next sealed message as JSON with a `t`, or a throw that ends the pairing as tampered. */
13
13
  export declare function openJson(session: PairingSession, message: RelayMessage | null, t?: string): Record<string, unknown>;
14
14
  /** The other side's messages in order, long-polling the relay. `next` gives null once `deadline` passes. */
@@ -66,8 +66,10 @@ export function mailbox(relay, id, token, clock) {
66
66
  return null;
67
67
  try {
68
68
  for (const m of await relay.poll(id, token, after, Math.min(25, Math.ceil(left / 1000)), signal)) {
69
- if (m.seq > after)
70
- (queue.push(m), (after = m.seq));
69
+ if (m.seq > after) {
70
+ queue.push(m);
71
+ after = m.seq;
72
+ }
71
73
  }
72
74
  failures = 0;
73
75
  }
@@ -130,7 +130,7 @@ export function createHistorySender(options) {
130
130
  const listed = unlisted.splice(0, HISTORY_ENVELOPE_ENTRIES);
131
131
  progress.listed += listed.length;
132
132
  report();
133
- return listed.map(({ n: _n, ...entry }) => entry);
133
+ return listed.map(({ n, ...entry }) => entry);
134
134
  },
135
135
  /** After N's "ready": list what's waiting, and keep listing as pages fill. */
136
136
  startPaging() {
@@ -2,7 +2,11 @@ const DAY_MS = 24 * 60 * 60 * 1000;
2
2
  export const dayOf = (sentAt) => Math.floor(sentAt / DAY_MS);
3
3
  /** Negative when `a` is newer: by time, then by message id, the same order the archive pages in. */
4
4
  export function newerFirst(a, b) {
5
- return b.sentAt - a.sentAt || (a.messageId < b.messageId ? 1 : a.messageId > b.messageId ? -1 : 0);
5
+ if (a.sentAt !== b.sentAt)
6
+ return b.sentAt - a.sentAt;
7
+ if (a.messageId === b.messageId)
8
+ return 0;
9
+ return a.messageId < b.messageId ? 1 : -1;
6
10
  }
7
11
  /** A's archive one UTC day at a time, newest day first across every conversation and server. */
8
12
  export async function* archiveByDay(archive, pageSize = 500) {
@@ -90,8 +90,14 @@ export function createNewDevicePairing(options) {
90
90
  throw new PairingEnded(e instanceof Error && /newer version/.test(e.message) ? "newer_version" : "tampered");
91
91
  }
92
92
  }
93
+ // attempt() sets both before signIn, run's history step or linked can run.
94
+ const paired = () => {
95
+ if (!current?.session)
96
+ throw new Error("No pairing session yet.");
97
+ return { ...current, session: current.session };
98
+ };
93
99
  async function signIn(account, from) {
94
- const { id, token, box, session } = current;
100
+ const { id, token, box, session } = paired();
95
101
  set({ phase: "signing_in", username: account.username, from });
96
102
  const verifier = base64Url(randomBytes(32));
97
103
  const auth = await oidc.deviceAuthorization({
@@ -151,7 +157,7 @@ export function createNewDevicePairing(options) {
151
157
  const tokens = envelope.account ? await signIn(envelope.account, envelope.from) : null;
152
158
  await storage.commit(envelope, tokens);
153
159
  if (envelope.history && options.history) {
154
- const { id, token } = current;
160
+ const { id, token } = paired();
155
161
  receiver = createHistoryReceiver({
156
162
  relay, id, token, key: envelope.history.key, sink: options.history, clock, signal: abort.signal, onProgress: setHistory,
157
163
  });
@@ -166,7 +172,7 @@ export function createNewDevicePairing(options) {
166
172
  }
167
173
  /** Reads A's history messages until every chunk is in, or A closing is the finish. */
168
174
  async function linked(from) {
169
- const { box, session } = current;
175
+ const { box, session } = paired();
170
176
  const stop = new AbortController();
171
177
  const onAbort = () => stop.abort();
172
178
  abort.signal.addEventListener("abort", onAbort, { once: true });
@@ -45,7 +45,10 @@ export interface Report {
45
45
  };
46
46
  /** The tail of the app's own log, where it keeps one. */
47
47
  logs?: string[];
48
- extra?: Record<string, unknown>;
48
+ extra?: {
49
+ embeddedServer?: boolean;
50
+ embeddedServerVersion?: string;
51
+ };
49
52
  }
50
53
  /**
51
54
  * What an app knows about itself when somebody opens the form. Passed in rather than read
@@ -120,11 +120,7 @@ export function describeAttached(report) {
120
120
  ? `v${report.app.version}${report.app.build ? ` (${report.app.build})` : ""}`
121
121
  : undefined);
122
122
  add("Channel", report.app?.channel);
123
- add("System", device?.platform && device?.osVersion
124
- ? `${platformLabel(device.platform)} ${device.osVersion}`
125
- : device?.platform
126
- ? platformLabel(device.platform)
127
- : undefined);
123
+ add("System", device?.platform ? [platformLabel(device.platform), device.osVersion].filter(Boolean).join(" ") : undefined);
128
124
  add("Simulator", device?.isEmulator ? "yes" : undefined);
129
125
  add("Electron", report.runtime?.electronVersion);
130
126
  add("Chrome", report.runtime?.chromeVersion);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Gryt's shared application logic: one implementation of the things the desktop and mobile apps both do.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -24,16 +24,20 @@
24
24
  "dist"
25
25
  ],
26
26
  "scripts": {
27
- "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node scripts/add-esm-extensions.mjs",
28
- "typecheck": "tsc -p tsconfig.json --noEmit",
29
- "test:comment-length": "node scripts/check-comment-length.mjs",
27
+ "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node scripts/add-esm-extensions.ts",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p scripts",
29
+ "lint": "eslint --flag unstable_native_nodejs_ts_config .",
30
+ "test:comment-length": "node scripts/check-comment-length.ts",
30
31
  "test": "node --test \"src/**/*.test.ts\"",
31
- "check-surface": "node scripts/check-public-surface.mjs",
32
+ "check-surface": "node scripts/check-public-surface.ts",
32
33
  "prepublishOnly": "npm run build && npm run test && npm run check-surface"
33
34
  },
34
35
  "devDependencies": {
36
+ "@eslint/js": "^9.39.5",
35
37
  "@types/node": "^20.19.33",
36
- "typescript": "^5.9.3"
38
+ "eslint": "^9.39.5",
39
+ "typescript": "^5.9.3",
40
+ "typescript-eslint": "^8.71.2"
37
41
  },
38
42
  "publishConfig": {
39
43
  "access": "public"