mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 06:25:22 +00:00
Expand store-and-forward: open couriers, spray-and-wait, persistent outbox, 6h public history (#1372)
Store-and-forward previously delivered to an out-of-range peer only if a
mutual favorite happened to be connected at send time and later met the
recipient directly, and everything except courier envelopes died with the
app process. This closes those gaps end to end:
- Persist the MessageRouter outbox to disk, sealed with a ChaChaPoly key
held only in the Keychain (no plaintext at rest); queued private
messages now survive an app kill and flush on next launch.
- Deposit retry: queued messages are re-deposited whenever a new eligible
courier connects, tracked per message so the same courier is never
double-burned, until 3 distinct couriers carry it or it expires.
- Tiered open couriering: signature-verified strangers can now carry mail
(2 envelopes/depositor into a 20-slot pool) alongside mutual favorites
(5 each); overflow evicts verified-tier mail before favorites'.
- Spray-and-wait: envelopes carry a copy budget (4, capped 8, new TLV,
wire-compatible with old clients); couriers split half their remaining
budget with each newly encountered courier so mail diffuses through a
moving crowd.
- Remote handover: a verified relayed announce now floods a copy toward
the multi-hop recipient (directed-relay treatment, 10-min per-envelope
cooldown) while the carried original stays put for a direct encounter.
- Public history: gossip-sync window for whole public messages widened
from 15 min to 6 h, matched on the receive-acceptance side, and the
message store persists to disk so devices bridge partitions and
restarts ("town crier").
- Privacy-safe local delivery counters (bare tallies, log-only) so the
store-and-forward stack is measurable on-device.
- Panic wipe now also clears the sealed outbox, gossip archive, and
counters.
- Rewrite WHITEPAPER.md to describe the app as implemented (Noise XX/X,
actual flood control, courier system, gossip sync, Nostr path); the old
document described a bloom filter, three fragment types, and a
MessageRetryService that don't exist.
1037 macOS tests pass (17 new); iOS builds.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
295f855b6f
commit
7341696280
@@ -125,7 +125,8 @@ struct BLEPeerRegistry {
|
||||
nickname: resolvedNames[info.peerID] ?? info.nickname,
|
||||
isConnected: info.isConnected,
|
||||
noisePublicKey: info.noisePublicKey,
|
||||
lastSeen: info.lastSeen
|
||||
lastSeen: info.lastSeen,
|
||||
isVerified: info.isVerifiedNickname
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,15 @@ enum BLEPublicMessagePolicy {
|
||||
}
|
||||
|
||||
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
|
||||
// Acceptance window matches the gossip-sync serving window: a peer
|
||||
// walking between partitions carries hours of public history, so the
|
||||
// receive side must not drop what sync legitimately serves.
|
||||
if isBroadcast,
|
||||
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
|
||||
BLEPacketFreshnessPolicy.isStale(
|
||||
timestampMilliseconds: packet.timestamp,
|
||||
now: now,
|
||||
maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds
|
||||
) {
|
||||
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||
timestampMilliseconds: packet.timestamp,
|
||||
now: now
|
||||
|
||||
@@ -48,7 +48,11 @@ struct BLEReceivePipeline {
|
||||
senderIsSelf: senderID == localPeerID,
|
||||
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||
// remote handover toward a relayed announce rides this same
|
||||
// deterministic relay treatment instead of the broadcast clamp.
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
|
||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||
|
||||
@@ -48,12 +48,17 @@ final class BLEService: NSObject {
|
||||
private let messageDeduplicator = MessageDeduplicator()
|
||||
|
||||
// Courier store-and-forward: envelopes this device carries for offline
|
||||
// third parties, and the trust gate for accepting deposits. Injectable
|
||||
// for tests; main-actor policy because favorites live on the main actor.
|
||||
// third parties, and the trust gate for accepting deposits. The policy
|
||||
// maps (depositor key, announce-verified?) to a quota tier, or nil to
|
||||
// reject. Injectable for tests; main-actor policy because favorites live
|
||||
// on the main actor.
|
||||
var courierStore: CourierStore = .shared
|
||||
var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in
|
||||
FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey)
|
||||
var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
|
||||
if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
|
||||
return isVerifiedPeer ? .verified : nil
|
||||
}
|
||||
// Local-only store-and-forward counters; nil in unit tests.
|
||||
var sfMetrics: StoreAndForwardMetrics?
|
||||
|
||||
#if DEBUG
|
||||
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
||||
@@ -275,6 +280,7 @@ final class BLEService: NSObject {
|
||||
gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
|
||||
gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
|
||||
maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
|
||||
publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds,
|
||||
maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
|
||||
stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
|
||||
stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
|
||||
@@ -286,8 +292,10 @@ final class BLEService: NSObject {
|
||||
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
|
||||
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds
|
||||
)
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
|
||||
// Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
|
||||
let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
|
||||
manager.delegate = self
|
||||
// Only start the periodic sync timers when real Bluetooth exists. In unit
|
||||
// tests there is no mesh to sync with, and the periodic sign/broadcast
|
||||
@@ -2515,7 +2523,8 @@ extension BLEService {
|
||||
epochDay: CourierEnvelope.epochDay(for: now)
|
||||
),
|
||||
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||||
ciphertext: sealed
|
||||
ciphertext: sealed,
|
||||
copies: TransportConfig.courierInitialCopies
|
||||
)
|
||||
guard let encoded = envelope.encode() else { return false }
|
||||
payload = encoded
|
||||
@@ -2535,7 +2544,7 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID.id),
|
||||
@@ -2544,6 +2553,10 @@ extension BLEService {
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
// Signed so a courier can authenticate the depositor before carrying
|
||||
// mail under their quota. Handover to the recipient doesn't need the
|
||||
// packet signature — the inner Noise X seal authenticates the sender.
|
||||
return noiseService.signPacket(packet) ?? packet
|
||||
}
|
||||
|
||||
/// Handles both courier roles for an incoming envelope addressed to us:
|
||||
@@ -2559,7 +2572,7 @@ extension BLEService {
|
||||
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
|
||||
openCourierEnvelope(envelope)
|
||||
} else {
|
||||
acceptCourierDeposit(envelope, from: peerID)
|
||||
acceptCourierDeposit(envelope, from: peerID, packet: packet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2589,6 +2602,7 @@ extension BLEService {
|
||||
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
|
||||
let payload = Data(typedPayload.dropFirst())
|
||||
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
|
||||
sfMetrics?.record(.courierOpened)
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||
peerID: senderPeerID,
|
||||
@@ -2603,20 +2617,38 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) {
|
||||
guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else {
|
||||
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) {
|
||||
// A deposit must come from its depositor over the direct link: the
|
||||
// claimed sender has to be the ingress peer, and the packet signature
|
||||
// has to verify against that peer's announced signing key. Otherwise
|
||||
// an untrusted sender could route an envelope through any trusted
|
||||
// neighbor and have us carry it under the neighbor's quota.
|
||||
guard PeerID(hexData: packet.senderID) == peerID else {
|
||||
SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) }
|
||||
guard let depositorKey = depositorInfo?.noisePublicKey else {
|
||||
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
|
||||
return
|
||||
}
|
||||
guard let signingKey = depositorInfo?.signingPublicKey,
|
||||
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||||
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security)
|
||||
return
|
||||
}
|
||||
let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false
|
||||
let store = courierStore
|
||||
let policy = courierDepositPolicy
|
||||
let metrics = sfMetrics
|
||||
Task { @MainActor in
|
||||
guard policy(depositorKey) else {
|
||||
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session)
|
||||
guard let tier = policy(depositorKey, isVerifiedPeer) else {
|
||||
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
|
||||
return
|
||||
}
|
||||
if store.deposit(envelope, from: depositorKey) {
|
||||
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))…", category: .session)
|
||||
if store.deposit(envelope, from: depositorKey, tier: tier) {
|
||||
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))… (\(tier.rawValue))", category: .session)
|
||||
metrics?.record(.courierAccepted)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2629,6 +2661,51 @@ extension BLEService {
|
||||
for envelope in envelopes {
|
||||
guard let payload = envelope.encode() else { continue }
|
||||
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
|
||||
sfMetrics?.record(.courierHandedOver)
|
||||
}
|
||||
}
|
||||
|
||||
/// Speculative handover toward a recipient heard only via a relayed
|
||||
/// announce: the envelope floods the mesh as a directed packet (relays
|
||||
/// treat it like a directed DM). Non-destructive — the carried copy stays
|
||||
/// until a direct handover or expiry, throttled per envelope so repeated
|
||||
/// announces don't re-flood.
|
||||
private func deliverCourierMailRemotely(to peerID: PeerID, noiseKey: Data) {
|
||||
let envelopes = courierStore.envelopesForRemoteHandover(
|
||||
recipientNoiseKey: noiseKey,
|
||||
cooldown: TransportConfig.courierRemoteHandoverCooldownSeconds
|
||||
)
|
||||
guard !envelopes.isEmpty else { return }
|
||||
SecureLogger.debug("📦 Remote handover: flooding \(envelopes.count) envelope(s) toward \(peerID.id.prefix(8))…", category: .session)
|
||||
for envelope in envelopes {
|
||||
guard let payload = envelope.encode() else { continue }
|
||||
broadcastPacket(makeCourierPacket(payload, to: peerID))
|
||||
sfMetrics?.record(.courierRemoteHandover)
|
||||
}
|
||||
}
|
||||
|
||||
/// Spray-and-wait: split copy budgets with another courier we just
|
||||
/// encountered, so carried mail diffuses through a moving crowd instead
|
||||
/// of riding a single carrier. Only favorites and verified peers qualify,
|
||||
/// mirroring the deposit policy they would apply to us.
|
||||
private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) {
|
||||
let store = courierStore
|
||||
let metrics = sfMetrics
|
||||
let sendSpray: ([CourierEnvelope]) -> Void = { [weak self] envelopes in
|
||||
guard let self, !envelopes.isEmpty else { return }
|
||||
SecureLogger.debug("📦 Spraying \(envelopes.count) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session)
|
||||
for envelope in envelopes {
|
||||
guard let payload = envelope.encode() else { continue }
|
||||
self.sendPacketDirected(self.makeCourierPacket(payload, to: peerID), to: peerID)
|
||||
metrics?.record(.courierSprayed)
|
||||
}
|
||||
}
|
||||
let policy = courierDepositPolicy
|
||||
Task { @MainActor in
|
||||
// Same trust gate as deposits: don't hand mail to a peer who
|
||||
// would reject it from us.
|
||||
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
|
||||
sendSpray(store.takeSprayCopies(for: noiseKey))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2755,6 +2832,9 @@ extension BLEService {
|
||||
centralManager?.stopScan()
|
||||
startScanning()
|
||||
}
|
||||
// Backgrounding may precede a kill; flush the public-history archive
|
||||
// outside its 30s maintenance cadence.
|
||||
gossipSyncManager?.persistNow()
|
||||
logBluetoothStatus("entered-background")
|
||||
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
|
||||
// No Local Name; nothing to refresh for advertising policy
|
||||
@@ -3186,16 +3266,23 @@ extension BLEService {
|
||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let result = announceHandler.handle(packet, from: peerID)
|
||||
|
||||
// Courier handover: an announce is the moment we learn a peer's Noise
|
||||
// static key, so check whether we're carrying mail addressed to them.
|
||||
// Direct announces only: envelopes are removed from the store
|
||||
// optimistically, so handover must ride an established link rather
|
||||
// than a speculative multi-hop send toward a relayed announce.
|
||||
// Courier work: an announce is the moment we learn a peer's Noise
|
||||
// static key, so check whether we're carrying mail addressed to them
|
||||
// (or spray-able mail they could carry). Verified announces only.
|
||||
guard !courierStore.isEmpty,
|
||||
let result,
|
||||
result.isVerified,
|
||||
result.isDirectAnnounce else { return }
|
||||
deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
|
||||
result.isVerified else { return }
|
||||
let noiseKey = result.announcement.noisePublicKey
|
||||
if result.isDirectAnnounce {
|
||||
// Established link: destructive handover is safe, and the peer is
|
||||
// close enough to become a courier for other carried mail.
|
||||
deliverCourierMail(to: result.peerID, noiseKey: noiseKey)
|
||||
sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true)
|
||||
} else {
|
||||
// Relayed announce: recipient is multi-hop away. Push a copy
|
||||
// toward them speculatively; the carried copy stays put.
|
||||
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the announce handler environment. All queue hops stay here so
|
||||
|
||||
@@ -11,13 +11,22 @@ import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Trust level of a courier deposit, decided by the caller's policy.
|
||||
/// Favorites get the larger quota and are never evicted to make room for
|
||||
/// verified-tier mail; verified (signature-verified announce, not a mutual
|
||||
/// favorite) get a small quota so a crowd of strangers can still carry mail.
|
||||
enum CourierDepositTier: String, Codable {
|
||||
case favorite
|
||||
case verified
|
||||
}
|
||||
|
||||
/// Holds courier envelopes this device is carrying for offline third parties.
|
||||
///
|
||||
/// Envelopes are opaque ciphertext deposited by mutual favorites; this store
|
||||
/// never learns sender, recipient, or content. Strict quotas keep the device
|
||||
/// from becoming a public mailbag: bounded count, bounded per-depositor
|
||||
/// count, bounded size, and a 24-hour lifetime aligned with the outbox
|
||||
/// retention policy. Carried mail is included in the panic wipe.
|
||||
/// Envelopes are opaque ciphertext; this store never learns sender,
|
||||
/// recipient, or content. Strict quotas keep the device from becoming a
|
||||
/// public mailbag: bounded count, bounded per-depositor count by trust tier,
|
||||
/// bounded size, and a 24-hour lifetime aligned with the outbox retention
|
||||
/// policy. Carried mail is included in the panic wipe.
|
||||
final class CourierStore {
|
||||
struct StoredEnvelope: Codable, Equatable {
|
||||
let recipientTag: Data
|
||||
@@ -25,15 +34,63 @@ final class CourierStore {
|
||||
let ciphertext: Data
|
||||
let depositorNoiseKey: Data
|
||||
let storedAt: Date
|
||||
var tier: CourierDepositTier
|
||||
/// Remaining spray-and-wait budget (1 = carry-only).
|
||||
var copies: UInt8
|
||||
/// Couriers this envelope was already sprayed to, so a repeat announce
|
||||
/// from the same peer doesn't burn budget on a copy they already hold.
|
||||
var sprayedTo: Set<Data>
|
||||
/// Last speculative multi-hop handover toward a relayed announce.
|
||||
var lastRemoteHandoverAt: Date?
|
||||
|
||||
var envelope: CourierEnvelope {
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
||||
}
|
||||
|
||||
init(
|
||||
recipientTag: Data,
|
||||
expiry: UInt64,
|
||||
ciphertext: Data,
|
||||
depositorNoiseKey: Data,
|
||||
storedAt: Date,
|
||||
tier: CourierDepositTier,
|
||||
copies: UInt8,
|
||||
sprayedTo: Set<Data> = [],
|
||||
lastRemoteHandoverAt: Date? = nil
|
||||
) {
|
||||
self.recipientTag = recipientTag
|
||||
self.expiry = expiry
|
||||
self.ciphertext = ciphertext
|
||||
self.depositorNoiseKey = depositorNoiseKey
|
||||
self.storedAt = storedAt
|
||||
self.tier = tier
|
||||
self.copies = copies
|
||||
self.sprayedTo = sprayedTo
|
||||
self.lastRemoteHandoverAt = lastRemoteHandoverAt
|
||||
}
|
||||
|
||||
// Files written before tiers/spray lack the newer fields; treat that
|
||||
// mail as favorite-tier carry-only, which is what it was.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
recipientTag = try container.decode(Data.self, forKey: .recipientTag)
|
||||
expiry = try container.decode(UInt64.self, forKey: .expiry)
|
||||
ciphertext = try container.decode(Data.self, forKey: .ciphertext)
|
||||
depositorNoiseKey = try container.decode(Data.self, forKey: .depositorNoiseKey)
|
||||
storedAt = try container.decode(Date.self, forKey: .storedAt)
|
||||
tier = try container.decodeIfPresent(CourierDepositTier.self, forKey: .tier) ?? .favorite
|
||||
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
|
||||
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
|
||||
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
|
||||
}
|
||||
}
|
||||
|
||||
enum Limits {
|
||||
static let maxEnvelopes = 20
|
||||
static let maxPerDepositor = 5
|
||||
static let maxEnvelopes = 40
|
||||
/// Verified-tier mail can never crowd out favorites' share.
|
||||
static let maxVerifiedEnvelopes = 20
|
||||
static let maxPerFavoriteDepositor = 5
|
||||
static let maxPerVerifiedDepositor = 2
|
||||
/// Slack on top of the 24h lifetime for depositor clock skew.
|
||||
static let maxExpirySlack: TimeInterval = 60 * 60
|
||||
}
|
||||
@@ -65,10 +122,11 @@ final class CourierStore {
|
||||
// MARK: - Depositing (courier side)
|
||||
|
||||
/// Accept an envelope from a depositor. Returns false when quotas or
|
||||
/// validity checks reject it. Trust policy (mutual favorite) is the
|
||||
/// caller's responsibility; this store only enforces resource bounds.
|
||||
/// validity checks reject it. Trust policy (which tier a depositor gets,
|
||||
/// if any) is the caller's responsibility; this store only enforces
|
||||
/// resource bounds.
|
||||
@discardableResult
|
||||
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool {
|
||||
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool {
|
||||
let date = now()
|
||||
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
|
||||
!envelope.ciphertext.isEmpty,
|
||||
@@ -86,18 +144,39 @@ final class CourierStore {
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
|
||||
// Identical ciphertext is the same envelope; accept idempotently.
|
||||
if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) {
|
||||
// Identical ciphertext is the same envelope; accept idempotently,
|
||||
// keeping the larger spray budget (bounded by maxCopies either way).
|
||||
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
|
||||
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else {
|
||||
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session)
|
||||
|
||||
let perDepositorLimit = tier == .favorite ? Limits.maxPerFavoriteDepositor : Limits.maxPerVerifiedDepositor
|
||||
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < perDepositorLimit else {
|
||||
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached (\(tier.rawValue))", category: .session)
|
||||
return false
|
||||
}
|
||||
if tier == .verified,
|
||||
envelopes.filter({ $0.tier == .verified }).count >= Limits.maxVerifiedEnvelopes {
|
||||
SecureLogger.debug("📦 Courier deposit rejected: verified-tier pool full", category: .session)
|
||||
return false
|
||||
}
|
||||
if envelopes.count >= Limits.maxEnvelopes {
|
||||
// Oldest-first eviction, matching outbox overflow behavior.
|
||||
let evicted = envelopes.removeFirst()
|
||||
SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session)
|
||||
// Oldest-first eviction, shedding verified-tier mail before
|
||||
// favorites' so open couriering can't crowd out trusted mail.
|
||||
// A verified deposit never displaces a favorite: when only
|
||||
// favorite mail is stored, it is rejected instead.
|
||||
if let victim = envelopes.firstIndex(where: { $0.tier == .verified }) {
|
||||
let evicted = envelopes.remove(at: victim)
|
||||
SecureLogger.debug("📦 Courier store full - evicted verified envelope stored at \(evicted.storedAt)", category: .session)
|
||||
} else if tier == .favorite {
|
||||
let evicted = envelopes.removeFirst()
|
||||
SecureLogger.debug("📦 Courier store full - evicted favorite envelope stored at \(evicted.storedAt)", category: .session)
|
||||
} else {
|
||||
SecureLogger.debug("📦 Courier deposit rejected: store full of favorite-tier mail", category: .session)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
envelopes.append(StoredEnvelope(
|
||||
@@ -105,7 +184,9 @@ final class CourierStore {
|
||||
expiry: envelope.expiry,
|
||||
ciphertext: envelope.ciphertext,
|
||||
depositorNoiseKey: depositorNoiseKey,
|
||||
storedAt: date
|
||||
storedAt: date,
|
||||
tier: tier,
|
||||
copies: envelope.copies
|
||||
))
|
||||
persistLocked()
|
||||
return true
|
||||
@@ -131,6 +212,58 @@ final class CourierStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelopes addressed to a recipient we heard from via a *relayed*
|
||||
/// announce. Non-destructive: a multi-hop send is speculative, so the
|
||||
/// envelope stays carried until a direct handover or expiry. The per-
|
||||
/// envelope cooldown keeps repeated announces from re-flooding the mesh.
|
||||
func envelopesForRemoteHandover(recipientNoiseKey: Data, cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: recipientNoiseKey, around: date)
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
var matched: [CourierEnvelope] = []
|
||||
for index in envelopes.indices where candidates.contains(envelopes[index].recipientTag) {
|
||||
if let last = envelopes[index].lastRemoteHandoverAt,
|
||||
date.timeIntervalSince(last) < cooldown {
|
||||
continue
|
||||
}
|
||||
envelopes[index].lastRemoteHandoverAt = date
|
||||
// The delivered copy carries no spray budget.
|
||||
matched.append(envelopes[index].envelope.withCopies(1))
|
||||
}
|
||||
if !matched.isEmpty { persistLocked() }
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Spray-and-wait (on encountering another courier)
|
||||
|
||||
/// Envelopes to re-deposit with a courier we just encountered, each with
|
||||
/// half its remaining budget (binary spray). Skips envelopes the courier
|
||||
/// deposited, envelopes addressed to them (those ride the handover path),
|
||||
/// carry-only envelopes, and couriers already sprayed.
|
||||
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
var sprayed: [CourierEnvelope] = []
|
||||
for index in envelopes.indices {
|
||||
let stored = envelopes[index]
|
||||
guard stored.copies > 1,
|
||||
stored.depositorNoiseKey != courierNoiseKey,
|
||||
!stored.sprayedTo.contains(courierNoiseKey),
|
||||
!courierTags.contains(stored.recipientTag) else { continue }
|
||||
let given = stored.copies / 2
|
||||
envelopes[index].copies = stored.copies - given
|
||||
envelopes[index].sprayedTo.insert(courierNoiseKey)
|
||||
sprayed.append(stored.envelope.withCopies(given))
|
||||
}
|
||||
if !sprayed.isEmpty { persistLocked() }
|
||||
return sprayed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
func pruneExpired() {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// MessageOutboxStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Disk persistence for the MessageRouter outbox, so private messages queued
|
||||
/// for an offline peer survive an app kill instead of silently evaporating.
|
||||
///
|
||||
/// Nothing else in the app persists message plaintext, and this store keeps
|
||||
/// that property: the outbox is sealed with a ChaChaPoly key that lives only
|
||||
/// in the Keychain (after-first-unlock, this device only), on top of iOS file
|
||||
/// protection. Wiped on panic alongside the courier store.
|
||||
final class MessageOutboxStore {
|
||||
struct QueuedMessage: Codable, Equatable {
|
||||
let content: String
|
||||
let nickname: String
|
||||
let messageID: String
|
||||
let timestamp: Date
|
||||
var sendAttempts: Int
|
||||
/// Noise keys of couriers already carrying this message, so deposit
|
||||
/// retries add couriers instead of re-burning the same ones.
|
||||
var depositedCourierKeys: Set<Data>
|
||||
|
||||
init(
|
||||
content: String,
|
||||
nickname: String,
|
||||
messageID: String,
|
||||
timestamp: Date,
|
||||
sendAttempts: Int = 0,
|
||||
depositedCourierKeys: Set<Data> = []
|
||||
) {
|
||||
self.content = content
|
||||
self.nickname = nickname
|
||||
self.messageID = messageID
|
||||
self.timestamp = timestamp
|
||||
self.sendAttempts = sendAttempts
|
||||
self.depositedCourierKeys = depositedCourierKeys
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
content = try container.decode(String.self, forKey: .content)
|
||||
nickname = try container.decode(String.self, forKey: .nickname)
|
||||
messageID = try container.decode(String.self, forKey: .messageID)
|
||||
timestamp = try container.decode(Date.self, forKey: .timestamp)
|
||||
sendAttempts = try container.decodeIfPresent(Int.self, forKey: .sendAttempts) ?? 0
|
||||
depositedCourierKeys = try container.decodeIfPresent(Set<Data>.self, forKey: .depositedCourierKeys) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private static let keychainService = "chat.bitchat.outbox"
|
||||
private static let keychainKey = "outbox-encryption-key"
|
||||
|
||||
private let fileURL: URL?
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) {
|
||||
self.keychain = keychain
|
||||
self.fileURL = fileURL ?? Self.defaultFileURL()
|
||||
}
|
||||
|
||||
// MARK: - API (call from the router's actor; IO is small and atomic)
|
||||
|
||||
func load() -> [PeerID: [QueuedMessage]] {
|
||||
guard let fileURL,
|
||||
let sealed = try? Data(contentsOf: fileURL),
|
||||
let key = encryptionKey(createIfMissing: false),
|
||||
let box = try? ChaChaPoly.SealedBox(combined: sealed),
|
||||
let plaintext = try? ChaChaPoly.open(box, using: key),
|
||||
let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
|
||||
return [:]
|
||||
}
|
||||
var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
for (peerID, queue) in decoded where !queue.isEmpty {
|
||||
outbox[PeerID(str: peerID)] = queue
|
||||
}
|
||||
return outbox
|
||||
}
|
||||
|
||||
func save(_ outbox: [PeerID: [QueuedMessage]]) {
|
||||
guard let fileURL else { return }
|
||||
let flattened = outbox.filter { !$0.value.isEmpty }
|
||||
guard !flattened.isEmpty else {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
guard let key = encryptionKey(createIfMissing: true) else {
|
||||
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) })
|
||||
let plaintext = try JSONEncoder().encode(keyed)
|
||||
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try sealed.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: drop the queued mail and the key that could ever read it.
|
||||
func wipe() {
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
|
||||
if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 {
|
||||
return SymmetricKey(data: data)
|
||||
}
|
||||
guard createIfMissing else { return nil }
|
||||
let key = SymmetricKey(size: .bits256)
|
||||
let data = key.withUnsafeBytes { Data($0) }
|
||||
// After-first-unlock so queued mail can flush from background BLE wakes.
|
||||
keychain.save(
|
||||
key: Self.keychainKey,
|
||||
data: data,
|
||||
service: Self.keychainService,
|
||||
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
)
|
||||
return key
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("courier", isDirectory: true)
|
||||
.appendingPathComponent("outbox.sealed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// StoreAndForwardMetrics.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Privacy-safe local counters for the store-and-forward stack: bare event
|
||||
/// tallies with no message IDs, peer identities, or timestamps, so delivery
|
||||
/// behavior can be measured on-device without recording who talked to whom.
|
||||
/// Log-only surface — nothing here ever leaves the device.
|
||||
final class StoreAndForwardMetrics {
|
||||
enum Event: String, CaseIterable {
|
||||
/// A private message entered the outbox (no prompt route available).
|
||||
case outboxQueued = "outbox.queued"
|
||||
/// A retained message was re-sent on a flush.
|
||||
case outboxResent = "outbox.resent"
|
||||
/// A delivery/read ack cleared a retained message.
|
||||
case outboxDelivered = "outbox.delivered"
|
||||
/// A retained message was dropped (attempt cap, TTL, or overflow).
|
||||
case outboxDropped = "outbox.dropped"
|
||||
/// We handed sealed mail to a courier.
|
||||
case courierDeposited = "courier.deposited"
|
||||
/// We accepted sealed mail to carry for a third party.
|
||||
case courierAccepted = "courier.accepted"
|
||||
/// We handed carried mail to its recipient over a direct link.
|
||||
case courierHandedOver = "courier.handedOver"
|
||||
/// We pushed carried mail toward a recipient heard via relay.
|
||||
case courierRemoteHandover = "courier.remoteHandover"
|
||||
/// We split spray copies to another courier.
|
||||
case courierSprayed = "courier.sprayed"
|
||||
/// Couriered mail addressed to us was opened and delivered.
|
||||
case courierOpened = "courier.opened"
|
||||
}
|
||||
|
||||
static let shared = StoreAndForwardMetrics()
|
||||
|
||||
private let lock = NSLock()
|
||||
private var counts: [String: Int]
|
||||
private let defaults: UserDefaults
|
||||
private static let defaultsKey = "chat.bitchat.storeAndForwardMetrics"
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
self.counts = defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int] ?? [:]
|
||||
}
|
||||
|
||||
func record(_ event: Event) {
|
||||
lock.lock()
|
||||
let total = (counts[event.rawValue] ?? 0) + 1
|
||||
counts[event.rawValue] = total
|
||||
defaults.set(counts, forKey: Self.defaultsKey)
|
||||
lock.unlock()
|
||||
SecureLogger.debug("📊 S&F \(event.rawValue) → \(total)", category: .session)
|
||||
}
|
||||
|
||||
func snapshot() -> [String: Int] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return counts
|
||||
}
|
||||
|
||||
/// Included in the panic wipe alongside the stores it describes.
|
||||
func reset() {
|
||||
lock.lock()
|
||||
counts = [:]
|
||||
defaults.removeObject(forKey: Self.defaultsKey)
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import Foundation
|
||||
struct CourierDirectory {
|
||||
/// Noise static key for a peer we can address while they're offline.
|
||||
var noiseKey: (PeerID) -> Data?
|
||||
/// Whether a peer (by Noise static key) may carry our mail.
|
||||
/// Whether a peer (by Noise static key) is a mutual favorite — the
|
||||
/// preferred courier tier. Verified non-favorites are the fallback tier,
|
||||
/// read off the transport snapshot.
|
||||
var isTrustedCourier: (Data) -> Bool
|
||||
|
||||
@MainActor
|
||||
@@ -30,9 +32,13 @@ struct CourierDirectory {
|
||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
||||
@MainActor
|
||||
final class MessageRouter {
|
||||
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
|
||||
|
||||
private let transports: [Transport]
|
||||
private let now: () -> Date
|
||||
private let courierDirectory: CourierDirectory
|
||||
private let outboxStore: MessageOutboxStore?
|
||||
private let metrics: StoreAndForwardMetrics?
|
||||
|
||||
/// Invoked whenever a retained private message is dropped without a
|
||||
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
|
||||
@@ -41,20 +47,11 @@ final class MessageRouter {
|
||||
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
||||
|
||||
/// Invoked when a message with no reachable transport was handed to at
|
||||
/// least one courier (a connected mutual favorite who will physically
|
||||
/// carry the sealed envelope). Delivery stays best-effort: the outbox
|
||||
/// retains the message until an ack arrives.
|
||||
/// least one courier (a connected peer who will physically carry the
|
||||
/// sealed envelope). Delivery stays best-effort: the outbox retains the
|
||||
/// message until an ack arrives.
|
||||
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
||||
|
||||
// Outbox entry with timestamp for TTL-based eviction
|
||||
private struct QueuedMessage {
|
||||
let content: String
|
||||
let nickname: String
|
||||
let messageID: String
|
||||
let timestamp: Date
|
||||
var sendAttempts: Int = 0
|
||||
}
|
||||
|
||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
|
||||
// Outbox limits to prevent unbounded memory growth
|
||||
@@ -69,11 +66,16 @@ final class MessageRouter {
|
||||
init(
|
||||
transports: [Transport],
|
||||
now: @escaping () -> Date = Date.init,
|
||||
courierDirectory: CourierDirectory? = nil
|
||||
courierDirectory: CourierDirectory? = nil,
|
||||
outboxStore: MessageOutboxStore? = nil,
|
||||
metrics: StoreAndForwardMetrics? = nil
|
||||
) {
|
||||
self.transports = transports
|
||||
self.now = now
|
||||
self.courierDirectory = courierDirectory ?? .favoritesBacked()
|
||||
self.outboxStore = outboxStore
|
||||
self.metrics = metrics
|
||||
self.outbox = outboxStore?.load() ?? [:]
|
||||
|
||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -134,50 +136,137 @@ final class MessageRouter {
|
||||
// may never come. Double delivery is harmless — receivers dedup
|
||||
// by message ID, and delivered/read acks never downgrade.
|
||||
if !transport.canDeliverPromptly(to: peerID) {
|
||||
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
|
||||
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||
}
|
||||
} else {
|
||||
var unsent = message
|
||||
unsent.sendAttempts = 0
|
||||
enqueue(unsent, for: peerID)
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
|
||||
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Couriers
|
||||
|
||||
/// Last resort when no transport can deliver promptly — the peer is
|
||||
/// unreachable, or only reachable through a send queue waiting on
|
||||
/// internet: seal the message to their known static key and hand it to
|
||||
/// connected mutual favorites who may physically encounter them. The
|
||||
/// queued copy above stays retained, so direct delivery still wins if
|
||||
/// the peer reappears first (receivers dedup by message ID).
|
||||
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
|
||||
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
|
||||
/// connected couriers who may physically encounter them. Mutual favorites
|
||||
/// are preferred; signature-verified strangers fill remaining slots so a
|
||||
/// crowd without favorites can still carry mail (envelopes are opaque
|
||||
/// either way). The queued copy stays retained, so direct delivery still
|
||||
/// wins if the peer reappears first (receivers dedup by message ID).
|
||||
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
|
||||
guard let recipientKey = courierDirectory.noiseKey(peerID),
|
||||
let entry = queuedMessage(messageID, for: peerID) else { return }
|
||||
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
|
||||
guard remainingSlots > 0 else { return }
|
||||
|
||||
for transport in transports {
|
||||
let couriers = transport.currentPeerSnapshots()
|
||||
.filter { snapshot in
|
||||
guard snapshot.isConnected,
|
||||
let key = snapshot.noisePublicKey,
|
||||
key != recipientKey else { return false }
|
||||
return courierDirectory.isTrustedCourier(key)
|
||||
}
|
||||
.prefix(Self.maxCouriersPerMessage)
|
||||
.map(\.peerID)
|
||||
let couriers = eligibleCouriers(
|
||||
on: transport,
|
||||
recipientKey: recipientKey,
|
||||
excluding: entry.depositedCourierKeys,
|
||||
limit: remainingSlots
|
||||
)
|
||||
guard !couriers.isEmpty else { continue }
|
||||
if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) {
|
||||
if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
|
||||
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session)
|
||||
recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
|
||||
onMessageCarried?(messageID, peerID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A courier candidate just connected: hand them any queued mail they are
|
||||
/// not already carrying. This is what turns couriering from "a favorite
|
||||
/// happened to be around at send time" into eventual spread — deposits
|
||||
/// retry as eligible peers appear, until each message rides with
|
||||
/// `maxCouriersPerMessage` distinct couriers or expires.
|
||||
func courierBecameAvailable(_ peerID: PeerID) {
|
||||
for transport in transports {
|
||||
guard transport.isPeerConnected(peerID),
|
||||
let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
|
||||
let courierKey = snapshot.noisePublicKey,
|
||||
courierDirectory.isTrustedCourier(courierKey) || snapshot.isVerified else { continue }
|
||||
|
||||
let currentDate = now()
|
||||
for (recipient, queue) in outbox {
|
||||
// Mail *to* this peer flushes directly on connect.
|
||||
guard recipient != peerID,
|
||||
let recipientKey = courierDirectory.noiseKey(recipient),
|
||||
recipientKey != courierKey else { continue }
|
||||
for message in queue {
|
||||
guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
|
||||
!message.depositedCourierKeys.contains(courierKey),
|
||||
currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
|
||||
if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
|
||||
SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))…", category: .session)
|
||||
recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
|
||||
onMessageCarried?(message.messageID, recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private struct CourierCandidate {
|
||||
let peerID: PeerID
|
||||
let noiseKey: Data
|
||||
}
|
||||
|
||||
private func eligibleCouriers(
|
||||
on transport: Transport,
|
||||
recipientKey: Data,
|
||||
excluding excludedKeys: Set<Data>,
|
||||
limit: Int
|
||||
) -> [CourierCandidate] {
|
||||
guard limit > 0 else { return [] }
|
||||
let candidates = transport.currentPeerSnapshots().compactMap { snapshot -> (CourierCandidate, isFavorite: Bool)? in
|
||||
guard snapshot.isConnected,
|
||||
let key = snapshot.noisePublicKey,
|
||||
key != recipientKey,
|
||||
!excludedKeys.contains(key) else { return nil }
|
||||
let isFavorite = courierDirectory.isTrustedCourier(key)
|
||||
guard isFavorite || snapshot.isVerified else { return nil }
|
||||
return (CourierCandidate(peerID: snapshot.peerID, noiseKey: key), isFavorite)
|
||||
}
|
||||
return candidates
|
||||
.sorted { $0.isFavorite && !$1.isFavorite }
|
||||
.prefix(limit)
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
private func queuedMessage(_ messageID: String, for peerID: PeerID) -> QueuedMessage? {
|
||||
outbox[peerID]?.first { $0.messageID == messageID }
|
||||
}
|
||||
|
||||
private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
|
||||
metrics?.record(.courierDeposited)
|
||||
guard var queue = outbox[peerID],
|
||||
let index = queue.firstIndex(where: { $0.messageID == messageID }) else { return }
|
||||
queue[index].depositedCourierKeys.formUnion(courierKeys)
|
||||
outbox[peerID] = queue
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
// MARK: - Outbox Management
|
||||
|
||||
/// A delivery or read ack confirms receipt; stop retaining the message.
|
||||
func markDelivered(_ messageID: String) {
|
||||
var cleared = false
|
||||
for (peerID, queue) in outbox {
|
||||
let filtered = queue.filter { $0.messageID != messageID }
|
||||
guard filtered.count != queue.count else { continue }
|
||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||
cleared = true
|
||||
}
|
||||
if cleared {
|
||||
metrics?.record(.outboxDelivered)
|
||||
persistOutbox()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,9 +280,26 @@ final class MessageRouter {
|
||||
if queue.count > Self.maxMessagesPerPeer {
|
||||
let evicted = queue.removeFirst()
|
||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session)
|
||||
onMessageDropped?(evicted.messageID, peerID)
|
||||
dropMessage(evicted.messageID, for: peerID)
|
||||
}
|
||||
outbox[peerID] = queue
|
||||
metrics?.record(.outboxQueued)
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
private func dropMessage(_ messageID: String, for peerID: PeerID) {
|
||||
metrics?.record(.outboxDropped)
|
||||
onMessageDropped?(messageID, peerID)
|
||||
}
|
||||
|
||||
private func persistOutbox() {
|
||||
outboxStore?.save(outbox)
|
||||
}
|
||||
|
||||
/// Panic wipe: forget queued mail on disk and in memory.
|
||||
func wipeOutbox() {
|
||||
outbox.removeAll()
|
||||
outboxStore?.wipe()
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
@@ -220,8 +326,6 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Outbox Management
|
||||
|
||||
func flushOutbox(for peerID: PeerID) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||
@@ -233,7 +337,7 @@ final class MessageRouter {
|
||||
// Skip expired messages (TTL exceeded)
|
||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
||||
onMessageDropped?(message.messageID, peerID)
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -241,16 +345,18 @@ final class MessageRouter {
|
||||
// Live link: send and stop retaining.
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
} else if let transport = reachableTransport(for: peerID) {
|
||||
// Weak signal: send but keep retaining until an ack clears it,
|
||||
// bounded by attempt count for peers that never ack.
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||
onMessageDropped?(message.messageID, peerID)
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
continue
|
||||
}
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
var retained = message
|
||||
retained.sendAttempts += 1
|
||||
remaining.append(retained)
|
||||
@@ -264,6 +370,7 @@ final class MessageRouter {
|
||||
} else {
|
||||
outbox[peerID] = remaining
|
||||
}
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
func flushAllOutbox() {
|
||||
@@ -273,6 +380,7 @@ final class MessageRouter {
|
||||
/// Periodically clean up expired messages from all outboxes
|
||||
func cleanupExpiredMessages() {
|
||||
let now = now()
|
||||
var droppedAny = false
|
||||
for peerID in Array(outbox.keys) {
|
||||
var expiredMessageIDs: [String] = []
|
||||
outbox[peerID]?.removeAll { message in
|
||||
@@ -285,8 +393,12 @@ final class MessageRouter {
|
||||
}
|
||||
for messageID in expiredMessageIDs {
|
||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
onMessageDropped?(messageID, peerID)
|
||||
dropMessage(messageID, for: peerID)
|
||||
droppedAny = true
|
||||
}
|
||||
}
|
||||
if droppedAny {
|
||||
persistOutbox()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,24 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
let isConnected: Bool
|
||||
let noisePublicKey: Data?
|
||||
let lastSeen: Date
|
||||
/// Whether the peer's announce was signature-verified (courier tier gate).
|
||||
let isVerified: Bool
|
||||
|
||||
init(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
isConnected: Bool,
|
||||
noisePublicKey: Data?,
|
||||
lastSeen: Date,
|
||||
isVerified: Bool = false
|
||||
) {
|
||||
self.peerID = peerID
|
||||
self.nickname = nickname
|
||||
self.isConnected = isConnected
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.lastSeen = lastSeen
|
||||
self.isVerified = isVerified
|
||||
}
|
||||
}
|
||||
|
||||
enum TransportEvent: @unchecked Sendable {
|
||||
|
||||
@@ -262,7 +262,14 @@ enum TransportConfig {
|
||||
static let syncSeenCapacity: Int = 1000
|
||||
static let syncGCSMaxBytes: Int = 400
|
||||
static let syncGCSTargetFpr: Double = 0.01
|
||||
// Fragments and file transfers keep the short window; whole public
|
||||
// messages get hours so a phone walking between partitions carries the
|
||||
// room's recent history with it (see syncPublicMessageMaxAgeSeconds).
|
||||
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
||||
// How far back public broadcast messages stay sync-able. Must not exceed
|
||||
// the receive-side acceptance window (BLEPublicMessagePolicy uses this
|
||||
// same constant) or served packets would be dropped as stale.
|
||||
static let syncPublicMessageMaxAgeSeconds: TimeInterval = 6 * 60 * 60
|
||||
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
@@ -273,4 +280,13 @@ enum TransportConfig {
|
||||
static let syncMessageIntervalSeconds: TimeInterval = 15.0
|
||||
static let syncResponseRateLimitMaxResponses: Int = 8
|
||||
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||
|
||||
// Courier store-and-forward
|
||||
// Initial spray-and-wait budget per deposited envelope: each courier may
|
||||
// hand half its remaining copies to another courier on encounter, so a
|
||||
// message diffuses through a moving crowd instead of riding one person.
|
||||
static let courierInitialCopies: UInt8 = 4
|
||||
// Cooldown between speculative multi-hop handovers of the same envelope
|
||||
// toward a recipient heard only via relayed announces.
|
||||
static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// GossipMessageArchive.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Disk persistence for the gossip-sync public message store, so the recent
|
||||
/// public history a device carries survives app restarts. This is what lets
|
||||
/// a phone act as a town crier: walk between two mesh partitions (or relaunch
|
||||
/// hours later) and sync the room's backlog to whoever missed it.
|
||||
///
|
||||
/// Contents are signed public broadcasts — already visible to anyone in radio
|
||||
/// range — so file protection (no additional sealing) is the right at-rest
|
||||
/// posture. Wiped on panic.
|
||||
final class GossipMessageArchive {
|
||||
private let fileURL: URL?
|
||||
|
||||
init(fileURL: URL? = nil) {
|
||||
self.fileURL = fileURL ?? Self.defaultFileURL()
|
||||
}
|
||||
|
||||
/// Raw binary packets, decoded and freshness-filtered by the caller.
|
||||
func load() -> [Data] {
|
||||
guard let fileURL,
|
||||
let data = try? Data(contentsOf: fileURL),
|
||||
let packets = try? JSONDecoder().decode([Data].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return packets
|
||||
}
|
||||
|
||||
func save(_ packets: [Data]) {
|
||||
guard let fileURL else { return }
|
||||
guard !packets.isEmpty else {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(packets)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist gossip archive: \(error)", category: .sync)
|
||||
}
|
||||
}
|
||||
|
||||
func wipe() {
|
||||
guard let fileURL else { return }
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
/// Panic-wipe hook for callers that don't hold the live instance.
|
||||
static func wipeDefault() {
|
||||
GossipMessageArchive().wipe()
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("sync", isDirectory: true)
|
||||
.appendingPathComponent("public-messages.json")
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,10 @@ final class GossipSyncManager {
|
||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||
var gcsTargetFpr: Double = 0.01 // 1%
|
||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - fragments/files/announces
|
||||
// Whole public messages stay sync-able much longer so devices carry
|
||||
// the room's recent history between partitions and across restarts.
|
||||
var publicMessageMaxAgeSeconds: TimeInterval = 900
|
||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
@@ -80,6 +83,7 @@ final class GossipSyncManager {
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
private let requestSyncManager: RequestSyncManager
|
||||
private let archive: GossipMessageArchive?
|
||||
weak var delegate: Delegate?
|
||||
|
||||
// Storage: broadcast packets by type, and latest announce per sender
|
||||
@@ -87,6 +91,7 @@ final class GossipSyncManager {
|
||||
private var fragments = PacketStore()
|
||||
private var fileTransfers = PacketStore()
|
||||
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||
private var archiveDirty = false
|
||||
|
||||
// Timer
|
||||
private var periodicTimer: DispatchSourceTimer?
|
||||
@@ -95,10 +100,11 @@ final class GossipSyncManager {
|
||||
private var syncSchedules: [SyncSchedule] = []
|
||||
private var responseRateLimiter: SyncResponseRateLimiter
|
||||
|
||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
|
||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager, archive: GossipMessageArchive? = nil) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
self.requestSyncManager = requestSyncManager
|
||||
self.archive = archive
|
||||
self.responseRateLimiter = SyncResponseRateLimiter(
|
||||
maxResponses: config.responseRateLimitMaxResponses,
|
||||
window: config.responseRateLimitWindowSeconds
|
||||
@@ -114,6 +120,12 @@ final class GossipSyncManager {
|
||||
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
syncSchedules = schedules
|
||||
|
||||
if archive != nil {
|
||||
queue.async { [weak self] in
|
||||
self?.restoreArchivedMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -153,10 +165,15 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if a packet is within the age threshold
|
||||
// Helper to check if a packet is within the age threshold. Whole public
|
||||
// messages get the long town-crier window; fragments, file transfers and
|
||||
// announces keep the short one.
|
||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||
let maxAgeSeconds = packet.type == MessageType.message.rawValue
|
||||
? config.publicMessageMaxAgeSeconds
|
||||
: config.maxMessageAgeSeconds
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
|
||||
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
|
||||
|
||||
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
||||
guard nowMs >= ageThresholdMs else { return true }
|
||||
@@ -197,6 +214,7 @@ final class GossipSyncManager {
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
||||
archiveDirty = true
|
||||
case .fragment:
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
@@ -417,14 +435,55 @@ final class GossipSyncManager {
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
|
||||
let messageCountBefore = messages.packets.count
|
||||
messages.removeExpired(isFresh: isPacketFresh)
|
||||
if messages.packets.count != messageCountBefore {
|
||||
archiveDirty = true
|
||||
}
|
||||
fragments.removeExpired(isFresh: isPacketFresh)
|
||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||
}
|
||||
|
||||
// MARK: - Archive (public message persistence)
|
||||
|
||||
/// Rebuild the public message store from disk on launch, dropping
|
||||
/// anything that aged out while the app was dead.
|
||||
private func restoreArchivedMessages() {
|
||||
guard let archive else { return }
|
||||
var restored = 0
|
||||
for data in archive.load() {
|
||||
guard let packet = BitchatPacket.from(data),
|
||||
packet.type == MessageType.message.rawValue,
|
||||
isPacketFresh(packet) else { continue }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
||||
restored += 1
|
||||
}
|
||||
if restored > 0 {
|
||||
SecureLogger.debug("Restored \(restored) archived public message(s) for gossip sync", category: .sync)
|
||||
archiveDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
private func persistArchiveIfDirty() {
|
||||
guard archiveDirty, let archive else { return }
|
||||
archiveDirty = false
|
||||
let packets = messages.allPackets(isFresh: isPacketFresh)
|
||||
.compactMap { $0.toBinaryData(padding: false) }
|
||||
archive.save(packets)
|
||||
}
|
||||
|
||||
/// Flush the archive outside the maintenance cadence (app backgrounding).
|
||||
func persistNow() {
|
||||
queue.async { [weak self] in
|
||||
self?.persistArchiveIfDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||
cleanupExpiredMessages()
|
||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||
persistArchiveIfDirty()
|
||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
||||
responseRateLimiter.prune(now: now)
|
||||
|
||||
@@ -471,7 +530,11 @@ final class GossipSyncManager {
|
||||
|
||||
private func removeState(for peerID: PeerID) {
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||
let messageCountBefore = messages.packets.count
|
||||
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
if messages.packets.count != messageCountBefore {
|
||||
archiveDirty = true
|
||||
}
|
||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ protocol ChatTransportEventContext: AnyObject {
|
||||
|
||||
// MARK: Routing & acknowledgements
|
||||
func flushRouterOutbox(for peerID: PeerID)
|
||||
/// Offer queued mail for *other* peers to this newly connected courier.
|
||||
func retryCourierDeposits(via peerID: PeerID)
|
||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||
|
||||
// MARK: Delivery status
|
||||
@@ -103,6 +105,10 @@ extension ChatViewModel: ChatTransportEventContext {
|
||||
messageRouter.flushOutbox(for: peerID)
|
||||
}
|
||||
|
||||
func retryCourierDeposits(via peerID: PeerID) {
|
||||
messageRouter.courierBecameAvailable(peerID)
|
||||
}
|
||||
|
||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
meshService.sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
@@ -208,6 +214,7 @@ final class ChatTransportEventCoordinator {
|
||||
}
|
||||
|
||||
context.flushRouterOutbox(for: peerID)
|
||||
context.retryCourierDeposits(via: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -764,15 +764,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
locationPresenceStore: LocationPresenceStore? = nil,
|
||||
locationManager: LocationChannelManager = .shared
|
||||
) {
|
||||
let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||
meshService.sfMetrics = .shared
|
||||
self.init(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
|
||||
transport: meshService,
|
||||
conversations: conversations,
|
||||
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
|
||||
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
||||
locationManager: locationManager
|
||||
locationManager: locationManager,
|
||||
outboxStore: MessageOutboxStore(keychain: keychain),
|
||||
sfMetrics: .shared
|
||||
)
|
||||
}
|
||||
|
||||
@@ -788,7 +792,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
peerIdentityStore: PeerIdentityStore? = nil,
|
||||
locationPresenceStore: LocationPresenceStore? = nil,
|
||||
locationManager: LocationChannelManager = .shared,
|
||||
readReceiptsDefaults: UserDefaults? = nil
|
||||
readReceiptsDefaults: UserDefaults? = nil,
|
||||
outboxStore: MessageOutboxStore? = nil,
|
||||
sfMetrics: StoreAndForwardMetrics? = nil
|
||||
) {
|
||||
let conversations = conversations ?? ConversationStore()
|
||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||
@@ -797,7 +803,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
meshService: transport
|
||||
meshService: transport,
|
||||
outboxStore: outboxStore,
|
||||
sfMetrics: sfMetrics
|
||||
)
|
||||
|
||||
self.keychain = keychain
|
||||
@@ -1189,8 +1197,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// Clear persistent favorites from keychain
|
||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
|
||||
// Drop courier mail carried for third parties (memory and disk)
|
||||
// Drop courier mail carried for third parties (memory and disk),
|
||||
// our own queued outbox, the carried public history, and the
|
||||
// counters describing all of it
|
||||
CourierStore.shared.wipe()
|
||||
messageRouter.wipeOutbox()
|
||||
GossipMessageArchive.wipeDefault()
|
||||
StoreAndForwardMetrics.shared.reset()
|
||||
|
||||
// Identity manager has cleared persisted identity data above
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ struct ChatViewModelServiceBundle {
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
meshService: Transport
|
||||
meshService: Transport,
|
||||
outboxStore: MessageOutboxStore? = nil,
|
||||
sfMetrics: StoreAndForwardMetrics? = nil
|
||||
) {
|
||||
let commandProcessor = CommandProcessor(identityManager: identityManager)
|
||||
let privateChatManager = PrivateChatManager(meshService: meshService)
|
||||
@@ -28,7 +30,11 @@ struct ChatViewModelServiceBundle {
|
||||
)
|
||||
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
let messageRouter = MessageRouter(transports: [meshService, nostrTransport])
|
||||
let messageRouter = MessageRouter(
|
||||
transports: [meshService, nostrTransport],
|
||||
outboxStore: outboxStore,
|
||||
metrics: sfMetrics
|
||||
)
|
||||
|
||||
self.commandProcessor = commandProcessor
|
||||
self.messageRouter = messageRouter
|
||||
|
||||
Reference in New Issue
Block a user