@comapeo/core 3.0.0 → 3.1.1

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,20 +1,47 @@
1
1
  import { TypedEmitter } from 'tiny-typed-emitter'
2
2
  import Protomux from 'protomux'
3
+ import timingSafeEqual from 'string-timing-safe-equal'
3
4
  import { assert, ExhaustivenessError, keyToId, noop } from './utils.js'
4
5
  import { isBlank } from './lib/string.js'
5
6
  import cenc from 'compact-encoding'
6
7
  import {
7
8
  DeviceInfo,
8
9
  Invite,
10
+ InviteAck,
9
11
  InviteCancel,
12
+ InviteCancelAck,
10
13
  InviteResponse,
14
+ InviteResponseAck,
11
15
  ProjectJoinDetails,
16
+ ProjectJoinDetailsAck,
17
+ DeviceInfo_RPCFeatures,
12
18
  } from './generated/rpc.js'
13
19
  import pDefer from 'p-defer'
14
20
  import { Logger } from './logger.js'
15
21
  import pTimeout, { TimeoutError } from 'p-timeout'
22
+ import {
23
+ RPCDisconnectBeforeAckError,
24
+ RPCDisconnectBeforeSendingError,
25
+ } from './errors.js'
16
26
  /** @import NoiseStream from '@hyperswarm/secret-stream' */
17
27
  /** @import { OpenedNoiseStream } from './lib/noise-secret-stream-helpers.js' */
28
+ /** @import {DeferredPromise} from 'p-defer' */
29
+
30
+ /**
31
+ * @typedef {InviteAck|InviteCancelAck|InviteResponseAck|ProjectJoinDetailsAck} AckResponse
32
+ */
33
+
34
+ /**
35
+ * @callback AckFilter
36
+ * @param {AckResponse} ack
37
+ * @returns {boolean}
38
+ */
39
+
40
+ /**
41
+ * @typedef {object} AckWaiter
42
+ * @property {AckFilter} filter
43
+ * @property {DeferredPromise<void>} deferred
44
+ */
18
45
 
19
46
  // Unique identifier for the mapeo rpc protocol
20
47
  const PROTOCOL_NAME = 'mapeo/rpc'
@@ -33,6 +60,10 @@ const MESSAGE_TYPES = {
33
60
  InviteResponse: 2,
34
61
  ProjectJoinDetails: 3,
35
62
  DeviceInfo: 4,
63
+ InviteAck: 5,
64
+ InviteCancelAck: 6,
65
+ InviteResponseAck: 7,
66
+ ProjectJoinDetailsAck: 8,
36
67
  }
37
68
  const MESSAGES_MAX_ID = Math.max.apply(null, [...Object.values(MESSAGE_TYPES)])
38
69
 
