libp2r2p 0.11.21 → 0.11.23
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 +72 -0
- package/content-key/index.js +1 -1
- package/content-key/services/iykc-proof.js +41 -2
- package/package.json +1 -1
- package/private-channel/helpers/decryption-error.js +15 -0
- package/private-channel/index.js +153 -27
- package/private-message/index.js +5 -1
- package/private-messenger/index.js +338 -150
- package/private-messenger/services/decryption-scheduler.js +135 -0
- package/private-messenger/services/migrate-message-queue.js +95 -0
- package/private-messenger/services/pending-decryption.js +126 -0
- package/private-messenger/services/storage-maintenance.js +1 -0
- package/private-messenger/session/README.md +19 -0
- package/private-messenger/session/helpers/foreground-work.js +6 -1
- package/private-messenger/session/index.js +26 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Admission only: never wait while holding an inbox operation, IDB transaction
|
|
2
|
+
// or the cross-instance retry lock. A busy channel cannot stall another one.
|
|
3
|
+
export function createDecryptionScheduler ({ ready = true, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
4
|
+
const channels = new Map()
|
|
5
|
+
const readyWaiters = new Set()
|
|
6
|
+
let timer
|
|
7
|
+
let running = false
|
|
8
|
+
let closed
|
|
9
|
+
let foreground = false
|
|
10
|
+
let foregroundAt = now()
|
|
11
|
+
let priority = null
|
|
12
|
+
let previous
|
|
13
|
+
let batch = 0
|
|
14
|
+
const stateFor = pubkey => {
|
|
15
|
+
if (!channels.has(pubkey)) channels.set(pubkey, { active: 0, at: now(), deadline: null, jobs: [] })
|
|
16
|
+
return channels.get(pubkey)
|
|
17
|
+
}
|
|
18
|
+
const quietAt = state => state.active || foreground ? Infinity : Math.max(state.at, foregroundAt) + 250
|
|
19
|
+
const schedule = () => {
|
|
20
|
+
if (timer !== undefined) clearTimer(timer)
|
|
21
|
+
timer = undefined
|
|
22
|
+
if (closed || !ready || running) return
|
|
23
|
+
const pending = [...channels.values()].filter(state => state.jobs.length)
|
|
24
|
+
if (!pending.length) return
|
|
25
|
+
const at = Math.min(...pending.map(state => Math.min(quietAt(state), state.deadline)))
|
|
26
|
+
timer = setTimer(pump, Math.max(0, at - now()))
|
|
27
|
+
}
|
|
28
|
+
async function pump () {
|
|
29
|
+
timer = undefined
|
|
30
|
+
if (closed || !ready || running) return
|
|
31
|
+
const time = now()
|
|
32
|
+
const eligible = [...channels].filter(([, state]) => state.jobs.length && Math.min(quietAt(state), state.deadline) <= time)
|
|
33
|
+
eligible.sort(([a, x], [b, y]) => {
|
|
34
|
+
const overdue = Number(y.deadline <= time) - Number(x.deadline <= time)
|
|
35
|
+
if (overdue) return overdue
|
|
36
|
+
if (x.deadline <= time && x.deadline !== y.deadline) return x.deadline - y.deadline
|
|
37
|
+
if (a === previous && batch >= 4) return 1
|
|
38
|
+
if (b === previous && batch >= 4) return -1
|
|
39
|
+
return Number(b === priority) - Number(a === priority)
|
|
40
|
+
})
|
|
41
|
+
if (!eligible.length) { schedule(); return }
|
|
42
|
+
const [pubkey, state] = eligible[0]
|
|
43
|
+
const job = state.jobs.shift()
|
|
44
|
+
running = true
|
|
45
|
+
if (previous === pubkey) batch++
|
|
46
|
+
else { previous = pubkey; batch = 1 }
|
|
47
|
+
try {
|
|
48
|
+
job.signal?.throwIfAborted()
|
|
49
|
+
job.resolve(await job.work())
|
|
50
|
+
} catch (error) { job.reject(error) } finally {
|
|
51
|
+
job.signal?.removeEventListener('abort', job.abort)
|
|
52
|
+
state.deadline = now() + 5000
|
|
53
|
+
running = false
|
|
54
|
+
// Always use a new task, including between records in the same batch.
|
|
55
|
+
schedule()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const rejectJobs = (state, error) => {
|
|
59
|
+
for (const job of state.jobs.splice(0)) {
|
|
60
|
+
job.signal?.removeEventListener('abort', job.abort)
|
|
61
|
+
job.reject(error)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
activity (pubkey) {
|
|
66
|
+
if (closed) return () => {}
|
|
67
|
+
const state = stateFor(pubkey)
|
|
68
|
+
state.active++; state.at = now(); schedule()
|
|
69
|
+
let released = false
|
|
70
|
+
return () => {
|
|
71
|
+
if (released) return
|
|
72
|
+
released = true
|
|
73
|
+
state.active--; state.at = now(); schedule()
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
foreground (active) { foreground = active; foregroundAt = now(); schedule() },
|
|
77
|
+
priority (pubkey) { priority = pubkey; schedule() },
|
|
78
|
+
setReady (value) {
|
|
79
|
+
if (ready === value || closed) return
|
|
80
|
+
ready = value
|
|
81
|
+
if (ready) {
|
|
82
|
+
for (const state of channels.values()) { state.at = now(); state.deadline = state.jobs.length ? now() + 5000 : null }
|
|
83
|
+
for (const wake of [...readyWaiters]) wake()
|
|
84
|
+
}
|
|
85
|
+
schedule()
|
|
86
|
+
},
|
|
87
|
+
async waitReady (signal) {
|
|
88
|
+
while (true) {
|
|
89
|
+
signal?.throwIfAborted()
|
|
90
|
+
if (closed) throw closed
|
|
91
|
+
if (ready) return
|
|
92
|
+
await new Promise((resolve, reject) => {
|
|
93
|
+
const finish = () => {
|
|
94
|
+
readyWaiters.delete(finish); signal?.removeEventListener('abort', finish)
|
|
95
|
+
if (closed || signal?.aborted) reject(closed || signal.reason)
|
|
96
|
+
else resolve()
|
|
97
|
+
}
|
|
98
|
+
readyWaiters.add(finish); signal?.addEventListener('abort', finish, { once: true })
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
run (pubkey, work, signal) {
|
|
103
|
+
signal?.throwIfAborted()
|
|
104
|
+
if (closed) return Promise.reject(closed)
|
|
105
|
+
const state = stateFor(pubkey)
|
|
106
|
+
state.deadline ??= now() + 5000
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const job = { work, resolve, reject, signal }
|
|
109
|
+
job.abort = () => {
|
|
110
|
+
const index = state.jobs.indexOf(job)
|
|
111
|
+
if (index < 0) return
|
|
112
|
+
state.jobs.splice(index, 1); reject(signal.reason); schedule()
|
|
113
|
+
}
|
|
114
|
+
signal?.addEventListener('abort', job.abort, { once: true })
|
|
115
|
+
state.jobs.push(job); schedule()
|
|
116
|
+
})
|
|
117
|
+
},
|
|
118
|
+
remove (pubkey) {
|
|
119
|
+
const state = channels.get(pubkey)
|
|
120
|
+
if (state) rejectJobs(state, new Error('PRIVATE_CHANNEL_CHANGED'))
|
|
121
|
+
channels.delete(pubkey); schedule()
|
|
122
|
+
},
|
|
123
|
+
finish (pubkey) {
|
|
124
|
+
const state = channels.get(pubkey)
|
|
125
|
+
if (state && !state.jobs.length) state.deadline = null
|
|
126
|
+
},
|
|
127
|
+
close () {
|
|
128
|
+
closed ||= new Error('PRIVATE_MESSENGER_CLOSED')
|
|
129
|
+
if (timer !== undefined) clearTimer(timer)
|
|
130
|
+
for (const state of channels.values()) rejectJobs(state, closed)
|
|
131
|
+
channels.clear()
|
|
132
|
+
for (const wake of [...readyWaiters]) wake()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { deliveryInfo } from '../../private-channel/helpers/rumor.js'
|
|
2
|
+
|
|
3
|
+
const encoder = new TextEncoder()
|
|
4
|
+
const indexName = 'byChannelTypeEventId'
|
|
5
|
+
const oldPath = ['item.channelPubkey', 'item.type', 'item.event.id']
|
|
6
|
+
const newPath = [...oldPath, 'item.provenance']
|
|
7
|
+
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
8
|
+
|
|
9
|
+
function open (indexedDB, name, version, upgrade) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const request = indexedDB.open(name, version)
|
|
12
|
+
let rejected = false
|
|
13
|
+
request.onblocked = () => { rejected = true; reject(new Error('IDB_DATABASE_BLOCKED')) }
|
|
14
|
+
request.onerror = () => reject(request.error)
|
|
15
|
+
request.onupgradeneeded = () => {
|
|
16
|
+
if (rejected) { request.transaction.abort(); return }
|
|
17
|
+
try { upgrade?.(request.result, request.transaction) } catch (error) {
|
|
18
|
+
request.transaction.abort(); reject(error)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
request.onsuccess = () => {
|
|
22
|
+
const db = request.result
|
|
23
|
+
db.onversionchange = () => db.close()
|
|
24
|
+
if (rejected) db.close()
|
|
25
|
+
else resolve(db)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function matchesLegacy (items) {
|
|
31
|
+
if (!items.indexNames.contains(indexName)) return false
|
|
32
|
+
const index = items.index(indexName)
|
|
33
|
+
return equal(items.keyPath, 'position') && equal(index.keyPath, oldPath) && index.unique && !index.multiEntry &&
|
|
34
|
+
items.indexNames.contains('byChannel') && items.index('byChannel').keyPath === 'item.channelPubkey' &&
|
|
35
|
+
!items.index('byChannel').unique && !items.index('byChannel').multiEntry
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The generic queue remains strict. Only this historical messenger schema is
|
|
39
|
+
// migrated, atomically with its records and capacity accounting.
|
|
40
|
+
export async function migrateMessageQueue (indexedDB, prefix) {
|
|
41
|
+
if (!indexedDB?.open) throw new Error('IDB_UNAVAILABLE')
|
|
42
|
+
const name = `${prefix}:idb-queue`
|
|
43
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
44
|
+
const db = await open(indexedDB, name)
|
|
45
|
+
if (!db.objectStoreNames.contains('items') || !db.objectStoreNames.contains('state')) { db.close(); return }
|
|
46
|
+
const tx = db.transaction(['items', 'state'], 'readonly')
|
|
47
|
+
const legacy = matchesLegacy(tx.objectStore('items')) && tx.objectStore('state').keyPath === 'key'
|
|
48
|
+
const version = db.version + 1
|
|
49
|
+
await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onabort = () => reject(tx.error) })
|
|
50
|
+
db.close()
|
|
51
|
+
if (!legacy) return
|
|
52
|
+
try {
|
|
53
|
+
const upgraded = await open(indexedDB, name, version, (_db, tx) => {
|
|
54
|
+
const items = tx.objectStore('items')
|
|
55
|
+
if (!matchesLegacy(items)) return
|
|
56
|
+
items.deleteIndex(indexName)
|
|
57
|
+
items.createIndex(indexName, newPath, { unique: true })
|
|
58
|
+
let delta = 0
|
|
59
|
+
const cursor = items.openCursor()
|
|
60
|
+
cursor.onsuccess = () => {
|
|
61
|
+
try {
|
|
62
|
+
const row = cursor.result
|
|
63
|
+
if (!row) {
|
|
64
|
+
const state = tx.objectStore('state')
|
|
65
|
+
const request = state.get('queue')
|
|
66
|
+
request.onsuccess = () => {
|
|
67
|
+
if (request.result) state.put({ ...request.result, usedBytes: request.result.usedBytes + delta })
|
|
68
|
+
}
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const record = row.value
|
|
72
|
+
const item = { ...record.item }
|
|
73
|
+
const sender = item.senderPubkey || item.meta?.senderPubkey || item.meta?.router?.tags?.find(tag => tag[0] === 'f')?.[1] || ''
|
|
74
|
+
const info = deliveryInfo(item.event, sender)
|
|
75
|
+
item.senderPubkey = sender
|
|
76
|
+
item.provenance = item.provenance === 'hearsay' ? 'hearsay' : info.provenance
|
|
77
|
+
let byteSize = 0
|
|
78
|
+
while (true) {
|
|
79
|
+
const size = encoder.encode(JSON.stringify({ byteSize, item })).length
|
|
80
|
+
if (size === byteSize) break
|
|
81
|
+
byteSize = size
|
|
82
|
+
}
|
|
83
|
+
delta += byteSize - record.byteSize
|
|
84
|
+
row.update({ ...record, item, byteSize })
|
|
85
|
+
row.continue()
|
|
86
|
+
} catch { tx.abort() }
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
upgraded.close()
|
|
90
|
+
return
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error?.name !== 'VersionError' || attempt === 2) throw error
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha2.js'
|
|
2
|
+
import { bytesToHex } from '../../base16/index.js'
|
|
3
|
+
import { base64ToBytes } from '../../base64/index.js'
|
|
4
|
+
import { createQueue } from '../../idb-queue/index.js'
|
|
5
|
+
import { isDeferredDecryptionError } from '../../private-channel/helpers/decryption-error.js'
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder()
|
|
8
|
+
const decoder = new TextDecoder()
|
|
9
|
+
export const PENDING_DECRYPTION_MAX_BYTES = 16 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
function storedBytes (item) {
|
|
12
|
+
let byteSize = 0
|
|
13
|
+
while (true) {
|
|
14
|
+
const size = encoder.encode(JSON.stringify({ byteSize, item })).length
|
|
15
|
+
if (size === byteSize) return size
|
|
16
|
+
byteSize = size
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function packetSnapshot (packet) {
|
|
21
|
+
const event = (value, router = false) => {
|
|
22
|
+
const copy = Object.fromEntries(['id', 'kind', 'pubkey', 'created_at', 'content', 'sig'].filter(key => value?.[key] !== undefined).map(key => [key, value[key]]))
|
|
23
|
+
if (value?.tags) {
|
|
24
|
+
copy.tags = router
|
|
25
|
+
? value.tags.filter(tag => ['f', 'p', 'imkc', 'c', 'i'].includes(tag[0])).map(tag => tag.slice(0, tag[0] === 'imkc' || tag[0] === 'c' ? 3 : 2))
|
|
26
|
+
: structuredClone(value.tags)
|
|
27
|
+
}
|
|
28
|
+
return copy
|
|
29
|
+
}
|
|
30
|
+
return { format: packet.format, channelPubkey: packet.channelPubkey, outer: event(packet.outer), ...(packet.router ? { router: event(packet.router, true) } : {}) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Stable across live, history and synthesized recovery wrappers.
|
|
34
|
+
function packetId (record) {
|
|
35
|
+
const rows = record.format === 'routerRow_v1'
|
|
36
|
+
? decoder.decode(base64ToBytes(record.router.content)).split('\n').filter(Boolean).map(line => {
|
|
37
|
+
const row = JSON.parse(line)
|
|
38
|
+
return row.length === 1 ? row : [row[0], row[1], row[2] || '', row[3] || '']
|
|
39
|
+
})
|
|
40
|
+
: null
|
|
41
|
+
const value = rows ? [record.channelPubkey, record.router.pubkey, record.router.tags.filter(tag => tag[0] !== 'c'), rows] : [record.channelPubkey, record.outer.id]
|
|
42
|
+
return bytesToHex(sha256(encoder.encode(JSON.stringify(value))))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = PENDING_DECRYPTION_MAX_BYTES, report = () => {} }) {
|
|
46
|
+
const queue = await createQueue({
|
|
47
|
+
prefix: `${prefix}:pending-decryption`, indexedDB, maxBytes, evictionPolicy: 'fifo',
|
|
48
|
+
indexes: { byId: { keyPath: 'id', unique: true }, byChannel: 'channelPubkey' }
|
|
49
|
+
})
|
|
50
|
+
let tail = Promise.resolve()
|
|
51
|
+
let timer
|
|
52
|
+
let counts = {}
|
|
53
|
+
const flush = () => {
|
|
54
|
+
clearTimeout(timer); timer = null
|
|
55
|
+
if (Object.keys(counts).length) {
|
|
56
|
+
const summary = counts; counts = {}
|
|
57
|
+
report({ action: 'summary', counts: summary })
|
|
58
|
+
console.debug('private-messenger pending decryption', JSON.stringify(summary))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const serial = work => { const result = tail.then(work); tail = result.catch(() => {}); return result }
|
|
62
|
+
const notify = (action, detail) => {
|
|
63
|
+
report({ action, ...detail })
|
|
64
|
+
const key = `${action}:${detail.code || detail.reason || ''}`
|
|
65
|
+
counts[key] = { count: (counts[key]?.count || 0) + 1, example: detail }
|
|
66
|
+
if (!timer) { timer = setTimeout(flush, 1000); timer.unref?.() }
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
queue,
|
|
70
|
+
has: record => serial(() => queue.someBy('byId', packetId(packetSnapshot(record)))),
|
|
71
|
+
save ({ error, record }) {
|
|
72
|
+
record = packetSnapshot(record)
|
|
73
|
+
return serial(async () => {
|
|
74
|
+
const id = packetId(record)
|
|
75
|
+
if (await queue.someBy('byId', id)) return true
|
|
76
|
+
const item = { id, channelPubkey: record.channelPubkey, packet: structuredClone(record), code: error.code, macAttempts: error.code === 'INVALID_MAC' ? 1 : 0 }
|
|
77
|
+
const requiredBytes = storedBytes(item)
|
|
78
|
+
if (requiredBytes > maxBytes) {
|
|
79
|
+
notify('discarded', { reason: 'oversized', channelPubkey: item.channelPubkey, id }); return true
|
|
80
|
+
}
|
|
81
|
+
const before = await queue.getCapacity()
|
|
82
|
+
try { await queue.enqueue(item) } catch (error) {
|
|
83
|
+
if (error.name === 'ConstraintError') return true
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
const after = await queue.getCapacity()
|
|
87
|
+
const evictedBytes = Math.max(0, before.usedBytes + requiredBytes - after.usedBytes)
|
|
88
|
+
if (evictedBytes) notify('discarded', { reason: 'capacity', bytes: evictedBytes })
|
|
89
|
+
const coordinates = Object.fromEntries(['eventId', 'eventKind', 'senderPubkey', 'receiverPubkey', 'ownContentPubkey', 'peerContentPubkey', 'hasOwnContentKey']
|
|
90
|
+
.filter(key => ['string', 'boolean', 'number'].includes(typeof error[key])).map(key => [key, error[key]]))
|
|
91
|
+
notify('deferred', { ...coordinates, code: error.code, stage: error.stage, id, channelPubkey: item.channelPubkey, capacityBytesBefore: before.usedBytes, capacityBytesAfter: after.usedBytes })
|
|
92
|
+
return true
|
|
93
|
+
})
|
|
94
|
+
},
|
|
95
|
+
async records ({ force = false } = {}) {
|
|
96
|
+
await tail
|
|
97
|
+
const records = []
|
|
98
|
+
for await (const item of queue.storedItems()) if (force || item.macAttempts < 3) records.push(item)
|
|
99
|
+
return records
|
|
100
|
+
},
|
|
101
|
+
current (item, { force = false } = {}) {
|
|
102
|
+
return serial(async () => {
|
|
103
|
+
const stored = await queue.getBy('byId', item.id)
|
|
104
|
+
return stored && (force || stored.macAttempts < 3) ? stored : null
|
|
105
|
+
})
|
|
106
|
+
},
|
|
107
|
+
recovered (item) {
|
|
108
|
+
return serial(async () => {
|
|
109
|
+
await queue.removeBy('byId', item.id)
|
|
110
|
+
notify('recovered', { id: item.id, channelPubkey: item.channelPubkey })
|
|
111
|
+
})
|
|
112
|
+
},
|
|
113
|
+
failed (item, error) {
|
|
114
|
+
return serial(async () => {
|
|
115
|
+
if (!isDeferredDecryptionError(error)) throw error
|
|
116
|
+
const current = await queue.getBy('byId', item.id)
|
|
117
|
+
if (!current) return
|
|
118
|
+
const updated = { ...current, code: error.code, macAttempts: current.macAttempts + (error.code === 'INVALID_MAC' ? 1 : 0) }
|
|
119
|
+
await queue.putBy('byId', updated, { existingOnly: true })
|
|
120
|
+
if (updated.macAttempts >= 3 && item.macAttempts < 3) notify('suspended', { id: item.id, code: error.code, channelPubkey: item.channelPubkey })
|
|
121
|
+
})
|
|
122
|
+
},
|
|
123
|
+
clearChannel: channel => serial(() => queue.removeBy('byChannel', channel)),
|
|
124
|
+
async close () { await tail; flush(); await queue.close() }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -185,6 +185,7 @@ function storageDatabaseNames (userPubkey) {
|
|
|
185
185
|
const prefix = `libp2r2p:private-messenger:${userPubkey}`
|
|
186
186
|
return [
|
|
187
187
|
`${prefix}:idb-queue`,
|
|
188
|
+
`${prefix}:pending-decryption:idb-queue`,
|
|
188
189
|
`${prefix}:seeds:idb-queue`,
|
|
189
190
|
`${prefix}:file-seeds:idb-queue`,
|
|
190
191
|
`${prefix}:file-authorizations:idb-queue`,
|
|
@@ -4,6 +4,25 @@
|
|
|
4
4
|
outbox and IRFS attachments without presentation state. It builds on
|
|
5
5
|
PrivateMessenger and the file coordinator, not another recovery protocol.
|
|
6
6
|
|
|
7
|
+
`retryPendingDecryptions({ force = false } = {})` forwards a background pass to
|
|
8
|
+
the account messenger while available. Call after committed content-key changes;
|
|
9
|
+
ordinary availability recovery already requests a pass. Ciphertext stays in the
|
|
10
|
+
separate bounded FIFO until admitted to the normal inbox, evicted or explicitly
|
|
11
|
+
cleared. This does not retry application storage/permission failures as crypto.
|
|
12
|
+
|
|
13
|
+
Construction accepts `pendingDecryptionReady` (default `true`). Pass `false` to
|
|
14
|
+
hold pending queue reads/replay until `setPendingDecryptionReady(true)` releases
|
|
15
|
+
them after initial presentation. This never pauses live reception, sends or
|
|
16
|
+
channel preparation. Recovery waits for 250 ms of channel inactivity; a five-second
|
|
17
|
+
maximum admits one packet during sustained activity. It yields between records,
|
|
18
|
+
with at most four consecutive preferred records before another eligible channel.
|
|
19
|
+
`setPendingDecryptionPriority(peer | null)` retains a peer preference through
|
|
20
|
+
preparation; self-chat/`null` or removal clear it. Deadlines prevent preference
|
|
21
|
+
from starving other channels. Account unavailability/close still cancel recovery;
|
|
22
|
+
`force` bypasses only the MAC attempt limit. Awaiting the retry API awaits its
|
|
23
|
+
scheduled pass, so do not await it on the interface's critical startup path.
|
|
24
|
+
|
|
25
|
+
|
|
7
26
|
Provide `owner`, a byte-based NIP-44 v3 `signer` with `withSharedKey`, and either
|
|
8
27
|
`eventStore` or a `messageStorage.save(event, { peerPubkey, hearsay })` adapter.
|
|
9
28
|
Optional `chunkStorage` and `recoveryStorage` (`seeds`, `authorizations`) select
|
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
export function createForegroundWork () {
|
|
4
4
|
let active = 0
|
|
5
5
|
const waiters = new Set()
|
|
6
|
+
const observers = new Set()
|
|
7
|
+
const notify = () => { for (const listener of observers) listener(active > 0) }
|
|
6
8
|
const lifetime = new AbortController()
|
|
7
9
|
return {
|
|
8
10
|
enter () {
|
|
9
11
|
active++
|
|
12
|
+
notify()
|
|
10
13
|
let released = false
|
|
11
14
|
return () => {
|
|
12
15
|
if (released) return
|
|
13
16
|
released = true
|
|
14
17
|
if (--active === 0) for (const wake of [...waiters]) wake()
|
|
18
|
+
notify()
|
|
15
19
|
}
|
|
16
20
|
},
|
|
17
21
|
wait (signal) {
|
|
@@ -24,6 +28,7 @@ export function createForegroundWork () {
|
|
|
24
28
|
combined.addEventListener('abort', finish, { once: true })
|
|
25
29
|
})
|
|
26
30
|
},
|
|
27
|
-
|
|
31
|
+
observe (listener) { observers.add(listener); listener(active > 0); return () => observers.delete(listener) },
|
|
32
|
+
close () { lifetime.abort(); waiters.clear(); observers.clear() }
|
|
28
33
|
}
|
|
29
34
|
}
|
|
@@ -44,9 +44,11 @@ function messengerStatusSnapshot (value) {
|
|
|
44
44
|
return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
|
|
45
45
|
}
|
|
46
46
|
|
|
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' }) }) {
|
|
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, pendingDecryptionReady = 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' }) }) {
|
|
48
48
|
fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
49
49
|
fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
50
|
+
if (typeof pendingDecryptionReady !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
|
|
51
|
+
let pendingDecryptionPeer = null
|
|
50
52
|
const reportedErrors = new WeakSet()
|
|
51
53
|
const reportError = error => {
|
|
52
54
|
if (error && (typeof error === 'object' || typeof error === 'function')) {
|
|
@@ -139,7 +141,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
139
141
|
let candidate
|
|
140
142
|
try {
|
|
141
143
|
candidate = await Messenger({
|
|
142
|
-
fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait,
|
|
144
|
+
fallbackRelays, fallbackDelayMs, pendingDecryptionReady, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait, _observeForeground: foreground.observe,
|
|
143
145
|
onMessageQueued: () => { if (messengerBinding === binding) return drain() },
|
|
144
146
|
onStateChanged: state => {
|
|
145
147
|
if (closed || messengerBinding !== binding) return
|
|
@@ -152,6 +154,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
152
154
|
if (typeof candidate?.readStatus !== 'function' || !notified) throw new ValidationError('MESSENGER_STATE_CONTRACT_REQUIRED')
|
|
153
155
|
const status = messengerStatusSnapshot(candidate.readStatus())
|
|
154
156
|
messenger = candidate
|
|
157
|
+
messenger.setPendingDecryptionReady?.(pendingDecryptionReady)
|
|
158
|
+
messenger.setPendingDecryptionPriority?.(channels.get(pendingDecryptionPeer)?.pubkey ?? null)
|
|
155
159
|
stateChanged(status)
|
|
156
160
|
} catch (error) {
|
|
157
161
|
messengerBinding = null
|
|
@@ -168,6 +172,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
168
172
|
signal.throwIfAborted()
|
|
169
173
|
const channel = { signer: scoped, pubkey, readerPubkey: pubkey, mode, seeders: seedersForPeer(peer) }
|
|
170
174
|
channels.set(peer, channel)
|
|
175
|
+
if (peer === pendingDecryptionPeer) messenger?.setPendingDecryptionPriority?.(channel.pubkey)
|
|
171
176
|
await schedule()
|
|
172
177
|
signal.throwIfAborted()
|
|
173
178
|
return channel
|
|
@@ -558,6 +563,21 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
558
563
|
await writeDownloadIntent(() => downloads?.remove(id))
|
|
559
564
|
}
|
|
560
565
|
return {
|
|
566
|
+
setPendingDecryptionReady (ready) {
|
|
567
|
+
if (typeof ready !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
|
|
568
|
+
pendingDecryptionReady = ready
|
|
569
|
+
if (!closed) messenger?.setPendingDecryptionReady?.(ready)
|
|
570
|
+
},
|
|
571
|
+
setPendingDecryptionPriority (peer) {
|
|
572
|
+
if (peer !== null && !/^[0-9a-f]{64}$/.test(peer || '')) throw new ValidationError('INVALID_PENDING_DECRYPTION_PRIORITY')
|
|
573
|
+
pendingDecryptionPeer = peer === owner ? null : peer
|
|
574
|
+
if (!closed) messenger?.setPendingDecryptionPriority?.(channels.get(pendingDecryptionPeer)?.pubkey ?? null)
|
|
575
|
+
},
|
|
576
|
+
async retryPendingDecryptions (options) {
|
|
577
|
+
if (closed || !available) return
|
|
578
|
+
await ensureMessenger()
|
|
579
|
+
if (!closed && available) return messenger?.retryPendingDecryptions(options)
|
|
580
|
+
},
|
|
561
581
|
download, cancelDownload, preparePeer,
|
|
562
582
|
// Peers-based so callers can prefetch as soon as a chat route opens, before
|
|
563
583
|
// the shared-key channel exists. When group chats get real membership, add
|
|
@@ -600,6 +620,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
600
620
|
if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
|
|
601
621
|
peers.clear(); for (const peer of next) peers.add(peer)
|
|
602
622
|
preparation.reconcile(peers)
|
|
623
|
+
if (pendingDecryptionPeer && !peers.has(pendingDecryptionPeer)) {
|
|
624
|
+
pendingDecryptionPeer = null
|
|
625
|
+
messenger?.setPendingDecryptionPriority?.(null)
|
|
626
|
+
}
|
|
603
627
|
for (const peer of channels.keys()) {
|
|
604
628
|
if (!peers.has(peer)) {
|
|
605
629
|
channels.delete(peer); pendingPriorityRanges.delete(peer)
|