mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 11:25:20 +00:00
* Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed, well-scoped findings; deeper architectural/security items are tracked separately. - PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s 150ms retry pause left the mic live and streaming for up to 120s, because cancel() no-op'd once `completed` was set. Bail after the sleep if the hold was released, and make cancel() always tear down a late-started capture. - Bridge courier depositDrop reported success and burned the dedup slot before the drop was actually published (evicted/compose-fail = lying 📦 "carried" with no retry). Only consume publishedDropKeys on durable accept; add BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey). - Blocked senders resurfaced via archived "heard here earlier" echoes, the one path that bypassed the live block filter — filter at seed time. - A late optimistic .sent clobbered the router's .carried state; extend ConversationStore.shouldSkipStatusUpdate to a full precedence guard (sending < sent < carried < delivered < read). - Read receipts were permanently burned when the router dropped them (marked sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only record as sent on a successful route, else retry on the next read scan. - MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a peer that never reconnects sat on .sending until relaunch — run it in the 120s bridge sweep. - Sightings tally now rolls over at midnight while idle; wave notification action localized across all 29 locales; bridged anon#tag uses suffix(4) like everything else; makeThrowawayIdentity delegates to NostrIdentity.generate(); .swiftlint.yml excludes .claude worktrees. - Add regression tests: carried→sent no-downgrade, carried→delivered upgrade, evicted pending drop stays retryable. - Bump MARKETING_VERSION to 1.7.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage The stricter no-downgrade guard (delivered/carried never regress to sent) broke two things the earlier commit didn't catch locally (perf tests are skipped in the default run, and Periphery runs only in CI): - PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered assuming both directions apply; the delivered -> sent half is now correctly skipped, so the pass measured 0 updates. Alternate two delivered timestamps instead — every update is real, no downgrade. - Routing the geoDM "not in a location channel" error into the thread removed the only caller of ChatPrivateConversationContext.addSystemMessage, leaving it (and its mock) dead per Periphery. Drop the protocol requirement, the mock impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is compile-time enforced: the context can no longer emit a public system line). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge - Read receipts: claim the receipt in sentReadReceipts synchronously before spawning the routing task (chat open runs two read scans in one MainActor stretch, so the async insert let every unread message route twice), and release the claim when the route fails so the retry-on-failed-route behavior is preserved. - Delivery status: extend the no-downgrade guard so the `.sending` stamp a pre-handshake resend emits can no longer clobber carried/delivered/read (the 📦 indicator survived `.sent` but not `.sending`). - Archived echoes: blocking a peer now purges their carried public messages from the gossip archive at block time (UnifiedPeerService and /block), while the fingerprint-to-peerID mapping is still known — the seed-time filter can't resolve offline non-favorite strangers and stays only as defense-in-depth. New Transport hook (default no-op) + GossipSyncManager.removePublicMessages with immediate persist. - Bridge courier: an envelope that can't encode within the drop size caps fails identically on every attempt; consume the dedup slot so the 120s retry sweep stops re-running Noise sealing on it. - MeshSightingsTracker: cache the day-key DateFormatter instead of building one per call. Tests: double-markAsRead dedup + failed-route retry, carried→sending no-downgrade matrix, block-time purge (manager + service wiring), oversize-drop slot consumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Also skip the sent→sending downgrade in the delivery-status guard Codex review follow-up: sendPrivateMessage without an established Noise session emits `.sending` asynchronously, so it can land after the message already reached `.sent` and visibly walk "Sent" back to "Sending...". Treat `.sending` as weaker than `.sent` too — the status was already truthful. `.failed` → `.sending` stays allowed so a retry after a real failure remains visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Retain Codable properties in Periphery scan (same fix as #1421) The noiseKey assign-only false positive fired persistently on this branch (twice, including a rerun) despite the baselined USR. Byte-identical to the fix on fix/announce-replay-link-steal so the branches merge cleanly in either order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
383 lines
14 KiB
Swift
383 lines
14 KiB
Swift
//
|
|
// UnifiedPeerService.swift
|
|
// bitchat
|
|
//
|
|
// Unified peer state management combining mesh connectivity and favorites
|
|
// This is free and unencumbered software released into the public domain.
|
|
//
|
|
|
|
import BitLogger
|
|
import BitFoundation
|
|
import Foundation
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
|
@MainActor
|
|
final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|
|
|
// MARK: - Published Properties
|
|
|
|
@Published private(set) var peers: [BitchatPeer] = []
|
|
@Published private(set) var connectedPeerIDs: Set<PeerID> = []
|
|
@Published private(set) var favorites: [BitchatPeer] = []
|
|
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
|
|
|
// MARK: - Private Properties
|
|
|
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
|
private var fingerprintCache: [PeerID: String] = [:]
|
|
private let meshService: Transport
|
|
private let idBridge: NostrIdentityBridge
|
|
private let identityManager: SecureIdentityStateManagerProtocol
|
|
weak var messageRouter: MessageRouter?
|
|
private let favoritesService = FavoritesPersistenceService.shared
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
meshService: Transport,
|
|
idBridge: NostrIdentityBridge,
|
|
identityManager: SecureIdentityStateManagerProtocol
|
|
) {
|
|
self.meshService = meshService
|
|
self.idBridge = idBridge
|
|
self.identityManager = identityManager
|
|
|
|
// Subscribe to changes from both services
|
|
setupSubscriptions()
|
|
|
|
// Perform initial update
|
|
Task { @MainActor in
|
|
updatePeers()
|
|
}
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setupSubscriptions() {
|
|
// Subscribe to mesh peer updates via delegate (preferred over publishers)
|
|
meshService.peerEventsDelegate = self
|
|
|
|
// Also listen for favorite change notifications
|
|
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.updatePeers()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// TransportPeerEventsDelegate
|
|
func didUpdatePeerSnapshots(_: [TransportPeerSnapshot]) {
|
|
updatePeers()
|
|
}
|
|
|
|
// MARK: - Core Update Logic
|
|
|
|
private func updatePeers() {
|
|
let meshPeers = meshService.currentPeerSnapshots()
|
|
// If we have no direct links at all, peers should not be marked reachable
|
|
// "Reachable" means mesh-attached via at least one live link.
|
|
let hasAnyConnected = meshPeers.contains { $0.isConnected }
|
|
let favorites = favoritesService.favorites
|
|
|
|
var enrichedPeers: [BitchatPeer] = []
|
|
var connected: Set<PeerID> = []
|
|
var addedPeerIDs: Set<PeerID> = []
|
|
var meshNoiseKeys: Set<Data> = []
|
|
|
|
// Phase 1: Add all mesh peers (connected and reachable)
|
|
for peerInfo in meshPeers {
|
|
let peerID = peerInfo.peerID
|
|
guard peerID != meshService.myPeerID else { continue } // Never add self
|
|
|
|
let peer = buildPeerFromMesh(
|
|
peerInfo: peerInfo,
|
|
favorites: favorites,
|
|
meshAttached: hasAnyConnected
|
|
)
|
|
|
|
enrichedPeers.append(peer)
|
|
if peer.isConnected { connected.insert(peerID) }
|
|
addedPeerIDs.insert(peerID)
|
|
|
|
// Update fingerprint cache
|
|
if let publicKey = peerInfo.noisePublicKey {
|
|
meshNoiseKeys.insert(publicKey)
|
|
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
|
|
}
|
|
}
|
|
|
|
// Phase 2: Add offline favorites that we actively favorite.
|
|
// Mesh rows use the short 16-hex peer ID while favorites are keyed by
|
|
// the full 32-byte noise key, so dedup must compare noise keys — a
|
|
// PeerID comparison between the two forms can never match.
|
|
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
|
if meshNoiseKeys.contains(favoriteKey) { continue }
|
|
|
|
let peerID = PeerID(hexData: favoriteKey)
|
|
if addedPeerIDs.contains(peerID) { continue }
|
|
|
|
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
|
|
enrichedPeers.append(peer)
|
|
addedPeerIDs.insert(peerID)
|
|
|
|
// Update fingerprint cache
|
|
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
|
|
}
|
|
|
|
// Phase 3: Sort peers
|
|
enrichedPeers.sort { lhs, rhs in
|
|
// Connectivity rank: connected > reachable > others
|
|
func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }
|
|
let lr = rank(lhs), rr = rank(rhs)
|
|
if lr != rr { return lr > rr }
|
|
// Then favorites inside same rank
|
|
if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }
|
|
// Finally alphabetical
|
|
return lhs.displayName < rhs.displayName
|
|
}
|
|
|
|
// Phase 4: Build subsets and indices
|
|
var favoritesList: [BitchatPeer] = []
|
|
var mutualsList: [BitchatPeer] = []
|
|
var newIndex: [PeerID: BitchatPeer] = [:]
|
|
|
|
for peer in enrichedPeers {
|
|
newIndex[peer.peerID] = peer
|
|
|
|
if peer.isFavorite {
|
|
favoritesList.append(peer)
|
|
}
|
|
if peer.isMutualFavorite {
|
|
mutualsList.append(peer)
|
|
}
|
|
}
|
|
|
|
// Phase 5: Filter out offline non-mutual peers and update published properties
|
|
let filtered = enrichedPeers.filter { p in
|
|
p.isConnected || p.isReachable || p.isMutualFavorite
|
|
}
|
|
self.peers = filtered
|
|
self.connectedPeerIDs = connected
|
|
self.favorites = favoritesList
|
|
self.mutualFavorites = mutualsList
|
|
self.peerIndex = newIndex
|
|
|
|
// Log summary (commented out to reduce noise)
|
|
// let connectedCount = connected.count
|
|
// let offlineCount = enrichedPeers.count - connectedCount
|
|
// Peer update: \(enrichedPeers.count) total (\(connectedCount) connected, \(offlineCount) offline)
|
|
}
|
|
|
|
// MARK: - Peer Building Helpers
|
|
|
|
private func buildPeerFromMesh(
|
|
peerInfo: TransportPeerSnapshot,
|
|
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship],
|
|
meshAttached: Bool
|
|
) -> BitchatPeer {
|
|
// Determine reachability based on lastSeen and identity trust
|
|
let now = Date()
|
|
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
|
|
let isVerified = fingerprint.map { identityManager.isVerified(fingerprint: $0) } ?? false
|
|
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
|
|
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
|
// A peer is reachable if we recently saw them AND we are attached to the mesh
|
|
let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention
|
|
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
|
|
|
var peer = BitchatPeer(
|
|
peerID: peerInfo.peerID,
|
|
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
|
nickname: peerInfo.nickname,
|
|
lastSeen: peerInfo.lastSeen,
|
|
isConnected: peerInfo.isConnected,
|
|
isReachable: isReachable
|
|
)
|
|
|
|
// Check for favorite status
|
|
if let noiseKey = peerInfo.noisePublicKey,
|
|
let favoriteStatus = favorites[noiseKey] {
|
|
peer.favoriteStatus = favoriteStatus
|
|
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
|
}
|
|
|
|
return peer
|
|
}
|
|
|
|
private func buildPeerFromFavorite(
|
|
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
|
peerID: PeerID
|
|
) -> BitchatPeer {
|
|
var peer = BitchatPeer(
|
|
peerID: peerID,
|
|
noisePublicKey: favorite.peerNoisePublicKey,
|
|
nickname: favorite.peerNickname,
|
|
lastSeen: favorite.lastUpdated,
|
|
isConnected: false,
|
|
isReachable: false
|
|
)
|
|
|
|
peer.favoriteStatus = favorite
|
|
peer.nostrPublicKey = favorite.peerNostrPublicKey
|
|
|
|
return peer
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Get peer by ID
|
|
func getPeer(by peerID: PeerID) -> BitchatPeer? {
|
|
return peerIndex[peerID]
|
|
}
|
|
|
|
/// Get peer ID for nickname
|
|
func getPeerID(for nickname: String) -> PeerID? {
|
|
for peer in peers {
|
|
if peer.displayName == nickname || peer.nickname == nickname {
|
|
return peer.peerID
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Check if peer is blocked
|
|
func isBlocked(_ peerID: PeerID) -> Bool {
|
|
// Get fingerprint
|
|
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
|
|
|
// Check SecureIdentityStateManager for block status
|
|
if let identity = identityManager.getSocialIdentity(for: fingerprint) {
|
|
return identity.isBlocked
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/// Block or unblock a mesh peer by its stable Noise identity.
|
|
///
|
|
/// The block is keyed by the peer's fingerprint, resolved from `peerID`
|
|
/// (cache / mesh session / known-peer Noise key). This works even when the
|
|
/// peer is offline — including offline favorites — so the exact tapped peer
|
|
/// is (un)blocked unambiguously instead of being re-resolved by a
|
|
/// display-name string that two peers could share.
|
|
/// - Returns: the resolved fingerprint, or `nil` if the identity is unknown.
|
|
@discardableResult
|
|
func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? {
|
|
guard let fingerprint = getFingerprint(for: peerID) else {
|
|
SecureLogger.warning(
|
|
"⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)",
|
|
category: .session
|
|
)
|
|
return nil
|
|
}
|
|
identityManager.setBlocked(fingerprint, isBlocked: blocked)
|
|
if blocked {
|
|
// Purge while the fingerprint↔peerID mapping is still known: the
|
|
// archived-echo seed filter can't resolve offline strangers, so
|
|
// scrub their carried messages now rather than at relaunch.
|
|
meshService.purgeArchivedPublicMessages(from: peerID)
|
|
}
|
|
updatePeers()
|
|
return fingerprint
|
|
}
|
|
|
|
/// Toggle favorite status
|
|
func toggleFavorite(_ peerID: PeerID) {
|
|
guard let peer = getPeer(by: peerID) else {
|
|
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
|
return
|
|
}
|
|
|
|
let wasFavorite = peer.isFavorite
|
|
|
|
// Get the actual nickname for logging and saving
|
|
var actualNickname = peer.nickname
|
|
|
|
// Debug logging to understand the issue
|
|
SecureLogger.debug("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", category: .session)
|
|
|
|
if actualNickname.isEmpty {
|
|
// Try to get from mesh service's current peer list
|
|
if let meshPeerNickname = meshService.peerNickname(peerID: peerID) {
|
|
actualNickname = meshPeerNickname
|
|
SecureLogger.debug("🔍 Got nickname from mesh service: '\(actualNickname)'", category: .session)
|
|
}
|
|
}
|
|
|
|
// Use displayName as fallback (which shows ID prefix if nickname is empty)
|
|
let finalNickname = actualNickname.isEmpty ? peer.displayName : actualNickname
|
|
|
|
if wasFavorite {
|
|
// Remove favorite
|
|
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
|
} else {
|
|
// Get or derive peer's Nostr public key if not already known
|
|
var peerNostrKey = peer.nostrPublicKey
|
|
if peerNostrKey == nil {
|
|
// Try to get from NostrIdentityBridge association
|
|
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
|
}
|
|
|
|
// Add favorite
|
|
favoritesService.addFavorite(
|
|
peerNoisePublicKey: peer.noisePublicKey,
|
|
peerNostrPublicKey: peerNostrKey,
|
|
peerNickname: finalNickname
|
|
)
|
|
}
|
|
|
|
// Log the final nickname being saved
|
|
SecureLogger.debug("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", category: .session)
|
|
|
|
// Send favorite notification to the peer via router (mesh or Nostr)
|
|
if let router = messageRouter {
|
|
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
|
} else {
|
|
// Fallback to mesh-only if router not yet wired
|
|
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
|
}
|
|
|
|
// Force update of peers to reflect the change
|
|
updatePeers()
|
|
|
|
// Force UI update by notifying SwiftUI directly
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.objectWillChange.send()
|
|
}
|
|
}
|
|
|
|
func getFingerprint(for peerID: PeerID) -> String? {
|
|
// Check cache first
|
|
if let cached = fingerprintCache[peerID] {
|
|
return cached
|
|
}
|
|
|
|
// Try to get from mesh service
|
|
if let fingerprint = meshService.getFingerprint(for: peerID) {
|
|
fingerprintCache[peerID] = fingerprint
|
|
return fingerprint
|
|
}
|
|
|
|
// Try to get from peer's public key
|
|
if let peer = getPeer(by: peerID) {
|
|
let fingerprint = peer.noisePublicKey.sha256Fingerprint()
|
|
fingerprintCache[peerID] = fingerprint
|
|
return fingerprint
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Compatibility Methods (for easy migration)
|
|
|
|
var blockedUsers: Set<String> {
|
|
Set(peers.compactMap { peer in
|
|
isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil
|
|
})
|
|
}
|
|
}
|