@@ -62,9 +93,14 @@ class Peer {
62
93
  #name
63
94
  /** @type {DeviceInfo['deviceType']} */
64
95
  #deviceType
96
+ /** @type {DeviceInfo['features']} */
97
+ #features = []
65
98
  #connectedAt = 0
66
99
  #disconnectedAt = 0
67
100
  #drainedListeners = new Set()
101
+ // Map of type -> Set<{filter, deferred}>
102
+ /** @type Map<keyof typeof MESSAGE_TYPES, Set<AckWaiter>>*/
103
+ #ackWaiters = new Map()
68
104
  #protomux
69
105
  #log
70
106
 
@@ -159,8 +195,14 @@ class Peer {
159
195
  // This promise should have already resolved, but if the peer never connected then we reject here
160
196
  this.#connected.reject(new PeerFailedConnectionError())
161
197
  for (const listener of this.#drainedListeners) {
162
- listener.reject(new Error('RPC Disconnected before sending'))
198
+ listener.reject(new RPCDisconnectBeforeSendingError())
163
199
  }
200
+ for (const waiters of this.#ackWaiters.values()) {
201
+ for (const { deferred } of waiters) {
202
+ deferred.reject(new RPCDisconnectBeforeAckError())
203
+ }
204
+ }
205
+ this.#ackWaiters.clear()
164
206
  this.#drainedListeners.clear()
165
207
  this.#log('disconnected')
166
208
  }
@@ -186,6 +228,53 @@ class Peer {
186
228
  await onDrain.promise
187
229
  }
188
230
 
231
+ /**
232
+ * Check if RPC Acknowledgement messages are supported by this peer
233
+ * @returns {boolean}
234
+ */
235
+ supportsAck() {
236
+ return this.#features.includes(DeviceInfo_RPCFeatures.ack) ?? false
237
+ }
238
+
239
+ /**
240
+ * @param {keyof typeof MESSAGE_TYPES} type
241
+ * @param {AckFilter} filter
242
+ * @returns {Promise<void>}
243
+ */
244
+ async #waitForAck(type, filter) {
245
+ if (!this.supportsAck()) return
246
+ if (!this.#ackWaiters.has(type)) {
247
+ this.#ackWaiters.set(type, new Set())
248
+ }
249
+ const deferred = pDefer()
250
+ this.#ackWaiters.get(type)?.add({
251
+ deferred,
252
+ filter,
253
+ })
254
+
255
+ await deferred.promise
256
+ }
257
+
258
+ /**
259
+ * @param {keyof typeof MESSAGE_TYPES} type
260
+ * @param {AckResponse} ack
261
+ */
262
+ receiveAck(type, ack) {
263
+ if (!this.supportsAck()) return
264
+ if (!this.#ackWaiters.has(type)) return
265
+ const waiters = this.#ackWaiters.get(type)
266
+ if (!waiters || !waiters.size) {
267
+ return
268
+ }
269
+
270
+ for (const waiter of waiters) {
271
+ if (waiter.filter(ack)) {
272
+ waiter.deferred.resolve()
273
+ waiters.delete(waiter)
274
+ }
275
+ }
276
+ }
277
+
189
278
  /**
190
279
  * @param {Buffer} buf
191
280
  * @returns {Promise<void>}
@@ -204,8 +293,24 @@ class Peer {
204
293
  const buf = Buffer.from(Invite.encode(invite).finish())
205
294
  const messageType = MESSAGE_TYPES.Invite
206
295
  await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
296
+ await this.#waitForAck('InviteAck', ({ inviteId }) =>
297
+ timingSafeEqual(inviteId, invite.inviteId)
298
+ )
207
299
  this.#log('sent invite %h', invite.inviteId)
208
300
  }
301
+
302
+ /**
303
+ * @param {Invite} invite
304
+ * @returns {Promise<void>}
305
+ */
306
+ async sendInviteAck({ inviteId }) {
307
+ this.#assertConnected('Peer disconnected before sending invite ack')
308
+ if (!this.supportsAck()) return
309
+ const buf = Buffer.from(InviteAck.encode({ inviteId }).finish())
310
+ const messageType = MESSAGE_TYPES.InviteAck
311
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
312
+ }
313
+
209
314
  /**
210
315
  * @param {InviteCancel} inviteCancel
211
316
  * @returns {Promise<void>}
@@ -215,8 +320,24 @@ class Peer {
215
320
  const buf = Buffer.from(InviteCancel.encode(inviteCancel).finish())
216
321
  const messageType = MESSAGE_TYPES.InviteCancel
217
322
  await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
323
+ await this.#waitForAck('InviteCancelAck', ({ inviteId }) =>
324
+ timingSafeEqual(inviteId, inviteCancel.inviteId)
325
+ )
218
326
  this.#log('sent invite cancel %h', inviteCancel.inviteId)
219
327
  }
328
+
329
+ /**
330
+ * @param {InviteCancel} inviteCancel
331
+ * @returns {Promise<void>}
332
+ */
333
+ async sendInviteCancelAck({ inviteId }) {
334
+ this.#assertConnected('Peer disconnected before sending invite cancel ack')
335
+ if (!this.supportsAck()) return
336
+ const buf = Buffer.from(InviteCancelAck.encode({ inviteId }).finish())
337
+ const messageType = MESSAGE_TYPES.InviteCancelAck
338
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
339
+ }
340
+
220
341
  /**
221
342
  * @param {InviteResponse} response
222
343
  * @returns {Promise<void>}
@@ -226,8 +347,26 @@ class Peer {
226
347
  const buf = Buffer.from(InviteResponse.encode(response).finish())
227
348
  const messageType = MESSAGE_TYPES.InviteResponse
228
349
  await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
350
+ await this.#waitForAck('InviteResponseAck', ({ inviteId }) =>
351
+ timingSafeEqual(inviteId, response.inviteId)
352
+ )
229
353
  this.#log('sent response for %h: %s', response.inviteId, response.decision)
230
354
  }
355
+
356
+ /**
357
+ * @param {InviteResponse} response
358
+ * @returns {Promise<void>}
359
+ */
360
+ async sendInviteResponseAck({ inviteId }) {
361
+ this.#assertConnected(
362
+ 'Peer disconnected before sending invite response ack'
363
+ )
364
+ if (!this.supportsAck()) return
365
+ const buf = Buffer.from(InviteResponseAck.encode({ inviteId }).finish())
366
+ const messageType = MESSAGE_TYPES.InviteResponseAck
367
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
368
+ }
369
+
231
370
  /** @param {ProjectJoinDetails} details */
