libp2r2p 0.11.18 → 0.11.19
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 +20 -1
- package/helpers/abortable-semaphore.js +2 -2
- package/package.json +1 -1
- package/private-channel/index.js +9 -2
- package/private-messenger/helpers/background-signer.js +16 -0
- package/private-messenger/index.js +173 -61
- package/private-messenger/session/README.md +26 -1
- package/private-messenger/session/helpers/foreground-work.js +29 -0
- package/private-messenger/session/helpers/peer-preparation.js +111 -0
- package/private-messenger/session/helpers/signer.js +5 -1
- package/private-messenger/session/index.js +151 -52
package/README.md
CHANGED
|
@@ -535,7 +535,8 @@ new channels are watched unless paused. Applications may explicitly watch again.
|
|
|
535
535
|
|
|
536
536
|
Pauses, unwatch and close record the start of the gap, even for channels that
|
|
537
537
|
have never received a message. Rewatch/resume uses live delivery plus historical
|
|
538
|
-
recovery with overlap.
|
|
538
|
+
recovery with overlap. Remote recovery failures never restore the caller's pause
|
|
539
|
+
reason: signer availability belongs exclusively to the account owner.
|
|
539
540
|
Before opening a watch, the messenger persists a pending recovery interval.
|
|
540
541
|
A first watch scans the configured recovery window (seven days by default),
|
|
541
542
|
including messages sent before this identity first opened the channel. Successful
|
|
@@ -546,6 +547,24 @@ This also covers abrupt termination without `close()` and channels with no
|
|
|
546
547
|
messages. Applications still supply signers and desired channels on reopening;
|
|
547
548
|
storage is scoped by origin and user pubkey, not the transient session ID.
|
|
548
549
|
Failed ingestion or incomplete relay fetch reports never complete the gap.
|
|
550
|
+
|
|
551
|
+
`init()` and `createPrivateMessenger()` resolve after local preparation. Likewise,
|
|
552
|
+
`update(options, { waitForBackground = false })` and
|
|
553
|
+
`resume(reason, { waitForBackground = false })` wait for valid local channel
|
|
554
|
+
configuration and mandatory durable writes, allowing sends independently of
|
|
555
|
+
subscriptions, presence and history. Set `waitForBackground: true` to also await
|
|
556
|
+
the initial background tasks associated with that call, not every future retry.
|
|
557
|
+
Both modes share tasks; unchanged configuration does not recreate subscriptions.
|
|
558
|
+
Later errors reach `onError` with the original error as `cause` and
|
|
559
|
+
`operation: 'private-messenger.background'` plus a `phase`. Cancelled or obsolete
|
|
560
|
+
tasks cannot deliver into a new channel generation. Mandatory persistence still
|
|
561
|
+
owns its storage pause; incomplete history remains a pending recovery interval.
|
|
562
|
+
Subscription setup is bounded separately from presence, so a slow announcement
|
|
563
|
+
does not hold the other channels' readers. Sessions prioritize user persistence
|
|
564
|
+
and publication before the next background presence signer call. Signing an outer
|
|
565
|
+
envelope can overlap recovery-seed persistence, but no envelope is yielded for
|
|
566
|
+
publication unless both succeed; only one outer is prepared at a time.
|
|
567
|
+
|
|
549
568
|
`private-channel.fetch` reports incomplete reads as an `AggregateError` with
|
|
550
569
|
stable `code: 'PRIVATE_CHANNEL_FETCH_INCOMPLETE'`. Its message names relays,
|
|
551
570
|
completion statuses and native failure messages, including status-only failures.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// A bounded FIFO for asynchronous work. Acquired leases release explicitly;
|
|
2
2
|
// queued cancellations never consume capacity or keep idle keyed gates alive.
|
|
3
|
-
export function createAbortableSemaphore (capacity, onIdle = () => {}) {
|
|
3
|
+
export function createAbortableSemaphore (capacity, onIdle = () => {}, { maxQueued = 256 } = {}) {
|
|
4
4
|
const queue = []
|
|
5
5
|
let active = 0
|
|
6
6
|
const drain = () => {
|
|
@@ -21,7 +21,7 @@ export function createAbortableSemaphore (capacity, onIdle = () => {}) {
|
|
|
21
21
|
return {
|
|
22
22
|
acquire (signal) {
|
|
23
23
|
signal?.throwIfAborted()
|
|
24
|
-
if (queue.length >=
|
|
24
|
+
if (queue.length >= maxQueued) return Promise.reject(Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_QUEUE_FULL'), { code: 'PRIVATE_CHANNEL_HISTORY_QUEUE_FULL' }))
|
|
25
25
|
return new Promise((resolve, reject) => {
|
|
26
26
|
const job = {
|
|
27
27
|
signal, resolve, abort: () => {
|
package/package.json
CHANGED
package/private-channel/index.js
CHANGED
|
@@ -158,7 +158,8 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
|
|
|
158
158
|
const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
|
|
159
159
|
const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
|
|
160
160
|
const total = preparedChunkCount(context.preparedRows, rowIndexes)
|
|
161
|
-
|
|
161
|
+
const persistSeeds = async () => {
|
|
162
|
+
if (!onPreparedSeed) return
|
|
162
163
|
const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
|
|
163
164
|
const payloadRow = readPreparedRow(context.preparedRows, 0)
|
|
164
165
|
for (const rowIndex of rowIndexes) {
|
|
@@ -179,12 +180,18 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
|
|
|
179
180
|
content
|
|
180
181
|
}), routerSeckey)
|
|
181
182
|
const createdAt = nowSeconds()
|
|
182
|
-
const
|
|
183
|
+
const prepareOuter = async () => privateChannelSigner.signEvent({
|
|
183
184
|
kind: PRIVATE_BROADCAST_KIND,
|
|
184
185
|
created_at: createdAt,
|
|
185
186
|
tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
|
|
186
187
|
content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
|
|
187
188
|
})
|
|
189
|
+
// These independent steps may run together, but no envelope is yielded
|
|
190
|
+
// until every seed is durable. Settle both before releasing prepared rows.
|
|
191
|
+
const [persistence, prepared] = await Promise.allSettled([index === 1 ? persistSeeds() : undefined, prepareOuter()])
|
|
192
|
+
if (persistence.status === 'rejected') throw persistence.reason
|
|
193
|
+
if (prepared.status === 'rejected') throw prepared.reason
|
|
194
|
+
const outer = prepared.value
|
|
188
195
|
if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
|
|
189
196
|
yield outer
|
|
190
197
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Keep actual permissions and cryptography in the signer. Background presence
|
|
2
|
+
// only yields before its next RPC, without interrupting an operation in flight.
|
|
3
|
+
export function backgroundSigner (signer, wait, signal) {
|
|
4
|
+
if (!signer || !wait) return signer
|
|
5
|
+
return new Proxy(signer, {
|
|
6
|
+
get (target, key) {
|
|
7
|
+
const value = Reflect.get(target, key, target)
|
|
8
|
+
if (typeof value !== 'function') return value
|
|
9
|
+
return async (...args) => {
|
|
10
|
+
await wait(signal)
|
|
11
|
+
signal?.throwIfAborted()
|
|
12
|
+
return value.apply(target, args)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
}
|
|
@@ -48,6 +48,7 @@ import * as privateMessage from '../private-message/index.js'
|
|
|
48
48
|
import { isOnline, onOnline } from '../network/index.js'
|
|
49
49
|
import { isRetryableRelayFailure, isReplaceableRelayFailure } from '../relay/index.js'
|
|
50
50
|
import { createPauseRecovery, isRecoverableStorageFailure } from './helpers/pause-recovery.js'
|
|
51
|
+
import { backgroundSigner } from './helpers/background-signer.js'
|
|
51
52
|
import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay } from './helpers/send-routing.js'
|
|
52
53
|
import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
|
|
53
54
|
import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
|
|
@@ -243,6 +244,7 @@ export class PrivateMessenger {
|
|
|
243
244
|
_pickRelaysForPubkeys = pickRelaysForPubkeys,
|
|
244
245
|
_subscribeRelayListUpdates = subscribeRelayListUpdates,
|
|
245
246
|
_isOnline = isOnline,
|
|
247
|
+
_waitForForeground,
|
|
246
248
|
_onOnline = onOnline,
|
|
247
249
|
_random = Math.random,
|
|
248
250
|
_setTimeout = globalThis.setTimeout.bind(globalThis),
|
|
@@ -304,6 +306,7 @@ export class PrivateMessenger {
|
|
|
304
306
|
this._pickRelaysForPubkeys = _pickRelaysForPubkeys
|
|
305
307
|
this._subscribeRelayListUpdates = _subscribeRelayListUpdates
|
|
306
308
|
this._isOnline = _isOnline
|
|
309
|
+
this._waitForForeground = _waitForForeground
|
|
307
310
|
this.sendRelayExclusions = new Map()
|
|
308
311
|
this.sendRoutingLifetime = new AbortController()
|
|
309
312
|
this.sendRoutingPause = new AbortController()
|
|
@@ -341,7 +344,13 @@ export class PrivateMessenger {
|
|
|
341
344
|
this.recoveryRanges = new Map()
|
|
342
345
|
this.recoveryPriority = new Map()
|
|
343
346
|
this.recoveryOnline = { at: 0, value: true }
|
|
344
|
-
this.
|
|
347
|
+
this.channelBackground = new Map()
|
|
348
|
+
// At most one queued job exists per configured channel. Keep setup below
|
|
349
|
+
// foreground demand; history has its own independent admission limits.
|
|
350
|
+
this.channelSetupAdmission = createAbortableSemaphore(3, () => {}, { maxQueued: Infinity })
|
|
351
|
+
this.presenceAdmission = createAbortableSemaphore(1, () => {}, { maxQueued: Infinity })
|
|
352
|
+
this.updateTail = Promise.resolve()
|
|
353
|
+
this.pauseRevisions = new Map()
|
|
345
354
|
this.capacityTimer = null
|
|
346
355
|
this.capacityCheck = null
|
|
347
356
|
this.capacityRequiredBytes = 0
|
|
@@ -350,6 +359,7 @@ export class PrivateMessenger {
|
|
|
350
359
|
this.liveRecoveryTimers = new Map()
|
|
351
360
|
this.watchRevisionByChannel = new Map()
|
|
352
361
|
this.presenceTimers = new Map()
|
|
362
|
+
this.presenceJobs = new Map()
|
|
353
363
|
this.stopRelayListWatcher = null
|
|
354
364
|
this.relayListWatcherPubkey = ''
|
|
355
365
|
this.relayListRefreshPromise = null
|
|
@@ -435,7 +445,6 @@ export class PrivateMessenger {
|
|
|
435
445
|
this.startStorageMaintenance()
|
|
436
446
|
this.state = { channels: await this.stateStore.load() }
|
|
437
447
|
await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
|
|
438
|
-
await this.pruneStoredSeeds()
|
|
439
448
|
this.initialized = true
|
|
440
449
|
await this.refreshAndApplyStoragePolicy()
|
|
441
450
|
this.broadcastStoragePolicyChange()
|
|
@@ -673,7 +682,16 @@ export class PrivateMessenger {
|
|
|
673
682
|
})
|
|
674
683
|
}
|
|
675
684
|
|
|
676
|
-
async update (options = {}) {
|
|
685
|
+
async update (options = {}, { waitForBackground = false } = {}) {
|
|
686
|
+
if (typeof waitForBackground !== 'boolean') throw new ValidationError('INVALID_WAIT_FOR_BACKGROUND')
|
|
687
|
+
const work = this.updateTail.catch(() => {}).then(() => this.applyUpdate(options))
|
|
688
|
+
this.updateTail = work
|
|
689
|
+
const background = await work
|
|
690
|
+
if (waitForBackground) await Promise.all(background)
|
|
691
|
+
return this
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async applyUpdate (options) {
|
|
677
695
|
this.assertOpen()
|
|
678
696
|
const {
|
|
679
697
|
userSigner = this.userSigner,
|
|
@@ -692,15 +710,17 @@ export class PrivateMessenger {
|
|
|
692
710
|
let nextIdentityStorageRetentionSeconds = updatesIdentityPolicy
|
|
693
711
|
? normalizeIdentityStorageRetentionSeconds(options.identityStorageRetentionSeconds)
|
|
694
712
|
: this.identityStorageRetentionSeconds
|
|
695
|
-
if (userSigner) {
|
|
713
|
+
if (userSigner && (userSigner !== this.userSigner || !this.userPubkey)) {
|
|
696
714
|
const userPubkey = await userSigner.getPublicKey?.()
|
|
697
715
|
if (!userPubkey) throw new ValidationError('USER_SIGNER_REQUIRED')
|
|
698
716
|
if (this.userPubkey && userPubkey !== this.userPubkey) throw new ValidationError('USER_SIGNER_MISMATCH')
|
|
699
717
|
this.userSigner = userSigner
|
|
700
718
|
}
|
|
719
|
+
if (contentKeySigner !== this.contentKeySigner || (!this.contentKeyPubkey && contentKeySigner)) {
|
|
720
|
+
this.contentKeyPubkey = await contentKeySigner?.getPublicKey?.() || ''
|
|
721
|
+
}
|
|
701
722
|
this.contentKeySigner = contentKeySigner || null
|
|
702
723
|
this.nymSigner = nymSigner || null
|
|
703
|
-
this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
|
|
704
724
|
const nextChannels = await this.normalizeChannels(channels, { relays, mode })
|
|
705
725
|
this.assertOpen()
|
|
706
726
|
const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
|
|
@@ -709,9 +729,11 @@ export class PrivateMessenger {
|
|
|
709
729
|
return !previous || ['signer', 'readerSigner', 'readerPubkey', 'nymSigner', 'mode', 'usesNip65WatchRelays', 'offlineRecoverySeconds', 'autoDeletionCapability'].some(key => previous[key] !== channel[key]) ||
|
|
710
730
|
['relays', 'sendRelays', 'seeders'].some(key => JSON.stringify(previous[key]) !== JSON.stringify(channel[key]))
|
|
711
731
|
}
|
|
712
|
-
const
|
|
732
|
+
const changed = nextChannels.filter(channel => readerIdentityChanged || updatesStalePolicy || updatesIdentityPolicy || changedChannel(channel))
|
|
733
|
+
const watchPubkeys = changed.filter(channel => !this.channels.has(channel.pubkey) || this.desiredChannels.has(channel.pubkey)).map(channel => channel.pubkey)
|
|
713
734
|
const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
|
|
714
735
|
const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
|
|
736
|
+
if (!changed.length && !removedPubkeys.length && !updatesStoragePolicy) return [...this.channelBackground.values()].map(job => job.work)
|
|
715
737
|
|
|
716
738
|
if (updatesStoragePolicy) {
|
|
717
739
|
const currentPolicy = await this.readStoragePolicySnapshot()
|
|
@@ -743,18 +765,35 @@ export class PrivateMessenger {
|
|
|
743
765
|
if (updatesStoragePolicy) this.broadcastStoragePolicyChange()
|
|
744
766
|
|
|
745
767
|
await this.unwatch(removedPubkeys)
|
|
768
|
+
// Keep an established reader alive until private-message atomically
|
|
769
|
+
// replaces it. Only obsolete setup/presence work is retired here.
|
|
770
|
+
for (const pubkey of watchPubkeys) {
|
|
771
|
+
this.cancelReloadGap(pubkey)
|
|
772
|
+
this.cancelLiveRecovery(pubkey)
|
|
773
|
+
this.channelBackground.get(pubkey)?.controller.abort()
|
|
774
|
+
this.channelBackground.delete(pubkey)
|
|
775
|
+
this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
|
|
776
|
+
this.stopPresencePublisher(pubkey)
|
|
777
|
+
}
|
|
746
778
|
for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
|
|
747
779
|
for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
|
|
748
|
-
for (const channel of
|
|
780
|
+
for (const channel of changed) this.channels.set(channel.pubkey, channel)
|
|
749
781
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
await this.
|
|
753
|
-
|
|
782
|
+
// Incremental registration must not prune persisted channels whose signers
|
|
783
|
+
// are still being prepared. Retention cleanup belongs to maintenance.
|
|
784
|
+
await this.applyRecoveryPolicies(changed, { prune: !this.seedStorage })
|
|
785
|
+
this.assertOpen()
|
|
786
|
+
for (const pubkey of watchPubkeys) {
|
|
787
|
+
this.desiredChannels.add(pubkey)
|
|
788
|
+
this.recordRecoveryWindow(pubkey)
|
|
789
|
+
}
|
|
790
|
+
if (this.pauseReasons.size) this.recordInterruption(watchPubkeys)
|
|
791
|
+
await this.flushStateWrites()
|
|
792
|
+
this.ensureNetworkWatchers()
|
|
754
793
|
if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
|
|
755
794
|
this.storagePolicyNeedsApply = false
|
|
756
795
|
}
|
|
757
|
-
return this
|
|
796
|
+
return this.startChannelBackground(watchPubkeys)
|
|
758
797
|
}
|
|
759
798
|
|
|
760
799
|
async normalizeChannels (channels, defaults) {
|
|
@@ -772,7 +811,7 @@ export class PrivateMessenger {
|
|
|
772
811
|
if (!signer && !readerSigner) throw new ValidationError('CHANNEL_SIGNER_REQUIRED')
|
|
773
812
|
const mode = channel.mode || defaults.mode || 'leecher'
|
|
774
813
|
if (!signer && doesModeStoreRecoverySeeds(mode)) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
|
|
775
|
-
const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || await readerSigner?.getPublicKey?.() || pubkey
|
|
814
|
+
const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || (readerSigner === signer ? pubkey : await readerSigner?.getPublicKey?.()) || pubkey
|
|
776
815
|
const autoDeletionCapability = channel.autoDeletionCapability === undefined
|
|
777
816
|
? undefined
|
|
778
817
|
: normalizeAutoDeletionCapability(channel.autoDeletionCapability)
|
|
@@ -804,8 +843,8 @@ export class PrivateMessenger {
|
|
|
804
843
|
return this._pickRelaysForPubkeys(pubkeys, relaysByPubkey, { relayType: 'read' })
|
|
805
844
|
}
|
|
806
845
|
|
|
807
|
-
async readRelaysForPubkey (pubkey) {
|
|
808
|
-
const relaysByPubkey = await this._getRelaysByPubkey([pubkey])
|
|
846
|
+
async readRelaysForPubkey (pubkey, options) {
|
|
847
|
+
const relaysByPubkey = await this._getRelaysByPubkey([pubkey], options)
|
|
809
848
|
const readRelays = uniq(relaysByPubkey?.[pubkey]?.read)
|
|
810
849
|
if (readRelays.length) return readRelays
|
|
811
850
|
return relayMapRelays(this._pickRelaysForPubkeys([pubkey], relaysByPubkey, { relayType: 'read' }))
|
|
@@ -823,8 +862,8 @@ export class PrivateMessenger {
|
|
|
823
862
|
}
|
|
824
863
|
}
|
|
825
864
|
|
|
826
|
-
async resolveWatchRelays (channel) {
|
|
827
|
-
const primary = !channel.usesNip65WatchRelays && channel.relays.length ? channel.relays : await this.readRelaysForPubkey(this.userPubkey)
|
|
865
|
+
async resolveWatchRelays (channel, options) {
|
|
866
|
+
const primary = !channel.usesNip65WatchRelays && channel.relays.length ? channel.relays : await this.readRelaysForPubkey(this.userPubkey, options)
|
|
828
867
|
return uniq([...primary, ...this.fallbackRelays].map(normalizeRelayUrl))
|
|
829
868
|
}
|
|
830
869
|
|
|
@@ -1124,7 +1163,7 @@ export class PrivateMessenger {
|
|
|
1124
1163
|
this.writeState(state)
|
|
1125
1164
|
}
|
|
1126
1165
|
|
|
1127
|
-
async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
|
|
1166
|
+
async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true, signal } = {}) {
|
|
1128
1167
|
this.assertOpen()
|
|
1129
1168
|
const channelPubkeys = uniq(channels)
|
|
1130
1169
|
for (const pubkey of channelPubkeys) {
|
|
@@ -1148,13 +1187,15 @@ export class PrivateMessenger {
|
|
|
1148
1187
|
await this.flushStateWrites()
|
|
1149
1188
|
let stop; let watchRelays
|
|
1150
1189
|
try {
|
|
1151
|
-
|
|
1190
|
+
signal?.throwIfAborted()
|
|
1191
|
+
watchRelays = await this.resolveWatchRelays(channel, { signal })
|
|
1152
1192
|
this.assertOpen()
|
|
1153
1193
|
if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
|
|
1154
1194
|
stop = await this._privateMessage.watch({
|
|
1155
1195
|
channels: [pubkey],
|
|
1156
1196
|
relays: watchRelays,
|
|
1157
1197
|
receiverSigner: this.userSigner,
|
|
1198
|
+
receiverPubkey: this.userPubkey,
|
|
1158
1199
|
iykcSigner: this.contentKeySigner,
|
|
1159
1200
|
privateChannelSigner: channel.signer,
|
|
1160
1201
|
privateChannelReaderSigner: channel.readerSigner,
|
|
@@ -1174,6 +1215,7 @@ export class PrivateMessenger {
|
|
|
1174
1215
|
onError: err => this.onError?.(err)
|
|
1175
1216
|
})
|
|
1176
1217
|
} catch (error) {
|
|
1218
|
+
if (signal?.aborted) continue
|
|
1177
1219
|
const transient = isRetryableRelayFailure(error)
|
|
1178
1220
|
const remote = error?.name !== 'Nip42AuthenticationError' && (error?.category === 'relay' || isReplaceableRelayFailure(error))
|
|
1179
1221
|
if (!transient && !remote) throw error
|
|
@@ -1655,6 +1697,8 @@ export class PrivateMessenger {
|
|
|
1655
1697
|
stopWatches (channels = [...this.stopByChannel.keys()]) {
|
|
1656
1698
|
const closing = []
|
|
1657
1699
|
for (const pubkey of channels) {
|
|
1700
|
+
this.channelBackground.get(pubkey)?.controller.abort()
|
|
1701
|
+
this.channelBackground.delete(pubkey)
|
|
1658
1702
|
this.cancelReloadGap(pubkey)
|
|
1659
1703
|
this.cancelLiveRecovery(pubkey)
|
|
1660
1704
|
this.liveInterruptions.delete(pubkey)
|
|
@@ -1785,6 +1829,7 @@ export class PrivateMessenger {
|
|
|
1785
1829
|
if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
|
|
1786
1830
|
this.assertOpen()
|
|
1787
1831
|
this.pauseReasons.add(reason)
|
|
1832
|
+
this.pauseRevisions.set(reason, (this.pauseRevisions.get(reason) || 0) + 1)
|
|
1788
1833
|
this.sendRoutingPause.abort(this.pausedError())
|
|
1789
1834
|
this.notifyStatus()
|
|
1790
1835
|
for (const extension of this.extensions) extension.pause?.()
|
|
@@ -1793,14 +1838,16 @@ export class PrivateMessenger {
|
|
|
1793
1838
|
return Promise.all([this.stopWatches([...this.desiredChannels]), this.flushStateWrites()])
|
|
1794
1839
|
}
|
|
1795
1840
|
|
|
1796
|
-
async resume (reason) {
|
|
1841
|
+
async resume (reason, { waitForBackground = false } = {}) {
|
|
1797
1842
|
if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
|
|
1843
|
+
if (typeof waitForBackground !== 'boolean') throw new ValidationError('INVALID_WAIT_FOR_BACKGROUND')
|
|
1798
1844
|
this.assertOpen()
|
|
1845
|
+
const revision = this.pauseRevisions.get(reason)
|
|
1799
1846
|
// Repair durable state before announcing that publication can resume.
|
|
1800
1847
|
if (reason === 'storage' && this.pendingStorageWrites.size) await this.retryStorageWrites(this.sendRoutingLifetime.signal)
|
|
1801
1848
|
await this.flushStateWrites()
|
|
1802
1849
|
this.assertOpen()
|
|
1803
|
-
|
|
1850
|
+
if (revision !== this.pauseRevisions.get(reason)) return
|
|
1804
1851
|
const removed = this.pauseReasons.delete(reason)
|
|
1805
1852
|
this.automaticPauses.delete(reason)
|
|
1806
1853
|
if (!this.pauseReasons.size && this.sendRoutingPause.signal.aborted) this.sendRoutingPause = new AbortController()
|
|
@@ -1810,29 +1857,75 @@ export class PrivateMessenger {
|
|
|
1810
1857
|
this.capacityTimer = null
|
|
1811
1858
|
this.capacityRequiredBytes = 0
|
|
1812
1859
|
}
|
|
1813
|
-
if (
|
|
1814
|
-
|
|
1815
|
-
if (
|
|
1816
|
-
const work = (async () => {
|
|
1817
|
-
const channels = [...this.desiredChannels]
|
|
1860
|
+
if (this.pauseReasons.size) return
|
|
1861
|
+
const channels = [...this.desiredChannels]
|
|
1862
|
+
if (removed) {
|
|
1818
1863
|
this.closeOpenOfflineRanges(channels)
|
|
1819
|
-
await this.
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
this.resumeWork = work
|
|
1829
|
-
try { await work } catch (err) {
|
|
1830
|
-
if (!this.closePromise) {
|
|
1831
|
-
if (wasAutomatic && (reason === 'network' ? isRetryableRelayFailure(err) : isRecoverableStorageFailure(err))) this.automaticPauses.add(reason)
|
|
1832
|
-
await this.applyPause(reason)
|
|
1864
|
+
await this.flushStateWrites()
|
|
1865
|
+
}
|
|
1866
|
+
if (this.pauseReasons.size || this.closePromise) return
|
|
1867
|
+
const jobs = removed ? this.startChannelBackground(channels, { recover: true }) : [...this.channelBackground.values()].map(job => job.work)
|
|
1868
|
+
if (removed) {
|
|
1869
|
+
for (const extension of this.extensions) {
|
|
1870
|
+
const work = Promise.resolve().then(() => { if (!this.pauseReasons.size && !this.closePromise) return extension.resume?.() })
|
|
1871
|
+
work.catch(error => this.onError?.(error))
|
|
1872
|
+
jobs.push(work)
|
|
1833
1873
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1874
|
+
}
|
|
1875
|
+
if (waitForBackground) await Promise.all(jobs)
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
startChannelBackground (channels, { recover = false } = {}) {
|
|
1879
|
+
const jobs = []
|
|
1880
|
+
for (const pubkey of channels) {
|
|
1881
|
+
if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey)) continue
|
|
1882
|
+
let job = this.channelBackground.get(pubkey)
|
|
1883
|
+
if (job) { jobs.push(job.work); continue }
|
|
1884
|
+
const controller = new AbortController()
|
|
1885
|
+
const channel = this.channels.get(pubkey)
|
|
1886
|
+
job = { controller, work: null }
|
|
1887
|
+
this.channelBackground.set(pubkey, job)
|
|
1888
|
+
const active = () => this.initialized && !controller.signal.aborted && !this.closePromise && !this.pauseReasons.size && this.channels.get(pubkey) === channel && this.desiredChannels.has(pubkey)
|
|
1889
|
+
let phase = 'subscribe'
|
|
1890
|
+
let stopAbort
|
|
1891
|
+
const cancelled = new Promise(resolve => {
|
|
1892
|
+
stopAbort = resolve
|
|
1893
|
+
controller.signal.addEventListener('abort', stopAbort, { once: true })
|
|
1894
|
+
})
|
|
1895
|
+
const work = Promise.resolve().then(async () => {
|
|
1896
|
+
if (!this.initialized) await this.initSettledPromise
|
|
1897
|
+
if (!active()) return
|
|
1898
|
+
const release = await this.channelSetupAdmission.acquire(controller.signal)
|
|
1899
|
+
try {
|
|
1900
|
+
if (!active()) return
|
|
1901
|
+
await this.watch([pubkey], { scheduleReloadGap: !recover, signal: controller.signal })
|
|
1902
|
+
} finally { release() }
|
|
1903
|
+
if (!active()) return
|
|
1904
|
+
phase = 'presence'
|
|
1905
|
+
if (doesModeStoreRecoverySeeds(channel.mode)) await this.startPresencePublisher(pubkey)
|
|
1906
|
+
if (!active()) return
|
|
1907
|
+
phase = 'history'
|
|
1908
|
+
if (recover) await this.recoverOfflineRanges([pubkey])
|
|
1909
|
+
if (!active()) return
|
|
1910
|
+
if (recover && this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
|
|
1911
|
+
}).catch(error => {
|
|
1912
|
+
if (!active()) return
|
|
1913
|
+
const diagnostic = Object.assign(new Error(error?.message || 'PRIVATE_MESSENGER_BACKGROUND_FAILED', { cause: error }), {
|
|
1914
|
+
name: error?.name || 'Error', ...(error?.code ? { code: error.code } : {}),
|
|
1915
|
+
...(error?.category ? { category: error.category } : {}),
|
|
1916
|
+
operation: 'private-messenger.background', phase
|
|
1917
|
+
})
|
|
1918
|
+
this.onError?.(diagnostic)
|
|
1919
|
+
throw error
|
|
1920
|
+
})
|
|
1921
|
+
job.work = Promise.race([work, cancelled]).finally(() => {
|
|
1922
|
+
controller.signal.removeEventListener('abort', stopAbort)
|
|
1923
|
+
if (this.channelBackground.get(pubkey) === job) this.channelBackground.delete(pubkey)
|
|
1924
|
+
})
|
|
1925
|
+
job.work.catch(() => {})
|
|
1926
|
+
jobs.push(job.work)
|
|
1927
|
+
}
|
|
1928
|
+
return jobs
|
|
1836
1929
|
}
|
|
1837
1930
|
|
|
1838
1931
|
receive (pubkey, operation, message = {}) {
|
|
@@ -2336,16 +2429,16 @@ export class PrivateMessenger {
|
|
|
2336
2429
|
})
|
|
2337
2430
|
}
|
|
2338
2431
|
|
|
2339
|
-
async publishSeederPresence (channelPubkey = this.defaultChannelPubkey()) {
|
|
2432
|
+
async publishSeederPresence (channelPubkey = this.defaultChannelPubkey(), { signal } = {}) {
|
|
2340
2433
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
2341
2434
|
if (!this.offlineRecoverySecondsFor(channel)) return null
|
|
2342
2435
|
const receiverPubkeys = uniq([...this.knownSeeders(channelPubkey), this.userPubkey])
|
|
2343
|
-
const routing = await this.resolveSendRouting({ channel, receiverPubkeys })
|
|
2436
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, signal })
|
|
2344
2437
|
this.debugSend('yell', channelPubkey, { code: SEEDER_PRESENCE_CODE, receiverPubkeys })
|
|
2345
2438
|
return this._privateMessage.yell({
|
|
2346
|
-
senderSigner: this.userSigner,
|
|
2347
|
-
imkcSigner: this.contentKeySigner,
|
|
2348
|
-
privateChannelSigner: channel.signer,
|
|
2439
|
+
senderSigner: backgroundSigner(this.userSigner, this._waitForForeground, signal),
|
|
2440
|
+
imkcSigner: backgroundSigner(this.contentKeySigner, this._waitForForeground, signal),
|
|
2441
|
+
privateChannelSigner: backgroundSigner(channel.signer, this._waitForForeground, signal),
|
|
2349
2442
|
privateChannelReaderPubkey: channel.readerPubkey,
|
|
2350
2443
|
receiverPubkeys,
|
|
2351
2444
|
...routing,
|
|
@@ -2362,22 +2455,40 @@ export class PrivateMessenger {
|
|
|
2362
2455
|
async startPresencePublisher (channelPubkey) {
|
|
2363
2456
|
if (this.pauseReasons.size || !this.desiredChannels.has(channelPubkey) || !this.offlineRecoverySecondsFor(channelPubkey)) return
|
|
2364
2457
|
if (this.presenceTimers.has(channelPubkey)) return
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2458
|
+
if (this.presenceJobs.has(channelPubkey)) return this.presenceJobs.get(channelPubkey).work
|
|
2459
|
+
const job = { controller: new AbortController(), work: null }
|
|
2460
|
+
const active = () => this.presenceJobs.get(channelPubkey) === job && !this.pauseReasons.size && !this.closePromise && this.desiredChannels.has(channelPubkey)
|
|
2461
|
+
let publishing = false
|
|
2462
|
+
const publish = async () => {
|
|
2463
|
+
if (!active() || publishing) return
|
|
2464
|
+
publishing = true
|
|
2465
|
+
let release
|
|
2466
|
+
try {
|
|
2467
|
+
release = await this.presenceAdmission.acquire(job.controller.signal)
|
|
2468
|
+
job.controller.signal.addEventListener('abort', release, { once: true })
|
|
2469
|
+
if (active()) await this.publishSeederPresence(channelPubkey, { signal: job.controller.signal })
|
|
2470
|
+
} catch (error) {
|
|
2471
|
+
if (active()) this.onError?.(Object.assign(new Error(error?.message, { cause: error }), { operation: 'private-messenger.background', phase: 'presence' }))
|
|
2472
|
+
} finally {
|
|
2473
|
+
if (release) { job.controller.signal.removeEventListener('abort', release); release() }
|
|
2474
|
+
publishing = false
|
|
2475
|
+
}
|
|
2369
2476
|
}
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2477
|
+
const work = Promise.resolve().then(async () => {
|
|
2478
|
+
await publish()
|
|
2479
|
+
if (!active()) return
|
|
2480
|
+
const timer = this._setInterval(publish, this.seederPresenceIntervalMs)
|
|
2481
|
+
timer?.unref?.()
|
|
2482
|
+
this.presenceTimers.set(channelPubkey, timer)
|
|
2483
|
+
})
|
|
2484
|
+
job.work = work
|
|
2485
|
+
this.presenceJobs.set(channelPubkey, job)
|
|
2486
|
+
return work
|
|
2378
2487
|
}
|
|
2379
2488
|
|
|
2380
2489
|
stopPresencePublisher (channelPubkey) {
|
|
2490
|
+
this.presenceJobs.get(channelPubkey)?.controller.abort()
|
|
2491
|
+
this.presenceJobs.delete(channelPubkey)
|
|
2381
2492
|
const timer = this.presenceTimers.get(channelPubkey)
|
|
2382
2493
|
if (timer) this._clearInterval(timer)
|
|
2383
2494
|
this.presenceTimers.delete(channelPubkey)
|
|
@@ -2458,7 +2569,7 @@ export class PrivateMessenger {
|
|
|
2458
2569
|
return seconds ? seconds * 1000 : DEFAULT_RECEIVED_CHUNK_TTL_MS
|
|
2459
2570
|
}
|
|
2460
2571
|
|
|
2461
|
-
async applyRecoveryPolicies (channels) {
|
|
2572
|
+
async applyRecoveryPolicies (channels, { prune = true } = {}) {
|
|
2462
2573
|
return this.runQueueOperation(async () => {
|
|
2463
2574
|
const state = this.readState()
|
|
2464
2575
|
const now = nowSeconds()
|
|
@@ -2484,7 +2595,7 @@ export class PrivateMessenger {
|
|
|
2484
2595
|
for (const channel of channels) {
|
|
2485
2596
|
if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryState(channel.pubkey)
|
|
2486
2597
|
}
|
|
2487
|
-
for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
|
|
2598
|
+
if (prune) for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
|
|
2488
2599
|
})
|
|
2489
2600
|
}
|
|
2490
2601
|
|
|
@@ -3070,6 +3181,7 @@ export class PrivateMessenger {
|
|
|
3070
3181
|
let unwatchError
|
|
3071
3182
|
try { await unwatchPromise } catch (err) { unwatchError = err }
|
|
3072
3183
|
await initSettledPromise
|
|
3184
|
+
await this.updateTail.catch(() => {})
|
|
3073
3185
|
await Promise.all([...this.extensions].map(extension => extension.close?.()))
|
|
3074
3186
|
await this.stampActiveChannelActivity()
|
|
3075
3187
|
await this.queueOperationTail
|
|
@@ -15,10 +15,35 @@ back to sender-content whenever a proof is missing or the lookup fails. Misses
|
|
|
15
15
|
are cached for 5 minutes (found proofs for 40), so a later prefetch picks up a
|
|
16
16
|
key published after an earlier miss. Set it to `false` when the signer owns that discovery. The DM context is `dm`; file contexts are `dm:media:<root>`.
|
|
17
17
|
`createMessengerSigner` adapts this byte signer to the transport's Base64 API.
|
|
18
|
+
Each adapter belongs to an immutable account/channel identity: it shares the
|
|
19
|
+
first successful public-key lookup and retries failed lookups only on a new call.
|
|
20
|
+
Create a new adapter when changing identity. Signing/encryption still uses the
|
|
21
|
+
real signer and its current permissions on every operation.
|
|
18
22
|
|
|
19
|
-
Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
|
|
23
|
+
Methods: `setPeers`, `setAvailable(boolean)`, `preparePeer(peer)`, `prefetchContentKeys(peers?)`,
|
|
20
24
|
`prioritizeRange(peer, options)`, `enqueue`, `retry`, `cancel`, `download`,
|
|
21
25
|
`cancelDownload`, and `close`.
|
|
26
|
+
|
|
27
|
+
The session creates one messenger for the account, independently of contact
|
|
28
|
+
revisions. `setPeers` and `setAvailable` reconcile local configuration without
|
|
29
|
+
awaiting all contacts or remote recovery. Channel preparation is incremental and
|
|
30
|
+
deduplicated: at most four preparations run, with at most three in the background.
|
|
31
|
+
`preparePeer(peer)` promotes an existing contact and resolves when its channel is
|
|
32
|
+
locally writable. Call it when opening a conversation; enqueue, retry and remote
|
|
33
|
+
download also request preparation automatically. An unprepared conversation does
|
|
34
|
+
not block local saves or sends to a ready contact. Resolved identities are reused.
|
|
35
|
+
The session also defers the next optional presence signer call while user outbox
|
|
36
|
+
writes/publications are active. Existing signer calls, subscriptions and historical
|
|
37
|
+
coverage remain intact; releasing foreground work resumes the waiting presence.
|
|
38
|
+
Successful sends remove the final outbox record directly instead of encrypting a
|
|
39
|
+
redundant final checkpoint; intermediate context/file checkpoints remain durable.
|
|
40
|
+
|
|
41
|
+
Known transient preparation failures retry with bounded offline-aware backoff;
|
|
42
|
+
unknown, permission and validation failures remain isolated to that contact until
|
|
43
|
+
explicit preparation/retry or a real availability transition. Removing a contact,
|
|
44
|
+
locking or closing discards obsolete signer results and cancels its queued work.
|
|
45
|
+
Contact changes never invent a `signer` pause; only `setAvailable` controls that
|
|
46
|
+
reason. Storage, capacity, network and explicit pauses keep their own owners.
|
|
22
47
|
`prefetchContentKeys` warms the content-key lookup cache for the owner and the
|
|
23
48
|
given peers (default: current peers); it is best-effort and never rejects.
|
|
24
49
|
`prioritizeRange` registers an in-memory priority window for a conversation:
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Local outbox persistence and user publications take precedence over the next
|
|
2
|
+
// optional presence signer call. Already-started signer calls are not recalled.
|
|
3
|
+
export function createForegroundWork () {
|
|
4
|
+
let active = 0
|
|
5
|
+
const waiters = new Set()
|
|
6
|
+
const lifetime = new AbortController()
|
|
7
|
+
return {
|
|
8
|
+
enter () {
|
|
9
|
+
active++
|
|
10
|
+
let released = false
|
|
11
|
+
return () => {
|
|
12
|
+
if (released) return
|
|
13
|
+
released = true
|
|
14
|
+
if (--active === 0) for (const wake of [...waiters]) wake()
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
wait (signal) {
|
|
18
|
+
const combined = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal
|
|
19
|
+
combined.throwIfAborted()
|
|
20
|
+
if (!active) return Promise.resolve()
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const finish = () => { waiters.delete(finish); combined.removeEventListener('abort', finish); combined.aborted ? reject(combined.reason) : resolve() }
|
|
23
|
+
waiters.add(finish)
|
|
24
|
+
combined.addEventListener('abort', finish, { once: true })
|
|
25
|
+
})
|
|
26
|
+
},
|
|
27
|
+
close () { lifetime.abort(); waiters.clear() }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createPauseRecovery } from '../../helpers/pause-recovery.js'
|
|
2
|
+
|
|
3
|
+
// One account owns the queue. Background work may occupy only three of the
|
|
4
|
+
// four slots so a newly opened conversation never sits behind the whole list.
|
|
5
|
+
export function createPeerPreparation ({ prepare, onReady, onError, isRetryable, online, onOnline, setTimer, clearTimer, random }) {
|
|
6
|
+
const entries = new Map()
|
|
7
|
+
let enabled = false
|
|
8
|
+
let closed = false
|
|
9
|
+
let running = 0
|
|
10
|
+
let background = 0
|
|
11
|
+
const unavailable = () => new Error('CHAT_UNAVAILABLE')
|
|
12
|
+
function entryFor (peer) {
|
|
13
|
+
const deferred = Promise.withResolvers()
|
|
14
|
+
deferred.promise.catch(() => {})
|
|
15
|
+
return { peer, ...deferred, controller: new AbortController(), state: 'queued', priority: false, value: null, error: null, recovery: null }
|
|
16
|
+
}
|
|
17
|
+
const current = entry => !closed && enabled && entries.get(entry.peer) === entry && !entry.controller.signal.aborted
|
|
18
|
+
function cancel (entry) {
|
|
19
|
+
entry.recovery?.stop()
|
|
20
|
+
entry.controller.abort(unavailable())
|
|
21
|
+
entry.reject(entry.controller.signal.reason)
|
|
22
|
+
entry.retryResult?.resolve()
|
|
23
|
+
entry.retryResult = null
|
|
24
|
+
}
|
|
25
|
+
function drain () {
|
|
26
|
+
if (!enabled || closed) return
|
|
27
|
+
while (running < 4) {
|
|
28
|
+
const queued = [...entries.values()].filter(entry => entry.state === 'queued')
|
|
29
|
+
const entry = queued.find(entry => entry.priority) || (background < 3 && queued[0])
|
|
30
|
+
if (!entry) break
|
|
31
|
+
const isBackground = !entry.priority
|
|
32
|
+
entry.state = 'running'
|
|
33
|
+
running++
|
|
34
|
+
if (isBackground) background++
|
|
35
|
+
Promise.resolve().then(() => {
|
|
36
|
+
entry.controller.signal.throwIfAborted()
|
|
37
|
+
return prepare(entry.peer, entry.controller.signal)
|
|
38
|
+
}).then(value => {
|
|
39
|
+
if (!current(entry)) return
|
|
40
|
+
entry.state = 'ready'; entry.value = value
|
|
41
|
+
entry.resolve(value)
|
|
42
|
+
onReady(entry.peer, value)
|
|
43
|
+
}, error => {
|
|
44
|
+
if (!current(entry)) return
|
|
45
|
+
entry.error = error
|
|
46
|
+
onError(error)
|
|
47
|
+
if (!isRetryable(error)) {
|
|
48
|
+
entry.state = 'failed'; entry.reject(error)
|
|
49
|
+
entry.retryResult?.reject(error); entry.retryResult = null
|
|
50
|
+
onReady(entry.peer)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
entry.state = 'waiting'
|
|
54
|
+
entry.recovery ||= createPauseRecovery({
|
|
55
|
+
attempt: async signal => {
|
|
56
|
+
signal.throwIfAborted()
|
|
57
|
+
if (!current(entry)) return
|
|
58
|
+
entry.state = 'queued'; drain()
|
|
59
|
+
// Recovery waits for the actual preparation; no slot is held
|
|
60
|
+
// during backoff or a connectivity wait.
|
|
61
|
+
await new Promise((resolve, reject) => { entry.retryResult = { resolve, reject } })
|
|
62
|
+
},
|
|
63
|
+
retryable: isRetryable, onError: () => {}, online, onOnline, setTimer, clearTimer, random
|
|
64
|
+
})
|
|
65
|
+
if (entry.retryResult) { entry.retryResult.reject(error); entry.retryResult = null } else entry.recovery.start({ retryAt: error.retryAt })
|
|
66
|
+
}).finally(() => {
|
|
67
|
+
if (entry.state === 'ready') { entry.retryResult?.resolve(); entry.retryResult = null }
|
|
68
|
+
if (!current(entry)) { entry.retryResult?.resolve(); entry.retryResult = null }
|
|
69
|
+
running--
|
|
70
|
+
if (isBackground) background--
|
|
71
|
+
drain()
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
get: peer => entries.get(peer)?.value,
|
|
77
|
+
error: peer => entries.get(peer)?.state === 'failed' ? entries.get(peer).error : null,
|
|
78
|
+
reconcile (peers) {
|
|
79
|
+
const wanted = new Set(peers)
|
|
80
|
+
for (const [peer, entry] of entries) if (!wanted.has(peer)) { entries.delete(peer); cancel(entry) }
|
|
81
|
+
for (const peer of wanted) if (!entries.has(peer)) entries.set(peer, entryFor(peer))
|
|
82
|
+
drain()
|
|
83
|
+
},
|
|
84
|
+
setAvailable (value) {
|
|
85
|
+
enabled = value === true
|
|
86
|
+
if (!enabled) {
|
|
87
|
+
for (const [peer, entry] of entries) {
|
|
88
|
+
if (entry.state === 'ready') continue
|
|
89
|
+
cancel(entry)
|
|
90
|
+
const replacement = entryFor(peer)
|
|
91
|
+
replacement.priority = entry.priority
|
|
92
|
+
entries.set(peer, replacement)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
drain()
|
|
96
|
+
},
|
|
97
|
+
request (peer, { retry = false } = {}) {
|
|
98
|
+
if (closed || !entries.has(peer)) return Promise.reject(unavailable())
|
|
99
|
+
let entry = entries.get(peer)
|
|
100
|
+
if (retry && entry.state === 'failed') { cancel(entry); entries.set(peer, (entry = entryFor(peer))) }
|
|
101
|
+
entry.priority = true
|
|
102
|
+
drain()
|
|
103
|
+
return entry.promise
|
|
104
|
+
},
|
|
105
|
+
close () {
|
|
106
|
+
closed = true; enabled = false
|
|
107
|
+
for (const entry of entries.values()) cancel(entry)
|
|
108
|
+
entries.clear()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -3,8 +3,12 @@ import { base64ToBytes, bytesToBase64 } from '../../../base64/index.js'
|
|
|
3
3
|
// The library transport uses its Base64 signer contract. Conversion stays at
|
|
4
4
|
// this adapter; the injected launcher/vault channel always carries raw bytes.
|
|
5
5
|
export function messengerSigner (signer) {
|
|
6
|
+
// The adapter belongs to one account/channel generation. Share both the
|
|
7
|
+
// lookup in flight and its immutable result; failed permissions are retryable
|
|
8
|
+
// only when the owning session explicitly asks again.
|
|
9
|
+
let publicKey
|
|
6
10
|
const adapter = {
|
|
7
|
-
getPublicKey: () => signer.getPublicKey(),
|
|
11
|
+
getPublicKey: () => (publicKey ??= Promise.resolve().then(() => signer.getPublicKey()).catch(error => { publicKey = null; throw error })),
|
|
8
12
|
getRelays: () => signer.getRelays(),
|
|
9
13
|
signEvent: event => signer.signEvent(event),
|
|
10
14
|
nip44v3Encrypt: (peer, kind, scope, text) => signer.nip44v3.encrypt(peer, kind, scope, base64ToBytes(text).buffer),
|
|
@@ -4,7 +4,10 @@ import { createPrivateMessenger } from '../index.js'
|
|
|
4
4
|
import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/index.js'
|
|
5
5
|
import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
|
|
6
6
|
import { getIykcProofs } from '../../content-key/index.js'
|
|
7
|
-
import { onOnline } from '../../network/index.js'
|
|
7
|
+
import { isOnline, onOnline } from '../../network/index.js'
|
|
8
|
+
import { createPeerPreparation } from './helpers/peer-preparation.js'
|
|
9
|
+
import { createForegroundWork } from './helpers/foreground-work.js'
|
|
10
|
+
import { isRetryableRelayFailure } from '../../relay/index.js'
|
|
8
11
|
import { ValidationError } from '../../error/index.js'
|
|
9
12
|
import { decodeFileMetadata } from '../../nip94/index.js'
|
|
10
13
|
import { createChatOutbox } from './helpers/work-storage.js'
|
|
@@ -41,7 +44,7 @@ function messengerStatusSnapshot (value) {
|
|
|
41
44
|
return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
47
|
+
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
45
48
|
fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
46
49
|
fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
47
50
|
const reportedErrors = new WeakSet()
|
|
@@ -53,11 +56,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
53
56
|
onError(error)
|
|
54
57
|
}
|
|
55
58
|
const userSigner = messengerSigner(signer)
|
|
59
|
+
const foreground = createForegroundWork()
|
|
56
60
|
messageStorage ||= createEventStoreMessageStorage({ eventStore })
|
|
57
61
|
chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
|
|
58
62
|
const peers = new Set()
|
|
59
63
|
const channels = new Map()
|
|
60
|
-
const deniedPeers = new Set()
|
|
61
64
|
const entries = new Map()
|
|
62
65
|
const cancelled = new Set()
|
|
63
66
|
const sendControllers = new Map()
|
|
@@ -113,6 +116,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
113
116
|
let lifecycle = 0
|
|
114
117
|
let configuring = Promise.resolve()
|
|
115
118
|
let configurationFailed = false
|
|
119
|
+
let configureQueued = false
|
|
120
|
+
let configureWork
|
|
121
|
+
let messengerWork
|
|
122
|
+
let fileSetupWork
|
|
116
123
|
let sending
|
|
117
124
|
let repump
|
|
118
125
|
let draining
|
|
@@ -132,7 +139,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
132
139
|
let candidate
|
|
133
140
|
try {
|
|
134
141
|
candidate = await Messenger({
|
|
135
|
-
fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
|
|
142
|
+
fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait,
|
|
136
143
|
onMessageQueued: () => { if (messengerBinding === binding) return drain() },
|
|
137
144
|
onStateChanged: state => {
|
|
138
145
|
if (closed || messengerBinding !== binding) return
|
|
@@ -154,28 +161,41 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
154
161
|
}
|
|
155
162
|
}
|
|
156
163
|
|
|
164
|
+
const preparation = createPeerPreparation({
|
|
165
|
+
prepare: async (peer, signal) => {
|
|
166
|
+
const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
|
|
167
|
+
const pubkey = await scoped.getPublicKey()
|
|
168
|
+
signal.throwIfAborted()
|
|
169
|
+
const channel = { signer: scoped, pubkey, readerPubkey: pubkey, mode, seeders: seedersForPeer(peer) }
|
|
170
|
+
channels.set(peer, channel)
|
|
171
|
+
await schedule()
|
|
172
|
+
signal.throwIfAborted()
|
|
173
|
+
return channel
|
|
174
|
+
},
|
|
175
|
+
onReady: () => { drain(); pump() }, onError: reportError,
|
|
176
|
+
isRetryable: error => ['connection', 'transport', 'timeout'].includes(error?.category) && isRetryableRelayFailure(error),
|
|
177
|
+
online: _isOnline, onOnline: _onOnline, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random
|
|
178
|
+
})
|
|
179
|
+
async function ensureMessenger () {
|
|
180
|
+
if (!messenger && !messengerWork) {
|
|
181
|
+
const work = createMessenger()
|
|
182
|
+
messengerWork = work
|
|
183
|
+
work.finally(() => { if (messengerWork === work) messengerWork = null }).catch(() => {})
|
|
184
|
+
}
|
|
185
|
+
await messengerWork
|
|
186
|
+
}
|
|
157
187
|
async function configure () {
|
|
158
188
|
if (closed || !available) return
|
|
159
|
-
await ready()
|
|
160
189
|
const version = lifecycle
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
let channel = channels.get(peer)
|
|
165
|
-
if (!channel) {
|
|
166
|
-
try {
|
|
167
|
-
const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
|
|
168
|
-
channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
|
|
169
|
-
channels.set(peer, channel)
|
|
170
|
-
} catch (error) { if (!retryable(error)) deniedPeers.add(peer); reportError(error); continue }
|
|
171
|
-
}
|
|
172
|
-
if (closed || !available || version !== lifecycle) return
|
|
173
|
-
values.push(channel)
|
|
174
|
-
}
|
|
175
|
-
if (!messenger) await createMessenger()
|
|
190
|
+
await ready()
|
|
191
|
+
if (closed || !available || version !== lifecycle) return
|
|
192
|
+
await ensureMessenger()
|
|
176
193
|
if (!messenger) return
|
|
177
|
-
if (closed || !available
|
|
194
|
+
if (closed || !available) { await messenger.pause('signer'); return }
|
|
195
|
+
if (version !== lifecycle) { configureQueued = true; return }
|
|
196
|
+
const values = [...channels].filter(([peer]) => peers.has(peer)).map(([, channel]) => channel)
|
|
178
197
|
await messenger.update({ channels: values })
|
|
198
|
+
if (closed || !available || version !== lifecycle) return
|
|
179
199
|
if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
|
|
180
200
|
for (const [peer, list] of [...pendingPriorityRanges]) {
|
|
181
201
|
const channel = channels.get(peer)
|
|
@@ -187,7 +207,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
187
207
|
}
|
|
188
208
|
}
|
|
189
209
|
await messenger.resume('signer')
|
|
210
|
+
if (closed || !available || version !== lifecycle) return
|
|
211
|
+
preparation.setAvailable(true)
|
|
190
212
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
213
|
+
if (!fileSetupWork) {
|
|
214
|
+
fileSetupWork = setupFiles().catch(error => { fileSetupWork = null; if (!closed) reportError(error) })
|
|
215
|
+
}
|
|
216
|
+
fileSetupWork.then(() => { if (!closed && available) restoreDownloads() }).catch(reportError)
|
|
217
|
+
drain(); pump()
|
|
218
|
+
}
|
|
219
|
+
async function setupFiles () {
|
|
191
220
|
if (!fileTransfers) {
|
|
192
221
|
fileTransfers = FileTransfer({
|
|
193
222
|
messenger, onError: reportError,
|
|
@@ -206,9 +235,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
206
235
|
}
|
|
207
236
|
}
|
|
208
237
|
})
|
|
209
|
-
downloads = await openDownloads({ owner, signer })
|
|
210
|
-
for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
|
|
211
238
|
}
|
|
239
|
+
if (!downloads) {
|
|
240
|
+
const candidate = await openDownloads({ owner, signer })
|
|
241
|
+
try {
|
|
242
|
+
const intents = await candidate.list()
|
|
243
|
+
downloads = candidate
|
|
244
|
+
for (const entry of intents) downloadIntents.set(entry.id, entry)
|
|
245
|
+
} catch (error) { await candidate.close?.(); throw error }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function restoreDownloads () {
|
|
212
249
|
for (const entry of downloadIntents.values()) {
|
|
213
250
|
if (!peers.has(entry.file.peer) || activeDownloads.has(entry.id)) continue
|
|
214
251
|
const work = download(entry.file, { manual: true })
|
|
@@ -220,12 +257,37 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
220
257
|
.finally(() => { if (activeDownloads.get(entry.id) === work) activeDownloads.delete(entry.id) })
|
|
221
258
|
activeDownloads.set(entry.id, work)
|
|
222
259
|
}
|
|
223
|
-
|
|
260
|
+
pump()
|
|
224
261
|
}
|
|
225
262
|
function schedule () {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
work.then(
|
|
263
|
+
configureQueued = true
|
|
264
|
+
if (configureWork) return configureWork
|
|
265
|
+
const work = Promise.resolve().then(async () => {
|
|
266
|
+
try {
|
|
267
|
+
do {
|
|
268
|
+
configureQueued = false
|
|
269
|
+
await configure()
|
|
270
|
+
if (closed || !available) break
|
|
271
|
+
} while (configureQueued)
|
|
272
|
+
} finally { if (configureWork === work) configureWork = null }
|
|
273
|
+
})
|
|
274
|
+
configureWork = configuring = work
|
|
275
|
+
work.then(() => {
|
|
276
|
+
if (configureWork === work) configureWork = null
|
|
277
|
+
configurationFailed = false
|
|
278
|
+
}, error => {
|
|
279
|
+
if (configureWork === work) configureWork = null
|
|
280
|
+
configurationFailed = true
|
|
281
|
+
if (!closed) reportError(error)
|
|
282
|
+
}).catch(() => {})
|
|
283
|
+
return work
|
|
284
|
+
}
|
|
285
|
+
async function preparePeer (peer, { retry = true } = {}) {
|
|
286
|
+
if (typeof peer !== 'string' || !/^[0-9a-f]{64}$/i.test(peer)) throw new ValidationError('INVALID_PRIVATE_PEER')
|
|
287
|
+
if (closed || !available || !peers.has(peer)) throw new Error('CHAT_UNAVAILABLE')
|
|
288
|
+
const work = preparation.request(peer, { retry })
|
|
289
|
+
work.catch(() => {})
|
|
290
|
+
if (!messenger || configurationFailed) await schedule()
|
|
229
291
|
return work
|
|
230
292
|
}
|
|
231
293
|
async function drain () {
|
|
@@ -285,7 +347,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
285
347
|
}
|
|
286
348
|
}
|
|
287
349
|
async function publish (peer, event, signal) {
|
|
288
|
-
if (
|
|
350
|
+
if (preparation.error(peer)) throw preparation.error(peer)
|
|
289
351
|
if (!available || !peers.has(peer) || !channels.has(peer)) throw unavailableError(new Error('CHAT_UNAVAILABLE'), signal)
|
|
290
352
|
const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
|
|
291
353
|
let report
|
|
@@ -299,6 +361,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
299
361
|
await assertMessagePublished(report, event)
|
|
300
362
|
}
|
|
301
363
|
async function sendEntry (entry) {
|
|
364
|
+
const releaseForeground = foreground.enter()
|
|
302
365
|
entry.status = 'pending'; emit()
|
|
303
366
|
const controller = new AbortController()
|
|
304
367
|
sendControllers.set(entry.id, controller)
|
|
@@ -321,6 +384,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
321
384
|
await storage.put(entry, { existing: true })
|
|
322
385
|
}
|
|
323
386
|
}
|
|
387
|
+
if (entry.peer !== owner && !preparation.get(entry.peer)) {
|
|
388
|
+
if (preparation.error(entry.peer)) throw preparation.error(entry.peer)
|
|
389
|
+
preparation.request(entry.peer).catch(() => {})
|
|
390
|
+
return
|
|
391
|
+
}
|
|
324
392
|
if (entry.peer !== owner && messengerStatus?.paused) {
|
|
325
393
|
throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
|
|
326
394
|
}
|
|
@@ -356,7 +424,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
356
424
|
const event = entry.events[entry.index]
|
|
357
425
|
if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
|
|
358
426
|
await active()
|
|
359
|
-
|
|
427
|
+
// The final durable operation is removal. Avoid encrypting a checkpoint
|
|
428
|
+
// that would immediately be deleted; earlier context stages still checkpoint.
|
|
429
|
+
if (entry.index + 1 < entry.events.length) await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
|
|
360
430
|
}
|
|
361
431
|
await storage.remove(entry.id)
|
|
362
432
|
entries.delete(entry.id)
|
|
@@ -368,16 +438,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
368
438
|
if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
|
|
369
439
|
entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
370
440
|
await storage.put(entry, { existing: true }).catch(reportError)
|
|
371
|
-
if (entry.peer !== owner && error.retryWhenAvailable && messenger
|
|
441
|
+
if (entry.peer !== owner && error.retryWhenAvailable && messenger?.readStatus().paused) pausedSends.add(entry.id)
|
|
442
|
+
else if (isUnavailableSend(cause) && cause.pauseReasons?.length && available && !closed) {
|
|
443
|
+
// Release can race persistence of the failed attempt. Do not lose
|
|
444
|
+
// its notification and spend a retry delay after the pause is gone.
|
|
445
|
+
entry.failed = false
|
|
446
|
+
queueMicrotask(() => pump())
|
|
447
|
+
}
|
|
372
448
|
reportError(error)
|
|
373
449
|
// The failed wire event may be a quote or file chunk. Report the
|
|
374
450
|
// owning outbox item separately, without mutating the native error.
|
|
375
451
|
if (error.retryWhenOnline) offlineSends.add(entry.id)
|
|
376
|
-
if (error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
|
|
452
|
+
if (entry.failed && error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
|
|
377
453
|
if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
|
|
378
454
|
}
|
|
379
455
|
emit()
|
|
380
|
-
} finally { sendControllers.delete(entry.id) }
|
|
456
|
+
} finally { sendControllers.delete(entry.id); releaseForeground() }
|
|
381
457
|
}
|
|
382
458
|
function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
|
|
383
459
|
// Transient availability failures without an active messenger pause retain
|
|
@@ -425,7 +501,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
425
501
|
})
|
|
426
502
|
}
|
|
427
503
|
async function pump () {
|
|
428
|
-
if (!available || closed || !
|
|
504
|
+
if (!available || closed || !storage) return
|
|
429
505
|
// A retry that lands while a pump is already running must not be skipped
|
|
430
506
|
// just because that pass already walked past the entry.
|
|
431
507
|
if (sending) { repump = true; return }
|
|
@@ -436,6 +512,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
436
512
|
for (const entry of entries.values()) {
|
|
437
513
|
if (closed || !available) break
|
|
438
514
|
if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
|
|
515
|
+
if (entry.peer !== owner && entry.localSaved.every(Boolean) && !preparation.get(entry.peer) && !preparation.error(entry.peer)) {
|
|
516
|
+
preparation.request(entry.peer).catch(() => {})
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
439
519
|
if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
|
|
440
520
|
await sendEntry(entry)
|
|
441
521
|
}
|
|
@@ -450,7 +530,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
450
530
|
const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
|
|
451
531
|
async function download (file, options = {}) {
|
|
452
532
|
if (file.peer === owner) return file
|
|
453
|
-
if (!available || closed || !peers.has(file.peer)
|
|
533
|
+
if (!available || closed || !peers.has(file.peer)) throw new Error('CHAT_UNAVAILABLE')
|
|
534
|
+
await preparePeer(file.peer)
|
|
535
|
+
await fileSetupWork
|
|
536
|
+
if (!fileTransfers || !downloads) throw new Error('CHAT_UNAVAILABLE')
|
|
454
537
|
const id = intentId(file)
|
|
455
538
|
if (options.manual) {
|
|
456
539
|
const epoch = downloadEpochs.get(id) || 0
|
|
@@ -475,7 +558,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
475
558
|
await writeDownloadIntent(() => downloads?.remove(id))
|
|
476
559
|
}
|
|
477
560
|
return {
|
|
478
|
-
download, cancelDownload,
|
|
561
|
+
download, cancelDownload, preparePeer,
|
|
479
562
|
// Peers-based so callers can prefetch as soon as a chat route opens, before
|
|
480
563
|
// the shared-key channel exists. When group chats get real membership, add
|
|
481
564
|
// a thin prefetchChannelContentKeys(channelPubkey) wrapper that resolves
|
|
@@ -503,20 +586,28 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
503
586
|
if (type === 'unread-page' && since === undefined) throw new TypeError('PRIORITY_SINCE_REQUIRED')
|
|
504
587
|
const request = { since, until, type }
|
|
505
588
|
const channel = channels.get(peer)
|
|
506
|
-
if (channel && typeof messenger?.prioritizeRange === 'function') {
|
|
589
|
+
if (channel && preparation.get(peer) && !pendingPriorityRanges.has(peer) && typeof messenger?.prioritizeRange === 'function') {
|
|
507
590
|
try { return await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error); return false }
|
|
508
591
|
}
|
|
509
592
|
const list = pendingPriorityRanges.get(peer) || []
|
|
510
593
|
list.push(request)
|
|
511
594
|
pendingPriorityRanges.set(peer, list)
|
|
595
|
+
if (channel && available) schedule().catch(() => {})
|
|
512
596
|
return true
|
|
513
597
|
},
|
|
514
598
|
async setPeers (values) {
|
|
515
599
|
const next = new Set([...values].filter(peer => peer !== owner))
|
|
516
600
|
if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
|
|
517
601
|
peers.clear(); for (const peer of next) peers.add(peer)
|
|
518
|
-
|
|
519
|
-
|
|
602
|
+
preparation.reconcile(peers)
|
|
603
|
+
for (const peer of channels.keys()) {
|
|
604
|
+
if (!peers.has(peer)) {
|
|
605
|
+
channels.delete(peer); pendingPriorityRanges.delete(peer)
|
|
606
|
+
for (const entry of entries.values()) {
|
|
607
|
+
if (entry.peer === peer) sendControllers.get(entry.id)?.abort()
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
520
611
|
return schedule()
|
|
521
612
|
},
|
|
522
613
|
async setAvailable (value) {
|
|
@@ -527,31 +618,39 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
527
618
|
if (available) resetSendRetry()
|
|
528
619
|
else clearSendRetry()
|
|
529
620
|
if (changed) lifecycle++
|
|
530
|
-
if (!available) {
|
|
621
|
+
if (!available) {
|
|
622
|
+
preparation.setAvailable(false)
|
|
623
|
+
for (const peer of channels.keys()) if (!preparation.get(peer)) channels.delete(peer)
|
|
624
|
+
inboxRecovery.stop(); await messenger?.pause('signer'); return
|
|
625
|
+
}
|
|
531
626
|
await ready()
|
|
532
627
|
for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
|
|
533
628
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
534
|
-
if (!changed && messenger && !configurationFailed)
|
|
629
|
+
if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
|
|
535
630
|
return schedule()
|
|
536
631
|
},
|
|
537
632
|
async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
|
|
538
633
|
if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
634
|
+
const releaseForeground = foreground.enter()
|
|
635
|
+
try {
|
|
636
|
+
await ready()
|
|
637
|
+
const main = wireEvent(event, owner)
|
|
638
|
+
const id = getEventHash(main)
|
|
639
|
+
const events = [...context.map(value => wireEvent(value, owner)), main]
|
|
640
|
+
const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
|
|
641
|
+
const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
|
|
642
|
+
await storage.put(entry)
|
|
643
|
+
entries.set(id, entry); emit(); pump()
|
|
644
|
+
if (peer !== owner) preparePeer(peer).catch(() => {})
|
|
645
|
+
return id
|
|
646
|
+
} finally { releaseForeground() }
|
|
548
647
|
},
|
|
549
648
|
async retry (id) {
|
|
550
649
|
offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
|
|
551
650
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
552
651
|
await ready()
|
|
553
652
|
const entry = entries.get(id)
|
|
554
|
-
if (entry) { entry.failed = false; resetSendRetry(); if (
|
|
653
|
+
if (entry) { entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
|
|
555
654
|
},
|
|
556
655
|
async cancel (id) {
|
|
557
656
|
cancelled.add(id)
|
|
@@ -566,9 +665,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
566
665
|
return (closeWork ??= (async () => {
|
|
567
666
|
closed = true; available = false; lifecycle++
|
|
568
667
|
stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
|
|
569
|
-
clearSendRetry(); inboxRecovery.stop(); pausedSends.clear()
|
|
668
|
+
clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
|
|
570
669
|
for (const controller of sendControllers.values()) controller.abort()
|
|
571
|
-
await Promise.allSettled([configuring, initialized])
|
|
670
|
+
await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
|
|
572
671
|
await messenger?.pause('closed')
|
|
573
672
|
if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
|
|
574
673
|
await messenger?.close()
|