232
371
  async sendProjectJoinDetails(details) {
233
372
  this.#assertConnected(
@@ -236,8 +375,22 @@ class Peer {
236
375
  const buf = Buffer.from(ProjectJoinDetails.encode(details).finish())
237
376
  const messageType = MESSAGE_TYPES.ProjectJoinDetails
238
377
  await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
378
+ await this.#waitForAck('ProjectJoinDetailsAck', ({ inviteId }) =>
379
+ timingSafeEqual(inviteId, details.inviteId)
380
+ )
239
381
  this.#log('sent project join details for %h', details.projectKey)
240
382
  }
383
+ /** @param {ProjectJoinDetails} details */
384
+ async sendProjectJoinDetailsAck({ inviteId }) {
385
+ this.#assertConnected(
386
+ 'Peer disconnected before sending project join details ack'
387
+ )
388
+ if (!this.supportsAck()) return
389
+ const buf = Buffer.from(ProjectJoinDetailsAck.encode({ inviteId }).finish())
390
+ const messageType = MESSAGE_TYPES.ProjectJoinDetailsAck
391
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
392
+ }
393
+
241
394
  /**
242
395
  * @param {DeviceInfo} deviceInfo
243
396
  * @returns {Promise<void>}
@@ -252,6 +405,7 @@ class Peer {
252
405
  receiveDeviceInfo(deviceInfo) {
253
406
  this.#name = deviceInfo.name
254
407
  this.#deviceType = deviceInfo.deviceType
408
+ this.#features = deviceInfo.features
255
409
  this.#log('received deviceInfo %o', deviceInfo)
256
410
  }
257
411
  /** @param {string} [message] */
@@ -267,9 +421,13 @@ class Peer {
267
421
  * @property {(peers: PeerInfo[]) => void} peers Emitted whenever the connection status of peers changes. An array of peerInfo objects with a peer id and the peer connection status
268
422
  * @property {(peer: PeerInfoConnected) => void} peer-add Emitted when a new peer is connected
269
423
  * @property {(peerId: string, invite: Invite) => void} invite Emitted when an invite is received
424
+ * @property {(peerId: string, invite: InviteAck) => void} invite-ack Emitted when an invite acknowledgement is received
270
425
  * @property {(peerId: string, invite: InviteCancel) => void} invite-cancel Emitted when we receive a cancelation for an invite
426
+ * @property {(peerId: string, invite: InviteCancelAck) => void} invite-cancel-ack Emitted when we receive a cancelation acknowledgement for an invite
271
427
  * @property {(peerId: string, inviteResponse: InviteResponse) => void} invite-response Emitted when an invite response is received
428
+ * @property {(peerId: string, inviteResponse: InviteResponseAck) => void} invite-response-ack Emitted when an invite response acknowledgement is received
272
429
  * @property {(peerId: string, details: ProjectJoinDetails) => void} got-project-details Emitted when project details are received
430
+ * @property {(peerId: string, details: ProjectJoinDetailsAck) => void} got-project-details-ack Emitted when project details are acknowledged as received
273
431
  * @property {(discoveryKey: Buffer, protomux: Protomux<import('@hyperswarm/secret-stream')>) => void} discovery-key Emitted when a new hypercore is replicated (by a peer) to a peer protomux instance (passed as the second parameter)
274
432
  * @property {(messageType: string, errorMessage?: string) => void} failed-to-handle-message Emitted when we received a message we couldn't handle for some reason. Primarily useful for testing
275
433
  */
@@ -572,6 +730,9 @@ export class LocalPeers extends TypedEmitter {
572
730
  const invite = parseInvite(value)
573
731
  const peerId = keyToId(protomux.stream.remotePublicKey)
574
732
  this.emit('invite', peerId, invite)
733
+ peer.sendInviteAck(invite).catch((e) => {
734
+ this.#l.log(`Error sending invite ack ${e.stack}`)
735
+ })
575
736
  this.#l.log(
576
737
  'Invite %h from %S for %h',
577
738
  invite.inviteId,
@@ -584,6 +745,9 @@ export class LocalPeers extends TypedEmitter {
584
745
  const inviteCancel = parseInviteCancel(value)
585
746
  const peerId = keyToId(protomux.stream.remotePublicKey)
586
747
  this.emit('invite-cancel', peerId, inviteCancel)
748
+ peer.sendInviteCancelAck(inviteCancel).catch((e) => {
749
+ this.#l.log(`Error sending invite cancel ack ${e.stack}`)
750
+ })
587
751
  this.#l.log(
588
752
  'Invite cancel from %S for %h',
589
753
  peerId,
@@ -595,12 +759,18 @@ export class LocalPeers extends TypedEmitter {
595
759
  const inviteResponse = parseInviteResponse(value)
596
760
  const peerId = keyToId(protomux.stream.remotePublicKey)
597
761
  this.emit('invite-response', peerId, inviteResponse)
762
+ peer.sendInviteResponseAck(inviteResponse).catch((e) => {
763
+ this.#l.log(`Error sending invite response ack ${e.stack}`)
764
+ })
598
765
  break
599
766
  }
600
767
  case 'ProjectJoinDetails': {
601
768
  const details = parseProjectJoinDetails(value)
602
769
  const peerId = keyToId(protomux.stream.remotePublicKey)
603
770
  this.emit('got-project-details', peerId, details)
771
+ peer.sendProjectJoinDetailsAck(details).catch((e) => {
772
+ this.#l.log(`Error sending project details ack ${e.stack}`)
773
+ })
604
774
  break
605
775
  }
606
776
  case 'DeviceInfo': {
@@ -609,6 +779,34 @@ export class LocalPeers extends TypedEmitter {
609
779
  this.#emitPeers()
610
780
  break
611
781
  }
782
+ case 'InviteAck': {
783
+ const ack = InviteAck.decode(value)
784
+ peer.receiveAck('InviteAck', ack)
785
+ const peerId = keyToId(protomux.stream.remotePublicKey)
786
+ this.emit('invite-ack', peerId, ack)
787
+ break
788
+ }
789
+ case 'InviteCancelAck': {
790
+ const ack = InviteCancelAck.decode(value)
791
+ peer.receiveAck('InviteCancelAck', ack)
792
+ const peerId = keyToId(protomux.stream.remotePublicKey)
793
+ this.emit('invite-cancel-ack', peerId, ack)
794
+ break
795
+ }
796
+ case 'InviteResponseAck': {
797
+ const ack = InviteResponseAck.decode(value)
798
+ peer.receiveAck('InviteResponseAck', ack)
799
+ const peerId = keyToId(protomux.stream.remotePublicKey)
800
+ this.emit('invite-response-ack', peerId, ack)
801
+ break
802
+ }
803
+ case 'ProjectJoinDetailsAck': {
804
+ const ack = ProjectJoinDetailsAck.decode(value)
805
+ peer.receiveAck('ProjectJoinDetailsAck', ack)
806
+ const peerId = keyToId(protomux.stream.remotePublicKey)
807
+ this.emit('got-project-details-ack', peerId, ack)
808
+ break
809
+ }
612
810
  /* c8 ignore next 2 */
613
811
  default:
614
812
  throw new ExhaustivenessError(type)
@@ -25,6 +25,10 @@ import {
25
25
  projectSettingsTable,
26
26
  } from './schema/client.js'
27
27
  import { ProjectKeys } from './generated/keys.js'
28
+ import {
29
+ DeviceInfo_RPCFeatures,
30
+ DeviceInfo_DeviceType,
31
+ } from './generated/rpc.js'
28
32
  import {
29
33
  deNullify,
30
34
  getDeviceId,
@@ -52,13 +56,17 @@ import {
52
56
  kRescindFullStopRequest,
53
57
  } from './sync/sync-api.js'
54
58
  import { NotFoundError } from './errors.js'
55
- /** @import { ProjectSettingsValue as ProjectValue } from '@comapeo/schema' */
59
+ import { WebSocket } from 'ws'
60
+
56
61
  /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */
57
62
  /** @import { SetNonNullable } from 'type-fest' */
63
+ /** @import { ProjectJoinDetails, } from './generated/rpc.js' */
58
64
  /** @import { CoreStorage, Namespace } from './types.js' */
59
- /** @import { DeviceInfoParam } from './schema/client.js' */
65
+ /** @import { DeviceInfoParam, ProjectInfo } from './schema/client.js' */
60
66
 
61
67
  /** @typedef {SetNonNullable<ProjectKeys, 'encryptionKeys'>} ValidatedProjectKeys */
68
+ /** @typedef {Pick<ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string, projectColor?: string, projectDescription?: string }} ProjectToAddDetails */
69
+ /** @typedef {{ projectId: string, createdAt?: string, updatedAt?: string, name?: string, projectColor?: string, projectDescription?: string }} ListedProject */
62
70
 
63
71
  const CLIENT_SQLITE_FILE_NAME = 'client.db'
64
72
 
@@ -113,6 +121,7 @@ export class MapeoManager extends TypedEmitter {
113
121
  #loggerBase
114
122
  #l
115
123
  #defaultConfigPath
124
+ #makeWebsocket
116
125
 
117
126
  /**
118
127
  * @param {Object} opts
@@ -126,6 +135,7 @@ export class MapeoManager extends TypedEmitter {
126
135
  * @param {string} [opts.customMapPath] File path to a locally stored Styled Map Package (SMP).
127
136
  * @param {string} [opts.fallbackMapPath] File path to a locally stored Styled Map Package (SMP)
128
137
  * @param {string} [opts.defaultOnlineStyleUrl] URL for an online-hosted StyleJSON asset.
138
+ * @param {(url: string) => WebSocket} [opts.makeWebsocket]
129
139
  */
130
140
  constructor({
131
141
  rootKey,
@@ -138,11 +148,13 @@ export class MapeoManager extends TypedEmitter {
138
148
  customMapPath,
139
149
  fallbackMapPath = DEFAULT_FALLBACK_MAP_FILE_PATH,
140
150
  defaultOnlineStyleUrl = DEFAULT_ONLINE_STYLE_URL,
151
+ makeWebsocket = (url) => new WebSocket(url),
141
152
  }) {
142
153
  super()
143
154
  this.#keyManager = new KeyManager(rootKey)
144
155
  this.#deviceId = getDeviceId(this.#keyManager)
145
156
  this.#defaultConfigPath = defaultConfigPath
157
+ this.#makeWebsocket = makeWebsocket
146
158
  const logger = (this.#loggerBase = new Logger({ deviceId: this.#deviceId }))
147
159
  this.#l = Logger.create('manager', logger)
148
160
  this.#dbFolder = dbFolder
@@ -270,9 +282,14 @@ export class MapeoManager extends TypedEmitter {
270
282
  const deviceInfo = this.getDeviceInfo()
271
283
  if (!hasSavedDeviceInfo(deviceInfo)) return
272
284
 
285
+ const deviceInfoToSend = {
286
+ ...deviceInfo,
287
+ features: [DeviceInfo_RPCFeatures.ack],
288
+ }
289
+
273
290
  const peerId = keyToId(openedNoiseStream.remotePublicKey)
274
291
 
275
- return this.#localPeers.sendDeviceInfo(peerId, deviceInfo)
292
+ return this.#localPeers.sendDeviceInfo(peerId, deviceInfoToSend)
276
293
  })
277
294
  .catch((e) => {
278
295
  // Ignore error but log
@@ -318,7 +335,7 @@ export class MapeoManager extends TypedEmitter {
318
335
  * @param {string} opts.projectPublicId
319
336
  * @param {Readonly<Buffer>} opts.projectInviteId
320
337
  * @param {ProjectKeys} opts.projectKeys
321
- * @param {Readonly<{ name?: string }>} [opts.projectInfo]
338
+ * @param {Readonly<ProjectInfo>} [opts.projectInfo]
322
339
  */
323
340
  #saveToProjectKeysTable({
324
341
  projectId,
@@ -353,15 +370,16 @@ export class MapeoManager extends TypedEmitter {
353
370
 
354
371
  /**
355
372
  * Create a new project.
356
- * @param {(
357
- * import('type-fest').Simplify<(
358
- * Partial<Pick<ProjectValue, 'name'>> &
359
- * { configPath?: string }
360
- * )>
361
- * )} [options]
373
+ *
374
+ * @param {{ name?: string, configPath?: string, projectColor?: string, projectDescription?: string }} [options]
362
375
  * @returns {Promise<string>} Project public id
363
376
  */
364
- async createProject({ name, configPath = this.#defaultConfigPath } = {}) {
377
+ async createProject({
378
+ name,
379
+ configPath = this.#defaultConfigPath,
380
+ projectColor,
381
+ projectDescription,
382
+ } = {}) {
365
383
  // 1. Create project keypair
366
384
  const projectKeypair = KeyManager.generateProjectKeypair()
367
385
 
@@ -406,7 +424,11 @@ export class MapeoManager extends TypedEmitter {
406
424
  })
407
425
 
408
426
  // 5. Write project settings to project instance
409
- await project.$setProjectSettings({ name })
427
+ await project.$setProjectSettings({
428
+ name,
429
+ projectColor,
430
+ projectDescription,
431
+ })
410
432
 
411
433
  // 6. Write device info into project
412
434
  const deviceInfo = this.getDeviceInfo()
@@ -495,13 +517,14 @@ export class MapeoManager extends TypedEmitter {
495
517
  logger: this.#loggerBase,
496
518
  getMediaBaseUrl: this.#getMediaBaseUrl.bind(this),
497
519
  isArchiveDevice,
520
+ makeWebsocket: this.#makeWebsocket,
498
521
  })
499
522
  await project[kClearDataIfLeft]()
500
523
  return project
501
524
  }
502
525
 
503
526
  /**
504
- * @returns {Promise<Array<Pick<ProjectValue, 'name'> & { projectId: string, createdAt?: string, updatedAt?: string}>>}
527
+ * @returns {Promise<Array<ListedProject>>}
505
528
  */
506
529
  async listProjects() {
507
530
  // We use the project keys table as the source of truth for projects that exist
@@ -522,11 +545,13 @@ export class MapeoManager extends TypedEmitter {
522
545
  createdAt: projectSettingsTable.createdAt,
523
546
  updatedAt: projectSettingsTable.updatedAt,
524
547
  name: projectSettingsTable.name,
548
+ projectColor: projectSettingsTable.projectColor,
549
+ projectDescription: projectSettingsTable.projectDescription,
525
550
  })
526
551
  .from(projectSettingsTable)
527
552
  .all()
528
553
 
529
- /** @type {Array<Pick<ProjectValue, 'name'> & { projectId: string, createdAt?: string, updatedAt?: string, createdBy?: string }>} */
554
+ /** @type {Array<ListedProject>} */
530
555
  const result = []
531
556
 
532
557
  for (const {
@@ -544,6 +569,11 @@ export class MapeoManager extends TypedEmitter {
544
569
  createdAt: existingProject?.createdAt,
545
570
  updatedAt: existingProject?.updatedAt,
546
571
  name: existingProject?.name || projectInfo.name,
572
+ projectColor:
573
+ existingProject?.projectColor || projectInfo.projectColor,
574
+ projectDescription:
575
+ existingProject?.projectDescription ||
576
+ projectInfo.projectDescription,
547
577
  })
548
578
  )
549
579
  }
@@ -556,12 +586,18 @@ export class MapeoManager extends TypedEmitter {
556
586
  * await `project.$waitForInitialSync()` to ensure that the device has
557
587
  * downloaded their proof of project membership and the project config.
558
588
  *
559
- * @param {Pick<import('./generated/rpc.js').ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string }} projectJoinDetails
589
+ * @param {ProjectToAddDetails} projectToAddDetails
560
590
  * @param {{ waitForSync?: boolean }} [opts] Set opts.waitForSync = false to not wait for sync during addProject()
561
591
  * @returns {Promise<string>}
562
592
  */
563
593
  addProject = async (
564
- { projectKey, encryptionKeys, projectName },
594
+ {
595
+ projectKey,
596
+ encryptionKeys,
597
+ projectName,
598
+ projectColor,
599
+ projectDescription,
600
+ },
565
601
  { waitForSync = true } = {}
566
602
  ) => {
567
603
  const projectPublicId = projectKeyToPublicId(projectKey)
@@ -599,7 +635,7 @@ export class MapeoManager extends TypedEmitter {
599
635
  projectKey,
600
636
  encryptionKeys,
601
637
  },
602
- projectInfo: { name: projectName },
638
+ projectInfo: { name: projectName, projectColor, projectDescription },
603
639
  })
604
640
 
605
641
  // Any errors from here we need to remove project from db because it has not
@@ -720,7 +756,10 @@ export class MapeoManager extends TypedEmitter {
720
756
  * @param {T} deviceInfo
721
757
  */
722
758
  async setDeviceInfo(deviceInfo) {
723
- const values = { deviceId: this.#deviceId, deviceInfo }
759
+ const values = {
760
+ deviceId: this.#deviceId,
761
+ deviceInfo,
762
+ }
724
763
  this.#db
725
764
  .insert(deviceSettingsTable)
726
765
  .values(values)
@@ -747,6 +786,7 @@ export class MapeoManager extends TypedEmitter {
747
786
  const deviceInfoToSend = {
748
787
  ...deviceInfo,
749
788
  deviceType,
789
+ features: [DeviceInfo_RPCFeatures.ack],
750
790
  }
751
791
  await Promise.all(
752
792
  this.#localPeers.peers
@@ -764,7 +804,7 @@ export class MapeoManager extends TypedEmitter {
764
804
  * @returns {(
765
805
  * {
766
806
  * deviceId: string;
767
- * deviceType: DeviceInfoParam['deviceType']
807
+ * deviceType: DeviceInfoParam['deviceType'];
768
808
  * } & Partial<DeviceInfoParam>
769
809
  * )}
770
810
  */
@@ -776,7 +816,7 @@ export class MapeoManager extends TypedEmitter {
776
816
  .get()
777
817
  return {
778
818
  deviceId: this.#deviceId,
779
- deviceType: 'device_type_unspecified',
819
+ deviceType: DeviceInfo_DeviceType.device_type_unspecified,
780
820
  ...row?.deviceInfo,
781
821
  }
782
822
  }
@@ -57,6 +57,7 @@ import { IconApi } from './icon-api.js'
57
57
  import { readConfig } from './config-import.js'
58
58
  import TranslationApi from './translation-api.js'
59
59
  import { NotFoundError, nullIfNotFound } from './errors.js'
60
+ import { WebSocket } from 'ws'
60
61
  /** @import { ProjectSettingsValue } from '@comapeo/schema' */
61
62
  /** @import { CoreStorage, BlobFilter, BlobStoreEntriesStream, KeyPair, Namespace, ReplicationStream, GenericBlobFilter } from './types.js' */
62
63
 
@@ -116,6 +117,7 @@ export class MapeoProject extends TypedEmitter {
116
117
  * @param {IndexWriter} opts.sharedIndexWriter
117
118
  * @param {CoreStorage} opts.coreStorage Folder to store all hypercore data
118
119
  * @param {(mediaType: 'blobs' | 'icons') => Promise<string>} opts.getMediaBaseUrl
120
+ * @param {(url: string) => WebSocket} [opts.makeWebsocket]
119
121
  * @param {import('./local-peers.js').LocalPeers} opts.localPeers
120
122
  * @param {boolean} opts.isArchiveDevice Whether this device is an archive device
121
123
  * @param {Logger} [opts.logger]
@@ -132,6 +134,7 @@ export class MapeoProject extends TypedEmitter {
132
134
  projectSecretKey,
133
135
  encryptionKeys,
134
136
  getMediaBaseUrl,
137
+ makeWebsocket = (url) => new WebSocket(url),
135
138
  localPeers,
136
139
  logger,
137
140
  isArchiveDevice,
@@ -350,6 +353,7 @@ export class MapeoProject extends TypedEmitter {
350
353
  getProjectName: this.#getProjectName.bind(this),
351
354
  projectKey,
352
355
  rpc: localPeers,
356
+ makeWebsocket,
353
357
  getReplicationStream,
354
358
  waitForInitialSyncWithPeer: (deviceId, abortSignal) =>
355
359
  this.$sync[kWaitForInitialSyncWithPeer](deviceId, abortSignal),
@@ -400,6 +404,7 @@ export class MapeoProject extends TypedEmitter {
400
404
  roles: this.#roles,
401
405
  blobStore: this.#blobStore,
402
406
  logger: this.#l,
407
+ makeWebsocket,
403
408
  getServerWebsocketUrls: async () => {
404
409
  const members = await this.#memberApi.getMany()
405
410
  /** @type {string[]} */
@@ -410,9 +415,9 @@ export class MapeoProject extends TypedEmitter {
410
415
  member.selfHostedServerDetails
411
416
  ) {
412
417
  const { baseUrl } = member.selfHostedServerDetails
413
- const wsUrl = new URL(`/sync/${this.#projectPublicId}`, baseUrl)
414
- wsUrl.protocol = wsUrl.protocol === 'http:' ? 'ws:' : 'wss:'
415
- serverWebsocketUrls.push(wsUrl.href)
418
+ serverWebsocketUrls.push(
419
+ baseUrlToWS(baseUrl, this.#projectPublicId)
420
+ )
416
421
  }
417
422
  }
418
423
  return serverWebsocketUrls
@@ -1086,3 +1091,16 @@ function mapAndValidateDeviceInfo(doc, { coreDiscoveryKey }) {
1086
1091
  }
1087
1092
  return doc
1088
1093
  }
1094
+
1095
+ /**
1096
+ *
1097
+ * @param {string} baseUrl
1098
+ * @param {string} projectPublicId
1099
+ * @returns {string}
1100
+ */
1101
+ export function baseUrlToWS(baseUrl, projectPublicId) {
1102
+ const wsUrl = new URL(`/sync/${projectPublicId}`, baseUrl)
1103
+ wsUrl.protocol = wsUrl.protocol === 'http:' ? 'ws:' : 'wss:'
1104
+
1105
+ return wsUrl.href
1106
+ }