mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 11:25:20 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e142dcda8 | ||
|
|
ef4bdb3856 | ||
|
|
bbe1793506 | ||
|
|
af7a664685 | ||
|
|
b4b6aa5ca6 | ||
|
|
1e5a52f39f | ||
|
|
3fc64f6168 | ||
|
|
9964710de2 | ||
|
|
806c420313 | ||
|
|
194dedac43 | ||
|
|
9af46a9ff8 | ||
|
|
da3fcd5a21 | ||
|
|
b282536080 | ||
|
|
e156356c71 | ||
|
|
81a6e18d04 |
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.5.0
|
||||
MARKETING_VERSION = 1.5.1
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
@@ -8,9 +8,6 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
||||
|
||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||
|
||||
> [!WARNING]
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
@@ -279,8 +279,3 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var nilIfEmpty: String? {
|
||||
self.isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ struct BitchatPacket: Codable {
|
||||
var signature: Data?
|
||||
var ttl: UInt8
|
||||
var route: [Data]?
|
||||
var isRSR: Bool
|
||||
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
|
||||
self.version = version
|
||||
self.type = type
|
||||
self.senderID = senderID
|
||||
@@ -33,10 +34,11 @@ struct BitchatPacket: Codable {
|
||||
self.signature = signature
|
||||
self.ttl = ttl
|
||||
self.route = route
|
||||
self.isRSR = isRSR
|
||||
}
|
||||
|
||||
// Convenience initializer for new binary format
|
||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
// Convert hex string peer ID to binary data (8 bytes)
|
||||
@@ -56,6 +58,7 @@ struct BitchatPacket: Codable {
|
||||
self.signature = nil
|
||||
self.ttl = ttl
|
||||
self.route = nil
|
||||
self.isRSR = isRSR
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
@@ -85,7 +88,8 @@ struct BitchatPacket: Codable {
|
||||
signature: nil, // Remove signature for signing
|
||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
version: version,
|
||||
route: route
|
||||
route: route,
|
||||
isRSR: false // RSR flag is mutable and not part of the signature
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
}
|
||||
|
||||
@@ -9,12 +9,16 @@ struct RequestSyncPacket {
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
let types: SyncTypeFlags?
|
||||
let sinceTimestamp: UInt64?
|
||||
let fragmentIdFilter: String?
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
self.data = data
|
||||
self.types = types
|
||||
self.sinceTimestamp = sinceTimestamp
|
||||
self.fragmentIdFilter = fragmentIdFilter
|
||||
}
|
||||
|
||||
func encode() -> Data {
|
||||
@@ -36,15 +40,24 @@ struct RequestSyncPacket {
|
||||
if let typesData = types?.toData() {
|
||||
putTLV(0x04, typesData)
|
||||
}
|
||||
if let ts = sinceTimestamp {
|
||||
var tsBE = ts.bigEndian
|
||||
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
|
||||
}
|
||||
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
|
||||
putTLV(0x06, fidData)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
||||
var off = 0
|
||||
var p: Int? = nil
|
||||
var m: UInt32? = nil
|
||||
var payload: Data? = nil
|
||||
var types: SyncTypeFlags? = nil
|
||||
var sinceTimestamp: UInt64? = nil
|
||||
var fragmentIdFilter: String? = nil
|
||||
|
||||
while off + 3 <= data.count {
|
||||
let t = Int(data[off]); off += 1
|
||||
@@ -68,12 +81,22 @@ struct RequestSyncPacket {
|
||||
if let decoded = SyncTypeFlags.decode(v) {
|
||||
types = decoded
|
||||
}
|
||||
case 0x05:
|
||||
if v.count == 8 {
|
||||
var ts: UInt64 = 0
|
||||
for b in v { ts = (ts << 8) | UInt64(b) }
|
||||
sinceTimestamp = ts
|
||||
}
|
||||
case 0x06:
|
||||
if let fid = String(data: v, encoding: .utf8) {
|
||||
fragmentIdFilter = fid
|
||||
}
|
||||
default:
|
||||
break // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var messageQueue: [PendingSend] = []
|
||||
private let messageQueueLock = NSLock()
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||
|
||||
@@ -79,8 +78,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
||||
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
||||
|
||||
// Reconnection timer
|
||||
private var reconnectionTimer: Timer?
|
||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ struct BinaryProtocol {
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
static let isCompressed: UInt8 = 0x04
|
||||
static let hasRoute: UInt8 = 0x08
|
||||
static let isRSR: UInt8 = 0x10
|
||||
}
|
||||
|
||||
// Encode BitchatPacket to binary format
|
||||
@@ -204,8 +205,9 @@ struct BinaryProtocol {
|
||||
if isCompressed { flags |= Flags.isCompressed }
|
||||
// HAS_ROUTE is only valid for v2+ packets
|
||||
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
|
||||
if packet.isRSR { flags |= Flags.isRSR }
|
||||
data.append(flags)
|
||||
|
||||
|
||||
if version == 2 {
|
||||
let length = UInt32(payloadDataSize)
|
||||
for shift in stride(from: 24, through: 0, by: -8) {
|
||||
@@ -329,7 +331,8 @@ struct BinaryProtocol {
|
||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
|
||||
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
|
||||
|
||||
let isRSR = (flags & Flags.isRSR) != 0
|
||||
|
||||
let payloadLength: Int
|
||||
if version == 2 {
|
||||
guard let len = read32() else { return nil }
|
||||
@@ -410,7 +413,8 @@ struct BinaryProtocol {
|
||||
signature: signature,
|
||||
ttl: ttl,
|
||||
version: version,
|
||||
route: route
|
||||
route: route,
|
||||
isRSR: isRSR
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import CryptoKit
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
|
||||
/// BLEService — Bluetooth Mesh Transport
|
||||
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
||||
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
||||
@@ -138,11 +137,6 @@ final class BLEService: NSObject {
|
||||
private var pendingMessagesAfterHandshake: [PeerID: [(content: String, messageID: String)]] = [:]
|
||||
// Noise typed payloads (ACKs, read receipts, etc.) pending handshake
|
||||
private var pendingNoisePayloadsAfterHandshake: [PeerID: [Data]] = [:]
|
||||
// Keep a tiny buffer of the last few unique announces we've seen (by sender)
|
||||
private var recentAnnounceBySender: [PeerID: BitchatPacket] = [:]
|
||||
private var recentAnnounceOrder: [PeerID] = []
|
||||
private let recentAnnounceBufferCap = 3
|
||||
|
||||
// Queue for notifications that failed due to full queue
|
||||
private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = []
|
||||
|
||||
@@ -200,6 +194,7 @@ final class BLEService: NSObject {
|
||||
|
||||
// MARK: - Gossip Sync
|
||||
private var gossipSyncManager: GossipSyncManager?
|
||||
private let requestSyncManager = RequestSyncManager()
|
||||
|
||||
// MARK: - Maintenance Timer
|
||||
|
||||
@@ -333,6 +328,31 @@ final class BLEService: NSObject {
|
||||
// Initialize gossip sync manager
|
||||
restartGossipManager()
|
||||
}
|
||||
|
||||
private func restartGossipManager() {
|
||||
// Stop existing
|
||||
gossipSyncManager?.stop()
|
||||
|
||||
let config = GossipSyncManager.Config(
|
||||
seenCapacity: TransportConfig.syncSeenCapacity,
|
||||
gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
|
||||
gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
|
||||
maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
|
||||
maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
|
||||
stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
|
||||
stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
|
||||
fragmentCapacity: TransportConfig.syncFragmentCapacity,
|
||||
fileTransferCapacity: TransportConfig.syncFileTransferCapacity,
|
||||
fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds,
|
||||
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
|
||||
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds
|
||||
)
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
manager.delegate = self
|
||||
manager.start()
|
||||
gossipSyncManager = manager
|
||||
}
|
||||
|
||||
// No advertising policy to set; we never include Local Name in adverts.
|
||||
|
||||
@@ -354,8 +374,6 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
recentAnnounceBySender.removeAll()
|
||||
recentAnnounceOrder.removeAll()
|
||||
pendingPeripheralWrites.removeAll()
|
||||
pendingFragmentTransfers.removeAll()
|
||||
pendingNotifications.removeAll()
|
||||
@@ -514,45 +532,52 @@ final class BLEService: NSObject {
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
// Send immediately to all connected peers
|
||||
|
||||
// Send immediately to all connected peers (synchronized access to BLE state)
|
||||
if let data = leavePacket.toBinaryData(padding: false) {
|
||||
let leavePriority = priority(for: leavePacket, data: data)
|
||||
|
||||
// Snapshot BLE state under bleQueue to avoid races with delegate callbacks
|
||||
let (peripheralStates, centralsCount, char) = bleQueue.sync {
|
||||
(Array(peripherals.values), subscribedCentrals.count, characteristic)
|
||||
}
|
||||
|
||||
// Send to peripherals we're connected to as central
|
||||
for state in peripherals.values where state.isConnected {
|
||||
for state in peripheralStates where state.isConnected {
|
||||
if let characteristic = state.characteristic {
|
||||
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: leavePriority)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Send to centrals subscribed to us as peripheral
|
||||
if subscribedCentrals.count > 0 && characteristic != nil {
|
||||
peripheralManager?.updateValue(data, for: characteristic!, onSubscribedCentrals: nil)
|
||||
if centralsCount > 0, let ch = char {
|
||||
peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Give leave message a moment to send (cooperative delay allows BLE callbacks to fire)
|
||||
let deadline = Date().addingTimeInterval(TransportConfig.bleThreadSleepWriteShortDelaySeconds)
|
||||
while Date() < deadline {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
|
||||
}
|
||||
|
||||
|
||||
// Clear pending notifications
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
pendingNotifications.removeAll()
|
||||
}
|
||||
|
||||
|
||||
// Stop timer
|
||||
maintenanceTimer?.cancel()
|
||||
maintenanceTimer = nil
|
||||
scanDutyTimer?.cancel()
|
||||
scanDutyTimer = nil
|
||||
|
||||
|
||||
centralManager?.stopScan()
|
||||
peripheralManager?.stopAdvertising()
|
||||
|
||||
// Disconnect all peripherals
|
||||
for state in peripherals.values {
|
||||
|
||||
// Disconnect all peripherals (synchronized access)
|
||||
let peripheralsToDisconnect = bleQueue.sync { Array(peripherals.values) }
|
||||
for state in peripheralsToDisconnect {
|
||||
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||||
}
|
||||
}
|
||||
@@ -567,6 +592,10 @@ final class BLEService: NSObject {
|
||||
incomingFragments.removeAll()
|
||||
fragmentMetadata.removeAll()
|
||||
activeTransfers.removeAll()
|
||||
// Also clear pending message queues to avoid stale state across sessions
|
||||
pendingMessagesAfterHandshake.removeAll()
|
||||
pendingNoisePayloadsAfterHandshake.removeAll()
|
||||
pendingDirectedRelays.removeAll()
|
||||
return entries
|
||||
}
|
||||
|
||||
@@ -578,15 +607,15 @@ final class BLEService: NSObject {
|
||||
// Clear processed messages
|
||||
messageDeduplicator.reset()
|
||||
|
||||
// Clear peripheral references
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
// Clear peripheral references (synchronized access to avoid races with BLE callbacks)
|
||||
bleQueue.sync {
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
centralSubscriptionRateLimits.removeAll()
|
||||
}
|
||||
meshTopology.reset()
|
||||
|
||||
// BCH-01-004: Clear rate-limit state
|
||||
centralSubscriptionRateLimits.removeAll()
|
||||
}
|
||||
|
||||
// MARK: Connectivity and peers
|
||||
@@ -790,6 +819,42 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private enum ConnectionSource {
|
||||
case peripheral(String)
|
||||
case central(String)
|
||||
case unknown
|
||||
}
|
||||
|
||||
private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID, connectionSource: ConnectionSource = .unknown) -> Bool {
|
||||
let currentTime = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
let isRSR = packet.isRSR
|
||||
var skipTimestampCheck = false
|
||||
|
||||
if isRSR {
|
||||
if requestSyncManager.isValidResponse(from: peerID, isRSR: true) {
|
||||
SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security)
|
||||
skipTimestampCheck = true
|
||||
} else {
|
||||
SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if !skipTimestampCheck {
|
||||
let maxSkew: UInt64 = 120_000
|
||||
let packetTime = packet.timestamp
|
||||
let skew = (packetTime > currentTime) ? (packetTime - currentTime) : (currentTime - packetTime)
|
||||
|
||||
if skew > maxSkew {
|
||||
SecureLogger.warning("Packet timestamp skewed by \(skew)ms (max \(maxSkew)ms) from \(peerID.id.prefix(8))…", category: .security)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Packet Broadcasting
|
||||
|
||||
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
|
||||
@@ -1006,7 +1071,13 @@ final class BLEService: NSObject {
|
||||
if let ch = characteristic {
|
||||
let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) }
|
||||
if !targets.isEmpty {
|
||||
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets)
|
||||
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
|
||||
if !success {
|
||||
// Notification queue full - queue for retry to prevent silent packet loss
|
||||
// This is critical for fragment delivery reliability
|
||||
let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast"
|
||||
enqueuePendingNotification(data: data, centrals: targets, context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1053,34 +1124,6 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func rebroadcastRecentAnnounces() {
|
||||
// Snapshot sender order to preserve ordering and avoid holding locks while sending
|
||||
let packets: [BitchatPacket] = collectionsQueue.sync {
|
||||
recentAnnounceOrder.compactMap { recentAnnounceBySender[$0] }
|
||||
}
|
||||
guard !packets.isEmpty else { return }
|
||||
for (idx, pkt) in packets.enumerated() {
|
||||
// Stagger slightly to avoid bursts
|
||||
let delayMs = idx * 20
|
||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
||||
self?.broadcastPacket(pkt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendData(_ data: Data, to peripheral: CBPeripheral) {
|
||||
// Fire-and-forget: Simple send without complex fallback logic
|
||||
guard peripheral.state == .connected else { return }
|
||||
|
||||
let peripheralUUID = peripheral.identifier.uuidString
|
||||
guard let state = peripherals[peripheralUUID],
|
||||
let characteristic = state.characteristic else { return }
|
||||
|
||||
// Fire-and-forget principle: always use .withoutResponse for speed
|
||||
// CoreBluetooth will handle fragmentation at L2CAP layer
|
||||
writeOrEnqueue(data, to: peripheral, characteristic: characteristic, priority: .high)
|
||||
}
|
||||
|
||||
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
if peerID == myPeerID && packet.ttl != 0 { return }
|
||||
|
||||
@@ -1120,13 +1163,6 @@ final class BLEService: NSObject {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !accepted && packet.ttl == 0 {
|
||||
accepted = true
|
||||
senderNickname = "anon" + String(peerID.id.prefix(4))
|
||||
}
|
||||
} else if packet.ttl == 0 {
|
||||
accepted = true
|
||||
senderNickname = "anon" + String(peerID.id.prefix(4))
|
||||
}
|
||||
|
||||
guard accepted else {
|
||||
@@ -1474,17 +1510,6 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendLeave() {
|
||||
SecureLogger.debug("👋 Sending leave announcement", category: .session)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
ttl: messageTTL,
|
||||
senderID: myPeerID,
|
||||
payload: Data(myNickname.utf8)
|
||||
)
|
||||
broadcastPacket(packet)
|
||||
}
|
||||
|
||||
private func sendAnnounce(forceSend: Bool = false) {
|
||||
// Throttle announces to prevent flooding
|
||||
let now = Date()
|
||||
@@ -1576,6 +1601,12 @@ extension BLEService: GossipSyncManager.Delegate {
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||
return noiseService.signPacket(packet) ?? packet
|
||||
}
|
||||
|
||||
func getConnectedPeers() -> [PeerID] {
|
||||
return collectionsQueue.sync {
|
||||
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
@@ -1629,9 +1660,50 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
self.delegate?.didUpdateBluetoothState(central.state)
|
||||
}
|
||||
|
||||
if central.state == .poweredOn {
|
||||
switch central.state {
|
||||
case .poweredOn:
|
||||
// Start scanning - use allow duplicates for faster discovery when active
|
||||
startScanning()
|
||||
|
||||
case .poweredOff:
|
||||
// Bluetooth was turned off - stop scanning and clean up connection state
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
|
||||
central.stopScan()
|
||||
// Mark all peripheral connections as disconnected (they are now invalid)
|
||||
let peerIDs: [PeerID] = peripherals.compactMap { $0.value.peerID }
|
||||
for state in peripherals.values {
|
||||
central.cancelPeripheralConnection(state.peripheral)
|
||||
}
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
// Notify UI of disconnections
|
||||
for peerID in peerIDs {
|
||||
notifyUI { [weak self] in
|
||||
self?.notifyPeerDisconnectedDebounced(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
|
||||
central.stopScan()
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
|
||||
case .unsupported:
|
||||
// Device doesn't support BLE
|
||||
SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session)
|
||||
|
||||
case .resetting:
|
||||
// Bluetooth stack is resetting - will get another state update when done
|
||||
SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session)
|
||||
|
||||
case .unknown:
|
||||
// Initial state before we know the actual state
|
||||
SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session)
|
||||
|
||||
@unknown default:
|
||||
SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1772,15 +1844,22 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
guard let self = self,
|
||||
let state = self.peripherals[peripheralID],
|
||||
state.isConnecting && !state.isConnected else { return }
|
||||
|
||||
|
||||
// Double-check actual CBPeripheral state to avoid canceling a just-connected peripheral
|
||||
// This prevents a race where connection completes just as timeout fires
|
||||
guard peripheral.state != .connected else {
|
||||
SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(advertisedName)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Connection timed out - cancel it
|
||||
SecureLogger.debug("⏱️ Timeout: \(advertisedName)", category: .session)
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
self.peripherals[peripheralID] = nil
|
||||
self.recentConnectTimeouts[peripheralID] = Date()
|
||||
self.failureCounts[peripheralID, default: 0] += 1
|
||||
// Try next candidate if any
|
||||
self.tryConnectFromQueue()
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
self.peripherals[peripheralID] = nil
|
||||
self.recentConnectTimeouts[peripheralID] = Date()
|
||||
self.failureCounts[peripheralID, default: 0] += 1
|
||||
// Try next candidate if any
|
||||
self.tryConnectFromQueue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2059,8 +2138,6 @@ extension BLEService: CBPeripheralDelegate {
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
// Try flushing any spooled directed packets now that we have a link
|
||||
self?.flushDirectedSpool()
|
||||
// Rebroadcast a couple of recent announces to seed the new link
|
||||
self?.rebroadcastRecentAnnounces()
|
||||
}
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session)
|
||||
@@ -2106,6 +2183,13 @@ extension BLEService: CBPeripheralDelegate {
|
||||
if result.reset {
|
||||
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
|
||||
}
|
||||
|
||||
// Codex review identified TOCTOU in this patch.
|
||||
// Enforce per-link sender binding immediately within the same notification batch.
|
||||
// NOTE: `processNotificationPacket` may bind `peripherals[peripheralUUID].peerID` when an announce
|
||||
// is processed, but `state` above is a snapshot. Track a local binding that we update as soon as
|
||||
// we see a binding-eligible announce so subsequent frames can't spoof a different sender.
|
||||
var boundPeerID: PeerID? = state.peerID
|
||||
|
||||
for frame in result.frames {
|
||||
guard let packet = BinaryProtocol.decode(frame) else {
|
||||
@@ -2113,6 +2197,32 @@ extension BLEService: CBPeripheralDelegate {
|
||||
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
|
||||
continue
|
||||
}
|
||||
|
||||
let claimedSenderID = PeerID(hexData: packet.senderID)
|
||||
|
||||
let trustedSenderID: PeerID?
|
||||
if let knownPeerID = boundPeerID {
|
||||
if knownPeerID != claimedSenderID {
|
||||
SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Peripheral \(peripheralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))…", category: .security)
|
||||
continue
|
||||
}
|
||||
trustedSenderID = knownPeerID
|
||||
} else {
|
||||
trustedSenderID = nil
|
||||
}
|
||||
|
||||
if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .peripheral(peripheralUUID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// If this is a direct-link announce, bind immediately for the remainder of this batch.
|
||||
if boundPeerID == nil,
|
||||
packet.type == MessageType.announce.rawValue,
|
||||
packet.ttl == messageTTL {
|
||||
boundPeerID = claimedSenderID
|
||||
state.peerID = claimedSenderID
|
||||
peripherals[peripheralUUID] = state
|
||||
}
|
||||
processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID)
|
||||
}
|
||||
}
|
||||
@@ -2158,7 +2268,8 @@ extension BLEService: CBPeripheralDelegate {
|
||||
}
|
||||
|
||||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||||
// Resume queued writes for this peripheral
|
||||
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
|
||||
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
|
||||
drainPendingWrites(for: peripheral)
|
||||
}
|
||||
|
||||
@@ -2191,6 +2302,7 @@ extension BLEService: CBPeripheralDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralManagerDelegate
|
||||
@@ -2198,11 +2310,12 @@ extension BLEService: CBPeripheralDelegate {
|
||||
extension BLEService: CBPeripheralManagerDelegate {
|
||||
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
|
||||
SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)
|
||||
|
||||
if peripheral.state == .poweredOn {
|
||||
|
||||
switch peripheral.state {
|
||||
case .poweredOn:
|
||||
// Remove all services first to ensure clean state
|
||||
peripheral.removeAllServices()
|
||||
|
||||
|
||||
// Create characteristic
|
||||
characteristic = CBMutableCharacteristic(
|
||||
type: BLEService.characteristicUUID,
|
||||
@@ -2210,14 +2323,54 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
value: nil,
|
||||
permissions: [.readable, .writeable]
|
||||
)
|
||||
|
||||
|
||||
// Create service
|
||||
let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
|
||||
service.characteristics = [characteristic!]
|
||||
|
||||
|
||||
// Add service (advertising will start in didAdd delegate)
|
||||
SecureLogger.debug("🔧 Adding BLE service...", category: .session)
|
||||
peripheral.add(service)
|
||||
|
||||
case .poweredOff:
|
||||
// Bluetooth was turned off - clean up peripheral state
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
|
||||
peripheral.stopAdvertising()
|
||||
// Clear subscribed centrals (they are now invalid)
|
||||
let centralPeerIDs = centralToPeerID.values.map { $0 }
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
centralSubscriptionRateLimits.removeAll()
|
||||
characteristic = nil
|
||||
// Notify UI of disconnections
|
||||
for peerID in centralPeerIDs {
|
||||
notifyUI { [weak self] in
|
||||
self?.notifyPeerDisconnectedDebounced(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
|
||||
peripheral.stopAdvertising()
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
centralSubscriptionRateLimits.removeAll()
|
||||
characteristic = nil
|
||||
|
||||
case .unsupported:
|
||||
// Device doesn't support BLE peripheral role
|
||||
SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session)
|
||||
|
||||
case .resetting:
|
||||
// Bluetooth stack is resetting
|
||||
SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session)
|
||||
|
||||
case .unknown:
|
||||
SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session)
|
||||
|
||||
@unknown default:
|
||||
SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2302,10 +2455,9 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
// Still flush directed packets and rebroadcast for legitimate mesh operation
|
||||
// Still flush directed packets for legitimate mesh operation
|
||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
|
||||
self?.flushDirectedSpool()
|
||||
self?.rebroadcastRecentAnnounces()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -2331,8 +2483,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
// Flush any spooled directed packets now that we have a central subscribed
|
||||
self?.flushDirectedSpool()
|
||||
// Rebroadcast a couple of recent announces to seed the new link
|
||||
self?.rebroadcastRecentAnnounces()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2398,28 +2548,38 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
self.pendingNotifications.removeAll()
|
||||
|
||||
// Try to send pending notifications
|
||||
for (data, centrals) in pending {
|
||||
var sentCount = 0
|
||||
for (index, (data, centrals)) in pending.enumerated() {
|
||||
if let centrals = centrals {
|
||||
// Send to specific centrals
|
||||
let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false
|
||||
if !success {
|
||||
// Still full, re-queue
|
||||
self.pendingNotifications.append((data: data, centrals: centrals))
|
||||
SecureLogger.debug("⚠️ Notification queue still full, re-queuing", category: .session)
|
||||
// Still full, re-queue this and all remaining items
|
||||
let remaining = pending.dropFirst(index)
|
||||
self.pendingNotifications.append(contentsOf: remaining)
|
||||
SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session)
|
||||
break // Stop trying, wait for next ready callback
|
||||
} else {
|
||||
SecureLogger.debug("✅ Sent pending notification from retry queue", category: .session)
|
||||
sentCount += 1
|
||||
}
|
||||
} else {
|
||||
// Broadcast to all
|
||||
let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false
|
||||
if !success {
|
||||
// Still full, re-queue
|
||||
self.pendingNotifications.append((data: data, centrals: nil))
|
||||
// Still full, re-queue this and all remaining items
|
||||
let remaining = pending.dropFirst(index)
|
||||
self.pendingNotifications.append(contentsOf: remaining)
|
||||
SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session)
|
||||
break
|
||||
} else {
|
||||
sentCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sentCount > 0 {
|
||||
SecureLogger.debug("✅ Sent \(sentCount) pending notifications from retry queue", category: .session)
|
||||
}
|
||||
|
||||
if !self.pendingNotifications.isEmpty {
|
||||
SecureLogger.debug("📋 Still have \(self.pendingNotifications.count) pending notifications", category: .session)
|
||||
@@ -2477,16 +2637,33 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
if let packet = BinaryProtocol.decode(combined) {
|
||||
// Clear buffer on success
|
||||
pendingWriteBuffers.removeValue(forKey: centralUUID)
|
||||
let senderID = PeerID(hexData: packet.senderID)
|
||||
|
||||
let claimedSenderID = PeerID(hexData: packet.senderID)
|
||||
|
||||
let trustedSenderID: PeerID?
|
||||
if let knownPeerID = centralToPeerID[centralUUID] {
|
||||
if knownPeerID != claimedSenderID {
|
||||
SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Central \(centralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))…", category: .security)
|
||||
continue
|
||||
}
|
||||
trustedSenderID = knownPeerID
|
||||
} else {
|
||||
trustedSenderID = nil
|
||||
}
|
||||
|
||||
if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .central(centralUUID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if packet.type != MessageType.announce.rawValue {
|
||||
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(senderID)", category: .session)
|
||||
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID)", category: .session)
|
||||
}
|
||||
if !subscribedCentrals.contains(sorted[0].central) {
|
||||
subscribedCentrals.append(sorted[0].central)
|
||||
}
|
||||
if packet.type == MessageType.announce.rawValue {
|
||||
if packet.ttl == messageTTL {
|
||||
centralToPeerID[centralUUID] = senderID
|
||||
centralToPeerID[centralUUID] = claimedSenderID
|
||||
refreshLocalTopology()
|
||||
}
|
||||
// Record ingress link for last-hop suppression then process
|
||||
@@ -2494,14 +2671,14 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
|
||||
}
|
||||
handleReceivedPacket(packet, from: senderID)
|
||||
handleReceivedPacket(packet, from: claimedSenderID)
|
||||
} else {
|
||||
// Record ingress link for last-hop suppression then process
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
|
||||
}
|
||||
handleReceivedPacket(packet, from: senderID)
|
||||
handleReceivedPacket(packet, from: claimedSenderID)
|
||||
}
|
||||
} else {
|
||||
// If buffer grows suspiciously large, reset to avoid memory leak
|
||||
@@ -2726,17 +2903,19 @@ extension BLEService {
|
||||
meshTopology.reset()
|
||||
}
|
||||
|
||||
private func restartGossipManager() {
|
||||
gossipSyncManager?.stop()
|
||||
let sync = GossipSyncManager(myPeerID: myPeerID)
|
||||
sync.delegate = self
|
||||
sync.start()
|
||||
gossipSyncManager = sync
|
||||
}
|
||||
|
||||
|
||||
private func sendNoisePayload(_ typedPayload: Data, to peerID: PeerID) {
|
||||
guard noiseService.hasSession(with: peerID) else {
|
||||
// Lazy-handshake path: queue? For now, initiate handshake and drop
|
||||
// No session yet - queue the payload SYNCHRONOUSLY before initiating handshake
|
||||
// to prevent race where fast handshake completion drains empty queue
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if self.pendingNoisePayloadsAfterHandshake[peerID] == nil {
|
||||
self.pendingNoisePayloadsAfterHandshake[peerID] = []
|
||||
}
|
||||
self.pendingNoisePayloadsAfterHandshake[peerID]?.append(typedPayload)
|
||||
SecureLogger.debug("📥 Queued noise payload for \(peerID) pending handshake", category: .session)
|
||||
}
|
||||
initiateNoiseHandshake(with: peerID)
|
||||
return
|
||||
}
|
||||
@@ -2874,13 +3053,19 @@ extension BLEService {
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let state = self.peripherals[uuid], let ch = state.characteristic else { return }
|
||||
var queueCopy: [PendingWrite] = []
|
||||
self.collectionsQueue.sync {
|
||||
queueCopy = self.pendingPeripheralWrites[uuid] ?? []
|
||||
|
||||
// Atomically take all pending items from the queue to avoid race conditions
|
||||
// where new items could be enqueued between read and update
|
||||
let itemsToSend: [PendingWrite] = self.collectionsQueue.sync(flags: .barrier) {
|
||||
let items = self.pendingPeripheralWrites[uuid] ?? []
|
||||
self.pendingPeripheralWrites[uuid] = nil
|
||||
return items
|
||||
}
|
||||
guard !queueCopy.isEmpty else { return }
|
||||
guard !itemsToSend.isEmpty else { return }
|
||||
|
||||
// Send as many as possible
|
||||
var sent = 0
|
||||
for item in queueCopy {
|
||||
for item in itemsToSend {
|
||||
if peripheral.canSendWriteWithoutResponse {
|
||||
peripheral.writeValue(item.data, for: ch, type: .withoutResponse)
|
||||
sent += 1
|
||||
@@ -2888,23 +3073,66 @@ extension BLEService {
|
||||
break
|
||||
}
|
||||
}
|
||||
if sent > 0 {
|
||||
|
||||
// Re-enqueue any items that couldn't be sent (maintaining order)
|
||||
let unsent = Array(itemsToSend.dropFirst(sent))
|
||||
if !unsent.isEmpty {
|
||||
self.collectionsQueue.async(flags: .barrier) {
|
||||
var q = self.pendingPeripheralWrites[uuid] ?? []
|
||||
if sent > 0 {
|
||||
let toRemove = min(sent, q.count)
|
||||
if toRemove > 0 {
|
||||
q.removeFirst(toRemove)
|
||||
}
|
||||
self.pendingPeripheralWrites[uuid] = q.isEmpty ? nil : q
|
||||
}
|
||||
var existing = self.pendingPeripheralWrites[uuid] ?? []
|
||||
// Prepend unsent items to maintain priority order
|
||||
existing.insert(contentsOf: unsent, at: 0)
|
||||
self.pendingPeripheralWrites[uuid] = existing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Periodically try to drain pending notifications as a backup mechanism
|
||||
private func drainPendingNotificationsIfPossible() {
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self,
|
||||
let characteristic = self.characteristic,
|
||||
!self.pendingNotifications.isEmpty else { return }
|
||||
|
||||
let pending = self.pendingNotifications
|
||||
self.pendingNotifications.removeAll()
|
||||
|
||||
var sentCount = 0
|
||||
for (index, (data, centrals)) in pending.enumerated() {
|
||||
let success: Bool
|
||||
if let centrals = centrals {
|
||||
success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false
|
||||
} else {
|
||||
success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false
|
||||
}
|
||||
|
||||
if !success {
|
||||
// Re-queue this and all remaining items
|
||||
let remaining = pending.dropFirst(index)
|
||||
self.pendingNotifications.append(contentsOf: remaining)
|
||||
break
|
||||
} else {
|
||||
sentCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if sentCount > 0 {
|
||||
SecureLogger.debug("🔄 Periodic drain: sent \(sentCount) pending notifications", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodically try to drain pending writes for all connected peripherals
|
||||
private func drainAllPendingWrites() {
|
||||
let uuids = collectionsQueue.sync { Array(pendingPeripheralWrites.keys) }
|
||||
for uuid in uuids {
|
||||
guard let state = peripherals[uuid], state.isConnected else { continue }
|
||||
drainPendingWrites(for: state.peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Application State Handlers (iOS)
|
||||
|
||||
|
||||
#if os(iOS)
|
||||
@objc private func appDidBecomeActive() {
|
||||
isAppActive = true
|
||||
@@ -3033,17 +3261,20 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
|
||||
// Get and clear pending messages for this peer
|
||||
// Atomically take all pending messages to process (prevents concurrent modification)
|
||||
let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [(content: String, messageID: String)]? in
|
||||
let messages = pendingMessagesAfterHandshake[peerID]
|
||||
pendingMessagesAfterHandshake.removeValue(forKey: peerID)
|
||||
return messages
|
||||
}
|
||||
|
||||
|
||||
guard let messages = pendingMessages, !messages.isEmpty else { return }
|
||||
|
||||
|
||||
SecureLogger.debug("📤 Sending \(messages.count) pending messages after handshake to \(peerID)", category: .session)
|
||||
|
||||
|
||||
// Track failed messages for re-queuing
|
||||
var failedMessages: [(content: String, messageID: String)] = []
|
||||
|
||||
// Send each pending message directly (we know session is established)
|
||||
for (content, messageID) in messages {
|
||||
do {
|
||||
@@ -3051,6 +3282,7 @@ extension BLEService {
|
||||
let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlvData = privateMessage.encode() else {
|
||||
SecureLogger.error("Failed to encode pending private message TLV")
|
||||
failedMessages.append((content, messageID))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -3080,6 +3312,7 @@ extension BLEService {
|
||||
SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to send pending message after handshake: \(error)")
|
||||
failedMessages.append((content, messageID))
|
||||
|
||||
// Notify delegate of failure
|
||||
notifyUI { [weak self] in
|
||||
@@ -3087,6 +3320,19 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-queue any failed messages for retry on next handshake
|
||||
if !failedMessages.isEmpty {
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.pendingMessagesAfterHandshake[peerID] == nil {
|
||||
self.pendingMessagesAfterHandshake[peerID] = []
|
||||
}
|
||||
// Prepend failed messages to maintain order
|
||||
self.pendingMessagesAfterHandshake[peerID]?.insert(contentsOf: failedMessages, at: 0)
|
||||
SecureLogger.warning("⚠️ Re-queued \(failedMessages.count) failed messages for \(peerID)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Fragmentation (Required for messages > BLE MTU)
|
||||
@@ -3235,7 +3481,8 @@ extension BLEService {
|
||||
signature: nil,
|
||||
ttl: packet.ttl,
|
||||
version: fragmentVersion,
|
||||
route: packet.route
|
||||
route: packet.route,
|
||||
isRSR: packet.isRSR
|
||||
)
|
||||
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
@@ -3426,9 +3673,16 @@ extension BLEService {
|
||||
|
||||
// Decode the original packet bytes we reassembled, so flags/compression are preserved
|
||||
if var originalPacket = BinaryProtocol.decode(reassembled) {
|
||||
SecureLogger.debug("✅ Reassembled packet id=\(String(format: "%016llx", fragU64)) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
|
||||
originalPacket.ttl = 0
|
||||
handleReceivedPacket(originalPacket, from: peerID)
|
||||
|
||||
// Reassembled packet validation
|
||||
let innerSender = PeerID(hexData: originalPacket.senderID)
|
||||
if !validatePacket(originalPacket, from: innerSender) {
|
||||
// Cleanup below
|
||||
} else {
|
||||
SecureLogger.debug("✅ Reassembled packet id=\(String(format: "%016llx", fragU64)) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
|
||||
originalPacket.ttl = 0
|
||||
handleReceivedPacket(originalPacket, from: peerID)
|
||||
}
|
||||
} else {
|
||||
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session)
|
||||
}
|
||||
@@ -3715,9 +3969,6 @@ extension BLEService {
|
||||
claimedNickname: announcement.nickname
|
||||
)
|
||||
|
||||
// Record this announce for lightweight rebroadcast buffer (exclude self)
|
||||
// (recentAnnounces has been removed in the refactor)
|
||||
|
||||
// Notify UI on main thread
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -3836,16 +4087,13 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
}
|
||||
// If still not accepted and this is a sync-returned packet (TTL==0),
|
||||
// accept with a generic nickname so history can be restored even for
|
||||
// peers we haven't verified yet.
|
||||
if !accepted && packet.ttl == 0 {
|
||||
accepted = true
|
||||
senderNickname = "anon" + String(peerID.id.prefix(4))
|
||||
}
|
||||
}
|
||||
|
||||
// Track broadcast messages for sync (treat nil or 0xFF..0xFF as broadcast)
|
||||
guard accepted else {
|
||||
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
let isBroadcastRecipient: Bool = {
|
||||
guard let r = packet.recipientID else { return true }
|
||||
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||
@@ -3854,11 +4102,6 @@ extension BLEService {
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
}
|
||||
|
||||
guard accepted else {
|
||||
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
guard let content = String(data: packet.payload, encoding: .utf8) else {
|
||||
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
|
||||
return
|
||||
@@ -3979,7 +4222,11 @@ extension BLEService {
|
||||
initiateNoiseHandshake(with: peerID)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error)")
|
||||
// Decryption failed - clear the corrupted session and re-initiate handshake
|
||||
// This handles cases where session state got out of sync (nonce mismatch, etc.)
|
||||
SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error) - clearing session and re-initiating handshake")
|
||||
noiseService.clearSession(for: peerID)
|
||||
initiateNoiseHandshake(with: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4120,7 +4367,12 @@ extension BLEService {
|
||||
if maintenanceCounter % 2 == 1 {
|
||||
flushDirectedSpool()
|
||||
}
|
||||
|
||||
|
||||
// Periodically attempt to drain pending notifications and writes as backup
|
||||
// in case callbacks are missed or delayed (every maintenance cycle = 5 seconds)
|
||||
drainPendingNotificationsIfPossible()
|
||||
drainAllPendingWrites()
|
||||
|
||||
// No rotating alias: nothing to refresh
|
||||
|
||||
// Reset counter to prevent overflow (every 60 seconds)
|
||||
|
||||
@@ -59,22 +59,11 @@ final class CommandProcessor {
|
||||
weak var meshService: Transport?
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
|
||||
/// Backward-compatible property for existing code
|
||||
weak var chatViewModel: CommandContextProvider? {
|
||||
get { contextProvider }
|
||||
set { contextProvider = newValue }
|
||||
}
|
||||
|
||||
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.contextProvider = contextProvider
|
||||
self.meshService = meshService
|
||||
self.identityManager = identityManager
|
||||
}
|
||||
|
||||
/// Backward-compatible initializer
|
||||
convenience init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.init(contextProvider: chatViewModel, meshService: meshService, identityManager: identityManager)
|
||||
}
|
||||
|
||||
/// Process a command string
|
||||
@MainActor
|
||||
|
||||
@@ -31,9 +31,6 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
static let shared = FavoritesPersistenceService()
|
||||
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||
|
||||
@@ -11,6 +11,10 @@ final class MeshTopologyTracker {
|
||||
// Last time we received an update from a node
|
||||
private var lastSeen: [RoutingID: Date] = [:]
|
||||
|
||||
// Maximum age for topology claims to be considered fresh for routing
|
||||
// Routes computed using stale topology can fail when the network has changed
|
||||
private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds
|
||||
|
||||
func reset() {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.claims.removeAll()
|
||||
@@ -55,25 +59,33 @@ final class MeshTopologyTracker {
|
||||
if source == target { return [] } // Direct connection, no intermediate hops
|
||||
|
||||
return queue.sync {
|
||||
let now = Date()
|
||||
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
|
||||
|
||||
// BFS
|
||||
var visited: Set<RoutingID> = [source]
|
||||
// Queue stores paths: [Start, Hop1, Hop2, ..., Current]
|
||||
var queuePaths: [[RoutingID]] = [[source]]
|
||||
|
||||
|
||||
while !queuePaths.isEmpty {
|
||||
let path = queuePaths.removeFirst()
|
||||
// Limit path length (path contains source + maxHops + target) -> maxHops intermediate
|
||||
// If maxHops = 10, max edges = 11, max nodes = 12.
|
||||
if path.count > maxHops + 1 { continue }
|
||||
|
||||
|
||||
guard let last = path.last else { continue }
|
||||
|
||||
|
||||
// Get neighbors that 'last' claims to see
|
||||
guard let neighbors = claims[last] else { continue }
|
||||
|
||||
// Check if 'last' node's topology info is fresh
|
||||
guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {
|
||||
continue // Skip stale nodes
|
||||
}
|
||||
|
||||
for neighbor in neighbors {
|
||||
if visited.contains(neighbor) { continue }
|
||||
|
||||
|
||||
// CONFIRMED EDGE CHECK:
|
||||
// 'last' claims 'neighbor' (checked above)
|
||||
// Does 'neighbor' claim 'last'?
|
||||
@@ -81,16 +93,21 @@ final class MeshTopologyTracker {
|
||||
neighborClaims.contains(last) else {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// Check if 'neighbor' node's topology info is fresh
|
||||
guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {
|
||||
continue // Skip edges to stale nodes
|
||||
}
|
||||
|
||||
var nextPath = path
|
||||
nextPath.append(neighbor)
|
||||
|
||||
|
||||
if neighbor == target {
|
||||
// Return only intermediate hops
|
||||
// Path: [Source, I1, I2, Target] -> [I1, I2]
|
||||
return Array(nextPath.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
|
||||
visited.insert(neighbor)
|
||||
queuePaths.append(nextPath)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,20 @@ import Foundation
|
||||
@MainActor
|
||||
final class MessageRouter {
|
||||
private let transports: [Transport]
|
||||
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
|
||||
// Outbox entry with timestamp for TTL-based eviction
|
||||
private struct QueuedMessage {
|
||||
let content: String
|
||||
let nickname: String
|
||||
let messageID: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
|
||||
// Outbox limits to prevent unbounded memory growth
|
||||
private static let maxMessagesPerPeer = 100
|
||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
|
||||
init(transports: [Transport]) {
|
||||
self.transports = transports
|
||||
@@ -34,52 +47,60 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transport Selection
|
||||
|
||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
||||
transports.first { $0.isPeerReachable(peerID) }
|
||||
}
|
||||
|
||||
private func connectedTransport(for peerID: PeerID) -> Transport? {
|
||||
transports.first { $0.isPeerConnected(peerID) }
|
||||
}
|
||||
|
||||
// MARK: - Message Sending
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
// Try to find a reachable transport
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
// Queue for later
|
||||
// Queue for later with timestamp for TTL tracking
|
||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))…", category: .session)
|
||||
|
||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
|
||||
outbox[peerID]?.append(message)
|
||||
|
||||
// Enforce per-peer size limit with FIFO eviction
|
||||
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
|
||||
let evicted = outbox[peerID]?.removeFirst()
|
||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")…", category: .session)
|
||||
}
|
||||
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
transport.sendReadReceipt(receipt, to: peerID)
|
||||
} else if !transports.isEmpty {
|
||||
// Fallback to last transport (usually Nostr) if neither is explicitly reachable?
|
||||
// Or better: just try the first one that supports it?
|
||||
// Existing logic preferred mesh, then nostr.
|
||||
// If neither reachable, existing logic queued it (via mesh usually) or sent via nostr.
|
||||
// Let's stick to "try reachable". If none, maybe pick the first one to queue?
|
||||
// Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport.
|
||||
// But let's stick to the reachable check.
|
||||
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) {
|
||||
if let transport = connectedTransport(for: peerID) {
|
||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
} else if let transport = reachableTransport(for: peerID) {
|
||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
} else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
} else {
|
||||
// Fallback: try all? or just the last one?
|
||||
// Old logic: if mesh connected, mesh. Else nostr.
|
||||
// Note: NostrTransport.isPeerReachable now returns true if mapped.
|
||||
// If not mapped, we can't send via Nostr anyway.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,17 +109,25 @@ final class MessageRouter {
|
||||
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)
|
||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||
|
||||
for (content, nickname, messageID) in queued {
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
|
||||
let now = Date()
|
||||
var remaining: [QueuedMessage] = []
|
||||
|
||||
for message in queued {
|
||||
// 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)
|
||||
continue
|
||||
}
|
||||
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
} else {
|
||||
remaining.append((content, nickname, messageID))
|
||||
remaining.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if remaining.isEmpty {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
} else {
|
||||
@@ -109,4 +138,15 @@ final class MessageRouter {
|
||||
func flushAllOutbox() {
|
||||
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
||||
}
|
||||
|
||||
/// Periodically clean up expired messages from all outboxes
|
||||
func cleanupExpiredMessages() {
|
||||
let now = Date()
|
||||
for peerID in Array(outbox.keys) {
|
||||
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
|
||||
if outbox[peerID]?.isEmpty == true {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +616,17 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
rateLimiter.resetAll()
|
||||
}
|
||||
|
||||
/// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)
|
||||
func clearSession(for peerID: PeerID) {
|
||||
sessionManager.removeSession(for: peerID)
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {
|
||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
||||
}
|
||||
}
|
||||
SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
|
||||
@@ -118,32 +118,15 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
// Convert recipient npub -> hex (x-only)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else {
|
||||
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
|
||||
return
|
||||
}
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,58 +141,31 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode recipient npub for favorite notification: \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode recipient npub for delivery ack: \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,21 +177,17 @@ extension NostrTransport {
|
||||
// MARK: Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,19 +195,12 @@ extension NostrTransport {
|
||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,6 +208,32 @@ extension NostrTransport {
|
||||
// MARK: - Private Helpers
|
||||
|
||||
extension NostrTransport {
|
||||
/// Converts npub bech32 string to hex pubkey
|
||||
@MainActor
|
||||
private func npubToHex(_ npub: String) -> String? {
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(npub)
|
||||
guard hrp == "npub" else { return nil }
|
||||
return data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates and sends a gift-wrapped private message event
|
||||
@MainActor
|
||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
||||
return
|
||||
}
|
||||
if registerPending {
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
}
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
|
||||
/// Must be called within a barrier on `queue`
|
||||
private func processReadQueueIfNeeded() {
|
||||
guard !isSendingReadAcks else { return }
|
||||
@@ -275,31 +246,16 @@ extension NostrTransport {
|
||||
/// Sends a single read ack item (called after extraction from queue within barrier)
|
||||
private func sendReadAckItem(_ item: QueuedRead) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode recipient npub for read ack: \(error.localizedDescription)", category: .session)
|
||||
scheduleNextReadAck()
|
||||
return
|
||||
}
|
||||
defer { scheduleNextReadAck() }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
scheduleNextReadAck(); return
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
|
||||
scheduleNextReadAck(); return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
scheduleNextReadAck()
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,11 +96,12 @@ enum TransportConfig {
|
||||
// Keep scanning fully ON when we saw traffic very recently
|
||||
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
||||
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
||||
static let bleExpectedWritePerFragmentMs: Int = 8
|
||||
static let bleExpectedWriteMaxMs: Int = 2000
|
||||
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
||||
static let bleFragmentSpacingMs: Int = 5
|
||||
static let bleFragmentSpacingDirectedMs: Int = 4
|
||||
static let bleExpectedWritePerFragmentMs: Int = 20
|
||||
static let bleExpectedWriteMaxMs: Int = 5000
|
||||
// Fragment pacing: Conservative spacing to prevent BLE buffer overflow
|
||||
// Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery
|
||||
static let bleFragmentSpacingMs: Int = 30
|
||||
static let bleFragmentSpacingDirectedMs: Int = 25
|
||||
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
||||
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
||||
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
||||
@@ -211,4 +212,18 @@ enum TransportConfig {
|
||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||
|
||||
// Gossip Sync Configuration
|
||||
static let syncSeenCapacity: Int = 1000
|
||||
static let syncGCSMaxBytes: Int = 400
|
||||
static let syncGCSTargetFpr: Double = 0.01
|
||||
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
||||
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
static let syncFragmentCapacity: Int = 600
|
||||
static let syncFileTransferCapacity: Int = 200
|
||||
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
|
||||
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncMessageIntervalSeconds: TimeInterval = 15.0
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
// Gossip-based sync manager using on-demand GCS filters
|
||||
final class GossipSyncManager {
|
||||
@@ -6,6 +7,7 @@ final class GossipSyncManager {
|
||||
func sendPacket(_ packet: BitchatPacket)
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||
func getConnectedPeers() -> [PeerID]
|
||||
}
|
||||
|
||||
private struct PacketStore {
|
||||
@@ -74,6 +76,7 @@ final class GossipSyncManager {
|
||||
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
private let requestSyncManager: RequestSyncManager
|
||||
weak var delegate: Delegate?
|
||||
|
||||
// Storage: broadcast packets by type, and latest announce per sender
|
||||
@@ -88,9 +91,10 @@ final class GossipSyncManager {
|
||||
private var lastStalePeerCleanup: Date = .distantPast
|
||||
private var syncSchedules: [SyncSchedule] = []
|
||||
|
||||
init(myPeerID: PeerID, config: Config = Config()) {
|
||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
self.requestSyncManager = requestSyncManager
|
||||
var schedules: [SyncSchedule] = []
|
||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
@@ -202,6 +206,19 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
||||
// Unicast sync to connected peers to allow RSR attribution
|
||||
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
||||
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
|
||||
for peerID in connectedPeers {
|
||||
sendRequestSync(to: peerID, types: types)
|
||||
}
|
||||
} else {
|
||||
// Fallback to broadcast (discovery phase)
|
||||
sendRequestSync(for: types)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendRequestSync(for types: SyncTypeFlags) {
|
||||
let payload = buildGcsPayload(for: types)
|
||||
let pkt = BitchatPacket(
|
||||
@@ -218,6 +235,9 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||
// Register the request for RSR validation
|
||||
requestSyncManager.registerRequest(to: peerID)
|
||||
|
||||
let payload = buildGcsPayload(for: types)
|
||||
var recipient = Data()
|
||||
var temp = peerID.id
|
||||
@@ -262,6 +282,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -274,6 +295,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -286,6 +308,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -298,6 +321,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -366,11 +390,13 @@ final class GossipSyncManager {
|
||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||
cleanupExpiredMessages()
|
||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
||||
|
||||
for index in syncSchedules.indices {
|
||||
guard syncSchedules[index].interval > 0 else { continue }
|
||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||
syncSchedules[index].lastSent = now
|
||||
sendRequestSync(for: syncSchedules[index].types)
|
||||
sendPeriodicSync(for: syncSchedules[index].types)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// RequestSyncManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// Manages outgoing sync requests and validates incoming responses.
|
||||
///
|
||||
/// Allows attributing RSR (Request-Sync Response) packets to specific peers
|
||||
/// that we have actively requested sync from.
|
||||
final class RequestSyncManager {
|
||||
|
||||
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
||||
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
||||
|
||||
// Allow responses for 30s after request
|
||||
private let responseWindow: TimeInterval = 30.0
|
||||
|
||||
/// Register that we are sending a sync request to a peer.
|
||||
/// - Parameter peerID: The peer we are requesting sync from
|
||||
func registerRequest(to peerID: PeerID) {
|
||||
let now = Date().timeIntervalSince1970
|
||||
queue.async(flags: .barrier) {
|
||||
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
||||
self.pendingRequests[peerID] = now
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a packet from a peer is a valid response to a sync request.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - peerID: The sender of the packet
|
||||
/// - isRSR: Whether the packet is marked as a Request-Sync Response
|
||||
/// - Returns: true if we have a pending request for this peer and the window is open
|
||||
func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool {
|
||||
guard isRSR else { return false }
|
||||
|
||||
return queue.sync {
|
||||
guard let requestTime = pendingRequests[peerID] else {
|
||||
SecureLogger.warning("Received unsolicited RSR packet from \(peerID.id.prefix(8))…", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
if now - requestTime > responseWindow {
|
||||
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
|
||||
// We don't remove here because we might receive multiple packets for one request
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic cleanup of expired requests
|
||||
func cleanup() {
|
||||
let now = Date().timeIntervalSince1970
|
||||
queue.async(flags: .barrier) {
|
||||
let originalCount = self.pendingRequests.count
|
||||
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
||||
now - timestamp <= self.responseWindow
|
||||
}
|
||||
let removed = originalCount - self.pendingRequests.count
|
||||
if removed > 0 {
|
||||
SecureLogger.debug("Cleaned up \(removed) expired sync requests", category: .sync)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,8 +279,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
||||
// Show Tor status once per app launch
|
||||
var torStatusAnnounced = false
|
||||
private var torProgressCancellable: AnyCancellable?
|
||||
private var lastTorProgressAnnounced = -1
|
||||
// Track whether a Tor restart is pending so we only announce
|
||||
// "tor restarted" after an actual restart, not the first launch.
|
||||
var torRestartPending: Bool = false
|
||||
@@ -323,8 +321,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
)
|
||||
// Channel activity tracking for background nudges
|
||||
var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
|
||||
private var lastPublicActivityNotifyAt: [String: Date] = [:]
|
||||
private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
|
||||
// Geohash participant tracker
|
||||
let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||
// Participants who indicated they teleported (by tag in their events)
|
||||
@@ -448,7 +444,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
self.deduplicationService = MessageDeduplicationService()
|
||||
|
||||
// Wire up dependencies
|
||||
self.commandProcessor.chatViewModel = self
|
||||
self.commandProcessor.contextProvider = self
|
||||
self.participantTracker.configure(context: self)
|
||||
|
||||
// Subscribe to privateChatManager changes to trigger UI updates
|
||||
@@ -503,8 +499,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
)
|
||||
// Suppress incremental Tor progress messages
|
||||
torProgressCancellable = nil
|
||||
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
||||
torStatusAnnounced = true
|
||||
addGeohashOnlySystemMessage(
|
||||
@@ -631,8 +625,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Listen for delivery acknowledgments
|
||||
|
||||
// When app becomes active, send read receipts for visible messages
|
||||
#if os(macOS)
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -1468,56 +1460,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
selectedPrivateChatFingerprint = nil
|
||||
}
|
||||
|
||||
// MARK: - Nostr Message Handling
|
||||
|
||||
@MainActor
|
||||
@objc private func handleNostrMessage(_ notification: Notification) {
|
||||
guard let message = notification.userInfo?["message"] as? BitchatMessage else { return }
|
||||
|
||||
// Store the Nostr pubkey if provided (for messages from unknown senders)
|
||||
if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String,
|
||||
let senderPeerID = message.senderPeerID {
|
||||
// Store mapping for read receipts
|
||||
nostrKeyMapping[senderPeerID] = nostrPubkey
|
||||
}
|
||||
|
||||
// Process the Nostr message through the same flow as Bluetooth messages
|
||||
didReceiveMessage(message)
|
||||
}
|
||||
|
||||
@objc private func handleDeliveryAcknowledgment(_ notification: Notification) {
|
||||
guard let messageId = notification.userInfo?["messageId"] as? String else { return }
|
||||
|
||||
|
||||
|
||||
// Update the delivery status for the message
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
// Update delivery status to delivered
|
||||
messages[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
|
||||
|
||||
// Schedule UI update for delivery status
|
||||
// UI will update automatically
|
||||
}
|
||||
|
||||
// Also update in private chats if it's a private message
|
||||
for (peerID, chatMessages) in privateChats {
|
||||
if let index = chatMessages.firstIndex(where: { $0.id == messageId }) {
|
||||
privateChats[peerID]?[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
|
||||
// UI will update automatically
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleNostrReadReceipt(_ notification: Notification) {
|
||||
guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return }
|
||||
|
||||
SecureLogger.info("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", category: .session)
|
||||
|
||||
// Process the read receipt through the same flow as Bluetooth read receipts
|
||||
didReceiveReadReceipt(receipt)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc private func handlePeerStatusUpdate(_ notification: Notification) {
|
||||
// Update private chat peer if needed when peer status changes
|
||||
|
||||
@@ -15,10 +15,22 @@ struct PaymentChipView: View {
|
||||
enum PaymentType {
|
||||
case cashu(String)
|
||||
case lightning(String)
|
||||
|
||||
|
||||
private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
|
||||
private static func cashuURL(from link: String) -> URL? {
|
||||
if let url = URL(string: link), url.scheme != nil {
|
||||
return url
|
||||
}
|
||||
let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link
|
||||
return URL(string: "cashu:\(enc)")
|
||||
}
|
||||
|
||||
var url: URL? {
|
||||
switch self {
|
||||
case .cashu(let link), .lightning(let link):
|
||||
case .cashu(let link):
|
||||
return Self.cashuURL(from: link)
|
||||
case .lightning(let link):
|
||||
return URL(string: link)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ struct ContentView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@State private var showPeerList = false
|
||||
@State private var showSidebar = false
|
||||
@State private var showAppInfo = false
|
||||
@State private var showMessageActions = false
|
||||
@@ -57,7 +56,6 @@ struct ContentView: View {
|
||||
@State private var expandedMessageIDs: Set<String> = []
|
||||
@State private var showLocationNotes = false
|
||||
@State private var notesGeohash: String? = nil
|
||||
@State private var sheetNotesCount: Int = 0
|
||||
@State private var imagePreviewURL: URL? = nil
|
||||
@State private var recordingAlertMessage: String = ""
|
||||
@State private var showRecordingAlert = false
|
||||
@@ -1181,19 +1179,6 @@ struct ContentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Split a name into base and a '#abcd' suffix if present
|
||||
private func splitNameSuffix(_ name: String) -> (base: String, suffix: String) {
|
||||
guard name.count >= 5 else { return (name, "") }
|
||||
let suffix = String(name.suffix(5))
|
||||
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
||||
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
||||
}) {
|
||||
let base = String(name.dropLast(5))
|
||||
return (base, suffix)
|
||||
}
|
||||
return (name, "")
|
||||
}
|
||||
|
||||
// Compute channel-aware people count and color for toolbar (cross-platform)
|
||||
private func channelPeopleCountAndColor() -> (Int, Color) {
|
||||
switch locationManager.selectedChannel {
|
||||
@@ -1308,8 +1293,8 @@ struct ContentView: View {
|
||||
|
||||
// Bookmark toggle (geochats): to the left of #geohash
|
||||
if case .location(let ch) = locationManager.selectedChannel {
|
||||
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
||||
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||
Button(action: { bookmarks.toggle(ch.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -1567,7 +1552,7 @@ private extension ContentView {
|
||||
} else if let media = mediaAttachment(for: message) {
|
||||
mediaMessageRow(message: message, media: media)
|
||||
} else {
|
||||
textMessageRow(message)
|
||||
TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1631,56 +1616,6 @@ private extension ContentView {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func textMessageRow(_ message: BitchatMessage) -> some View {
|
||||
let cashuTokens = message.content.extractCashuLinks()
|
||||
let lightningLinks = message.content.extractLightningLinks()
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if isLong && cashuTokens.isEmpty {
|
||||
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||
Button(labelKey) {
|
||||
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||
else { expandedMessageIDs.insert(message.id) }
|
||||
}
|
||||
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(lightningLinks.prefix(3)), id: \.self) { link in
|
||||
PaymentChipView(paymentType: .lightning(link))
|
||||
}
|
||||
|
||||
ForEach(Array(cashuTokens.prefix(3)), id: \.self) { token in
|
||||
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
||||
let urlStr = "cashu:\(enc)"
|
||||
PaymentChipView(paymentType: .cashu(urlStr))
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func expandWindow(ifNeededFor message: BitchatMessage,
|
||||
allMessages: [BitchatMessage],
|
||||
privatePeer: PeerID?,
|
||||
|
||||
@@ -5,21 +5,6 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
private enum RegexCache {
|
||||
static let cashu: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||
}()
|
||||
static let lightningScheme: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||
}()
|
||||
static let bolt11: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||
}()
|
||||
static let lnurl: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||
}()
|
||||
}
|
||||
|
||||
extension String {
|
||||
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
||||
func hasVeryLongToken(threshold: Int) -> Bool {
|
||||
@@ -38,7 +23,7 @@ extension String {
|
||||
|
||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||
let regex = RegexCache.cashu
|
||||
let regex = MessageFormattingEngine.Patterns.cashu
|
||||
let ns = self as NSString
|
||||
let range = NSRange(location: 0, length: ns.length)
|
||||
var found: [String] = []
|
||||
@@ -59,19 +44,19 @@ extension String {
|
||||
let ns = self as NSString
|
||||
let full = NSRange(location: 0, length: ns.length)
|
||||
// lightning: scheme
|
||||
for m in RegexCache.lightningScheme.matches(in: self, range: full) {
|
||||
for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append(s)
|
||||
if results.count >= max { return results }
|
||||
}
|
||||
// BOLT11
|
||||
for m in RegexCache.bolt11.matches(in: self, range: full) {
|
||||
for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append("lightning:\(s)")
|
||||
if results.count >= max { return results }
|
||||
}
|
||||
// LNURL bech32
|
||||
for m in RegexCache.lnurl.matches(in: self, range: full) {
|
||||
for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append("lightning:\(s)")
|
||||
if results.count >= max { return results }
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// BLEServiceCoreTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Focused BLEService tests for packet handling behavior.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEServiceCoreTests {
|
||||
|
||||
@Test
|
||||
func duplicatePacket_isDeduped() async {
|
||||
let ble = makeService()
|
||||
let delegate = PublicCaptureDelegate()
|
||||
ble.delegate = delegate
|
||||
|
||||
let sender = PeerID(str: "1122334455667788")
|
||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
|
||||
_ = await TestHelpers.waitUntil({ delegate.publicMessagesSnapshot().count == 1 },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
|
||||
let messages = delegate.publicMessagesSnapshot()
|
||||
#expect(messages.count == 1)
|
||||
#expect(messages.first?.content == "Hello")
|
||||
}
|
||||
|
||||
@Test
|
||||
func staleBroadcast_isIgnored() async {
|
||||
let ble = makeService()
|
||||
let delegate = PublicCaptureDelegate()
|
||||
ble.delegate = delegate
|
||||
|
||||
let sender = PeerID(str: "A1B2C3D4E5F60708")
|
||||
let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)
|
||||
let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)
|
||||
#expect(!didReceive)
|
||||
#expect(delegate.publicMessagesSnapshot().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
let keychain = MockKeychain()
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||
}
|
||||
|
||||
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data(content.utf8),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
}
|
||||
|
||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
||||
private let lock = NSLock()
|
||||
private(set) var publicMessages: [BitchatMessage] = []
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: nil
|
||||
)
|
||||
lock.lock()
|
||||
publicMessages.append(message)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
|
||||
func publicMessagesSnapshot() -> [BitchatMessage] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return publicMessages
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ struct BLEServiceTests {
|
||||
service.sendMessage("Hello, world!")
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
#expect(service.sentMessages.count == 1)
|
||||
}
|
||||
@@ -97,7 +97,7 @@ struct BLEServiceTests {
|
||||
)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
#expect(service.sentMessages.count == 1)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ struct BLEServiceTests {
|
||||
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ struct BLEServiceTests {
|
||||
service.simulateIncomingMessage(incomingMessage)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ struct BLEServiceTests {
|
||||
service.simulateIncomingPacket(packet)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ struct BLEServiceTests {
|
||||
service.sendMessage("Test delivery")
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ struct BLEServiceTests {
|
||||
service.simulateIncomingPacket(packet)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//
|
||||
// ChatViewModelDeliveryStatusTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for ChatViewModel delivery status state machine.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
}
|
||||
|
||||
// MARK: - Delivery Status Tests
|
||||
|
||||
struct ChatViewModelDeliveryStatusTests {
|
||||
|
||||
// MARK: - Status Transition Tests
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_noDowngrade_readToDelivered() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-1"
|
||||
|
||||
// Setup: create a message with .read status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .read(by: "Peer", at: Date())
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: try to downgrade to .delivered
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should remain .read (no downgrade)
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_upgrade_sentToDelivered() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-2"
|
||||
|
||||
// Setup: create a message with .sent status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: upgrade to .delivered
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should be .delivered
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .delivered = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_upgrade_deliveredToRead() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-3"
|
||||
|
||||
// Setup: create a message with .delivered status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: upgrade to .read
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should be .read
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Read Receipt Handling
|
||||
|
||||
@Test @MainActor
|
||||
func didReceiveReadReceipt_updatesMessageStatus() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-4"
|
||||
|
||||
// Setup: create a message with .sent status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: receive read receipt
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: messageID,
|
||||
readerID: peerID,
|
||||
readerNickname: "Peer"
|
||||
)
|
||||
viewModel.didReceiveReadReceipt(receipt)
|
||||
|
||||
// Assert: status should be .read
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Public Timeline Status Tests
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_publicTimeline_updatesCorrectly() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let messageID = "public-msg-1"
|
||||
|
||||
// Setup: add a message to public timeline with .sending status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Public message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: false,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
viewModel.messages.append(message)
|
||||
|
||||
// Action: update to .sent
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
|
||||
// Assert
|
||||
let updatedMessage = viewModel.messages.first(where: { $0.id == messageID })
|
||||
#expect({
|
||||
if case .sent = updatedMessage?.deliveryStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Status Rank Tests (for deduplication)
|
||||
|
||||
@Test @MainActor
|
||||
func statusRank_orderingIsCorrect() async {
|
||||
// This tests the implicit ordering used in refreshVisibleMessages
|
||||
// failed < sending < sent < partiallyDelivered < delivered < read
|
||||
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.failed(reason: "test"),
|
||||
.sending,
|
||||
.sent,
|
||||
.partiallyDelivered(reached: 1, total: 3),
|
||||
.delivered(to: "B", at: Date()),
|
||||
.read(by: "C", at: Date())
|
||||
]
|
||||
|
||||
// Verify each status has a logical progression
|
||||
// This is more of a documentation test to ensure the ranking logic is understood
|
||||
for (index, status) in statuses.enumerated() {
|
||||
switch status {
|
||||
case .failed: #expect(index == 0)
|
||||
case .sending: #expect(index == 1)
|
||||
case .sent: #expect(index == 2)
|
||||
case .partiallyDelivered: #expect(index == 3)
|
||||
case .delivered: #expect(index == 4)
|
||||
case .read: #expect(index == 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,22 @@ struct ChatViewModelPrivateChatExtensionTests {
|
||||
// MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers
|
||||
// Check MockTransport implementation... it might need update or verification
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_unreachable_setsFailedStatus() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
|
||||
let peerID = PeerID(str: validHex)
|
||||
|
||||
viewModel.sendPrivateMessage("Hello", to: peerID)
|
||||
|
||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
||||
let status = viewModel.privateChats[peerID]?.last?.deliveryStatus
|
||||
#expect({
|
||||
if case .failed = status { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_storesMessage() async {
|
||||
@@ -250,10 +266,17 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
|
||||
LocationChannelManager.shared.select(channel)
|
||||
defer { LocationChannelManager.shared.select(.mesh) }
|
||||
|
||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
|
||||
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: "pub1",
|
||||
@@ -267,19 +290,119 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
// Allow async processing
|
||||
let didAppend = await TestHelpers.waitUntil({
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
return viewModel.messages.contains { $0.content == "Hello Geo" }
|
||||
})
|
||||
#expect(didAppend)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_ignoresRecentSelfEcho() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Self echo"
|
||||
)
|
||||
event.id = "evt-self"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Check timeline
|
||||
// This depends on `handlePublicMessage` being called and updating `messages`
|
||||
// Since `handlePublicMessage` delegates to `timelineStore` and updates `messages`...
|
||||
// And we are in the correct channel...
|
||||
|
||||
// However, `handleNostrEvent` in the extension now calls `handlePublicMessage`.
|
||||
// Let's verify if the message appears.
|
||||
// Note: `handleNostrEvent` logic was refactored.
|
||||
// The new logic in `ChatViewModel+Nostr.swift` calls `handlePublicMessage`.
|
||||
|
||||
// We need to ensure `deduplicationService` doesn't block it (new instance, so empty).
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
#expect(!viewModel.messages.contains { $0.content == "Self echo" })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_skipsBlockedSender() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: blockedPubkey,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Blocked"
|
||||
)
|
||||
event.id = "evt-blocked"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func switchLocationChannel_clearsNostrDedupCache() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.deduplicationService.recordNostrEvent("evt-cache")
|
||||
#expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash Queue Tests
|
||||
|
||||
struct ChatViewModelGeohashQueueTests {
|
||||
|
||||
@Test @MainActor
|
||||
func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.addGeohashOnlySystemMessage("Queued system")
|
||||
#expect(!viewModel.messages.contains { $0.content == "Queued system" })
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
#expect(viewModel.messages.contains { $0.content == "Queued system" })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GeoDM Tests
|
||||
|
||||
struct ChatViewModelGeoDMTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
let messageID = "pm-1"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
|
||||
let payloadData = try #require(packet.encode(), "Failed to encode private message")
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
||||
|
||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// ChatViewModelRefactoringTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Pinning tests to characterize ChatViewModel behavior before refactoring.
|
||||
// These tests act as a safety net to ensure we don't break existing functionality.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct ChatViewModelRefactoringTests {
|
||||
|
||||
// Helper to setup the environment
|
||||
@MainActor
|
||||
private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: MockIdentityManager) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport, identityManager)
|
||||
}
|
||||
|
||||
// MARK: - Command Processor Integration "Pinning"
|
||||
|
||||
@Test @MainActor
|
||||
func command_msg_routesToTransport() async throws {
|
||||
let (viewModel, transport, _) = makePinnedViewModel()
|
||||
|
||||
// Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified
|
||||
let peerID = PeerID(str: "0000000000000001")
|
||||
transport.simulateConnect(peerID, nickname: "alice")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action: User types /msg command
|
||||
viewModel.sendMessage("/msg @alice Hello Private World")
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didSend)
|
||||
|
||||
// Assert:
|
||||
// 1. Should NOT go to public transport
|
||||
#expect(transport.sentMessages.isEmpty, "Command should not be sent as public message")
|
||||
|
||||
// 2. Should go to private transport logic
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
#expect(transport.sentPrivateMessages.first?.content == "Hello Private World")
|
||||
#expect(transport.sentPrivateMessages.first?.peerID == peerID)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func command_block_updatesIdentity() async throws {
|
||||
let (viewModel, transport, identity) = makePinnedViewModel()
|
||||
|
||||
// Setup: Use simulateConnect
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
// Mock the fingerprint so the block command finds it
|
||||
transport.peerFingerprints[peerID] = "fingerprint_123"
|
||||
transport.simulateConnect(peerID, nickname: "troll")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action
|
||||
viewModel.sendMessage("/block @troll")
|
||||
|
||||
// Assert
|
||||
// Verify identity manager was called to block "fingerprint_123"
|
||||
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didBlock)
|
||||
}
|
||||
|
||||
// MARK: - Message Routing Logic
|
||||
|
||||
@Test @MainActor
|
||||
func routing_incomingPrivateMessage_addsToPrivateChats() async {
|
||||
let (viewModel, _, _) = makePinnedViewModel()
|
||||
let senderID = PeerID(str: "sender_1")
|
||||
|
||||
// Setup
|
||||
let message = BitchatMessage(
|
||||
id: "msg_1",
|
||||
sender: "bob",
|
||||
content: "Secret",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "me",
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
// Action: Simulate incoming private message
|
||||
viewModel.didReceiveMessage(message)
|
||||
|
||||
// Wait for async processing with proper timeout
|
||||
let found = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(found)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func routing_incomingPublicMessage_addsToPublicTimeline() async {
|
||||
let (viewModel, _, _) = makePinnedViewModel()
|
||||
let senderID = PeerID(str: "sender_2")
|
||||
|
||||
// Setup
|
||||
let message = BitchatMessage(
|
||||
id: "msg_2",
|
||||
sender: "charlie",
|
||||
content: "Public Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
// Action
|
||||
viewModel.didReceiveMessage(message)
|
||||
|
||||
// Wait for async processing with proper timeout
|
||||
let found = await TestHelpers.waitUntil(
|
||||
{ viewModel.messages.contains(where: { $0.content == "Public Hi" }) },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(found)
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,44 @@ struct ChatViewModelSendingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Handling Tests
|
||||
|
||||
struct ChatViewModelCommandTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_commandsNotSentToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let commands = ["/nick bob", "/who", "/help", "/clear"]
|
||||
|
||||
for command in commands {
|
||||
transport.resetRecordings()
|
||||
viewModel.sendMessage(command)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(transport.sentMessages.isEmpty)
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Cap Tests
|
||||
|
||||
struct ChatViewModelTimelineCapTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_trimsTimelineToCap() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let total = TransportConfig.meshTimelineCap + 5
|
||||
|
||||
for i in 0..<total {
|
||||
viewModel.sendMessage("cap-msg-\(i)")
|
||||
}
|
||||
|
||||
#expect(viewModel.messages.count == TransportConfig.meshTimelineCap)
|
||||
#expect(viewModel.messages.last?.content == "cap-msg-\(total - 1)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Receiving Tests
|
||||
|
||||
struct ChatViewModelReceivingTests {
|
||||
@@ -164,6 +202,40 @@ struct ChatViewModelReceivingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiting Tests
|
||||
|
||||
struct ChatViewModelRateLimitingTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handlePublicMessage_rateLimitsBurstBySender() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let senderID = PeerID(str: "1122334455667788")
|
||||
let now = Date()
|
||||
|
||||
for i in 0..<6 {
|
||||
let message = BitchatMessage(
|
||||
id: "rate-\(i)",
|
||||
sender: "Spammer",
|
||||
content: "rate-msg-\(i)",
|
||||
timestamp: now,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
)
|
||||
viewModel.handlePublicMessage(message)
|
||||
}
|
||||
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
let burstMessages = viewModel.messages.filter { $0.content.hasPrefix("rate-msg-") }
|
||||
#expect(burstMessages.count == 5)
|
||||
#expect(!burstMessages.contains { $0.content == "rate-msg-5" })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer Connection Tests
|
||||
|
||||
struct ChatViewModelPeerTests {
|
||||
@@ -258,6 +330,94 @@ struct ChatViewModelPrivateChatTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Chat Selection Tests
|
||||
|
||||
struct ChatViewModelPrivateChatSelectionTests {
|
||||
|
||||
@Test @MainActor
|
||||
func openMostRelevantPrivateChat_prefersUnreadMostRecent() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerA = PeerID(str: "PEER_A")
|
||||
let peerB = PeerID(str: "PEER_B")
|
||||
|
||||
let older = Date().addingTimeInterval(-120)
|
||||
let newer = Date().addingTimeInterval(-30)
|
||||
|
||||
viewModel.privateChats = [
|
||||
peerA: [
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
],
|
||||
peerB: [
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
]
|
||||
]
|
||||
viewModel.unreadPrivateMessages = [peerA, peerB]
|
||||
|
||||
viewModel.openMostRelevantPrivateChat()
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func openMostRelevantPrivateChat_fallsBackToMostRecentChat() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerA = PeerID(str: "PEER_A")
|
||||
let peerB = PeerID(str: "PEER_B")
|
||||
|
||||
let older = Date().addingTimeInterval(-200)
|
||||
let newer = Date().addingTimeInterval(-20)
|
||||
|
||||
viewModel.privateChats = [
|
||||
peerA: [
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
],
|
||||
peerB: [
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
viewModel.openMostRelevantPrivateChat()
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bluetooth State Tests
|
||||
|
||||
struct ChatViewModelBluetoothTests {
|
||||
@@ -309,11 +469,37 @@ struct ChatViewModelPanicTests {
|
||||
|
||||
// Set up some state
|
||||
transport.connectedPeers.insert(PeerID(str: "PEER1"))
|
||||
viewModel.messages = [
|
||||
BitchatMessage(
|
||||
id: "panic-1",
|
||||
sender: "Tester",
|
||||
content: "Before",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
]
|
||||
viewModel.privateChats[PeerID(str: "PEER1")] = [
|
||||
BitchatMessage(
|
||||
id: "pm-1",
|
||||
sender: "Peer",
|
||||
content: "Secret",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: PeerID(str: "PEER1")
|
||||
)
|
||||
]
|
||||
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
// After panic, emergency disconnect should be called
|
||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||
#expect(viewModel.messages.isEmpty)
|
||||
#expect(viewModel.privateChats.isEmpty)
|
||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//
|
||||
// ChatViewModelTorTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for ChatViewModel+Tor.swift Tor lifecycle notification handlers.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
}
|
||||
|
||||
// MARK: - Tor Notification Handler Tests
|
||||
|
||||
struct ChatViewModelTorTests {
|
||||
|
||||
// MARK: - handleTorWillStart Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Precondition: flag should start false
|
||||
#expect(!viewModel.torStatusAnnounced)
|
||||
|
||||
// Action: simulate Tor starting notification
|
||||
viewModel.handleTorWillStart()
|
||||
|
||||
// Wait for Task to complete
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: flag should be set (torEnforced is true in tests)
|
||||
#expect(viewModel.torStatusAnnounced)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: pre-set the flag
|
||||
viewModel.torStatusAnnounced = true
|
||||
|
||||
// Switch to a geohash channel so messages would be visible
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Action: call handler again
|
||||
viewModel.handleTorWillStart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: no new message added (flag was already true)
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
// MARK: - handleTorWillRestart Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillRestart_setsPendingFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Precondition
|
||||
#expect(!viewModel.torRestartPending)
|
||||
|
||||
// Action
|
||||
viewModel.handleTorWillRestart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert
|
||||
#expect(viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillRestart_setsFlag_regardlessOfChannel() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Action: call handler (works regardless of channel)
|
||||
viewModel.handleTorWillRestart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: flag should be set
|
||||
#expect(viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
// MARK: - handleTorDidBecomeReady Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: simulate restart pending state
|
||||
viewModel.torRestartPending = true
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: should clear pending flag
|
||||
#expect(!viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: not restarting, but initial ready not announced yet
|
||||
viewModel.torRestartPending = false
|
||||
viewModel.torInitialReadyAnnounced = false
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: should set flag (torEnforced is true in tests)
|
||||
#expect(viewModel.torInitialReadyAnnounced)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: already announced initial ready
|
||||
viewModel.torRestartPending = false
|
||||
viewModel.torInitialReadyAnnounced = true
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: no new message
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
// MARK: - handleTorPreferenceChanged Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorPreferenceChanged_resetsAllFlags() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: set all flags
|
||||
viewModel.torStatusAnnounced = true
|
||||
viewModel.torInitialReadyAnnounced = true
|
||||
viewModel.torRestartPending = true
|
||||
|
||||
// Action
|
||||
viewModel.handleTorPreferenceChanged(Notification(name: .init("test")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: all flags reset
|
||||
#expect(!viewModel.torStatusAnnounced)
|
||||
#expect(!viewModel.torInitialReadyAnnounced)
|
||||
#expect(!viewModel.torRestartPending)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ struct CommandProcessorTests {
|
||||
|
||||
@MainActor
|
||||
@Test func slapNotFoundGrammar() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/slap @system")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
@@ -18,7 +18,7 @@ struct CommandProcessorTests {
|
||||
|
||||
@MainActor
|
||||
@Test func hugNotFoundGrammar() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/hug @system")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
@@ -30,7 +30,7 @@ struct CommandProcessorTests {
|
||||
|
||||
@MainActor
|
||||
@Test func slapUsageMessage() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/slap")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
|
||||
@@ -134,7 +134,7 @@ struct FragmentationTests {
|
||||
}
|
||||
}
|
||||
|
||||
try await sleep(1.0)
|
||||
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2))
|
||||
|
||||
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
||||
#expect(message.content.hasPrefix("[file]"))
|
||||
|
||||
@@ -7,12 +7,14 @@ struct GossipSyncManagerTests {
|
||||
private let myPeerID = PeerID(str: "0102030405060708")
|
||||
|
||||
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
try await confirmation("sync request sent") { sent in
|
||||
delegate.onSend = {
|
||||
delegate.onSend = nil
|
||||
sent()
|
||||
}
|
||||
|
||||
@@ -34,7 +36,7 @@ struct GossipSyncManagerTests {
|
||||
}
|
||||
|
||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||
try await sleep(0.002)
|
||||
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
||||
@@ -47,7 +49,8 @@ struct GossipSyncManagerTests {
|
||||
config.stalePeerCleanupIntervalSeconds = 0
|
||||
config.stalePeerTimeoutSeconds = 5
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let peerHex = "0011223344556677"
|
||||
let senderData = try #require(Data(hexString: peerHex))
|
||||
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
@@ -92,7 +95,8 @@ struct GossipSyncManagerTests {
|
||||
config.stalePeerTimeoutSeconds = 5
|
||||
config.maxMessageAgeSeconds = 100
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let peerHex = "8899aabbccddeeff"
|
||||
let senderData = try #require(Data(hexString: peerHex))
|
||||
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
||||
@@ -136,7 +140,8 @@ struct GossipSyncManagerTests {
|
||||
config.fileTransferSyncIntervalSeconds = 1
|
||||
config.maintenanceIntervalSeconds = 0
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
@@ -206,7 +211,8 @@ struct GossipSyncManagerTests {
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
@@ -240,7 +246,7 @@ struct GossipSyncManagerTests {
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await sleep(0.01)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 1)
|
||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||
@@ -268,4 +274,8 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||
packet
|
||||
}
|
||||
|
||||
func getConnectedPeers() -> [PeerID] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
//import Foundation
|
||||
//import XCTest
|
||||
//@testable import bitchat
|
||||
//
|
||||
//private let localizationTestsDirectoryURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
//private let testsRootURL = localizationTestsDirectoryURL.deletingLastPathComponent()
|
||||
//private let repoRootURL = testsRootURL.deletingLastPathComponent()
|
||||
//
|
||||
//final class LocalizationCatalogTests: XCTestCase {
|
||||
// // Ensures every app locale includes exactly the same keys as Base.
|
||||
// func testAppCatalogLocaleParity() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// assertLocaleParity(context: context, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Verifies format placeholders stay consistent across app locales.
|
||||
// func testAppCatalogPlaceholderConsistency() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// assertPlaceholderConsistency(context: context, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Guards a core set of app strings from going empty per locale.
|
||||
// func testAppPrimaryKeysNonEmpty() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let primaryKeys = try loadPrimaryKeys().app
|
||||
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Ensures every share extension locale matches Base key coverage.
|
||||
// func testShareExtensionCatalogLocaleParity() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// assertLocaleParity(context: context, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Verifies share extension placeholders align across locales.
|
||||
// func testShareExtensionCatalogPlaceholderConsistency() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// assertPlaceholderConsistency(context: context, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Confirms critical share extension strings remain non-empty per locale.
|
||||
// func testShareExtensionPrimaryKeysNonEmpty() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let primaryKeys = try loadPrimaryKeys().shareExtension
|
||||
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Validates that configured locales contain expected string values.
|
||||
// func testLocalizationExpectedValues() throws {
|
||||
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let config = try loadPrimaryKeys()
|
||||
//
|
||||
// guard let testLocales = config.testLocales else {
|
||||
// // If no testLocales specified, skip this test
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// guard let expectedValues = config.expectedValues else {
|
||||
// XCTFail("No expectedValues configured in PrimaryLocalizationKeys.json")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// // Loop through each locale to test
|
||||
// for locale in testLocales {
|
||||
// guard let localeExpectedValues = expectedValues[locale] else {
|
||||
// XCTFail("No expected values configured for locale '\(locale)' in PrimaryLocalizationKeys.json")
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// // Test each expected key/value pair for this locale
|
||||
// for (key, expectedValue) in localeExpectedValues {
|
||||
// if config.app.contains(key) {
|
||||
// assertLocaleStringValue(context: appContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "App")
|
||||
// } else if config.shareExtension.contains(key) {
|
||||
// assertLocaleStringValue(context: shareContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "ShareExtension")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Ensures configured test locales are present and complete.
|
||||
// func testConfiguredLocalesCompleteness() throws {
|
||||
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let config = try loadPrimaryKeys()
|
||||
//
|
||||
// guard let testLocales = config.testLocales else {
|
||||
// // If no testLocales specified, skip this test
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// let baseLocale = appContext.baseLocale
|
||||
// let baseAppKeys = appContext.keysByLocale[baseLocale] ?? Set()
|
||||
// let baseShareKeys = shareContext.keysByLocale[baseLocale] ?? Set()
|
||||
//
|
||||
// for locale in testLocales {
|
||||
// // Skip base locale comparison with itself
|
||||
// if locale == baseLocale { continue }
|
||||
//
|
||||
// // Verify locale is present in both catalogs
|
||||
// XCTAssertTrue(appContext.locales.contains(locale), "Locale '\(locale)' missing from app catalog")
|
||||
// XCTAssertTrue(shareContext.locales.contains(locale), "Locale '\(locale)' missing from share extension catalog")
|
||||
//
|
||||
// // Verify locale has same number of keys as base locale
|
||||
// let appLocaleKeys = appContext.keysByLocale[locale] ?? Set()
|
||||
// XCTAssertEqual(appLocaleKeys.count, baseAppKeys.count, "Locale '\(locale)' app catalog missing keys compared to \(baseLocale)")
|
||||
//
|
||||
// let shareLocaleKeys = shareContext.keysByLocale[locale] ?? Set()
|
||||
// XCTAssertEqual(shareLocaleKeys.count, baseShareKeys.count, "Locale '\(locale)' share extension catalog missing keys compared to \(baseLocale)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // MARK: - Assertions
|
||||
//
|
||||
// private func assertLocaleParity(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// let baseLocale = context.baseLocale
|
||||
// guard let baseKeys = context.keysByLocale[baseLocale] else {
|
||||
// return XCTFail("Missing base locale \(baseLocale) in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
//
|
||||
// for (locale, keys) in context.keysByLocale.sorted(by: { $0.key < $1.key }) {
|
||||
// XCTAssertEqual(keys, baseKeys, "Locale \(locale) has key mismatch in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertPlaceholderConsistency(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// let baseLocale = context.baseLocale
|
||||
// guard let baseSignatures = context.placeholderSignature[baseLocale] else {
|
||||
// return XCTFail("Missing base placeholder signature for \(catalogName)", file: file, line: line)
|
||||
// }
|
||||
//
|
||||
// for (locale, localeSignatures) in context.placeholderSignature.sorted(by: { $0.key < $1.key }) {
|
||||
// guard locale != baseLocale else { continue }
|
||||
// for key in baseSignatures.keys.sorted() {
|
||||
// guard let baseMap = baseSignatures[key] else {
|
||||
// continue
|
||||
// }
|
||||
// guard let localeMap = localeSignatures[key] else {
|
||||
// return XCTFail("Key \(key) missing for locale \(locale) in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// for path in baseMap.keys.sorted() {
|
||||
// let expected = normalizedPlaceholders(baseMap[path, default: []])
|
||||
// let actual = normalizedPlaceholders(localeMap[path, default: []])
|
||||
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// for (localePath, localeTokens) in localeMap {
|
||||
// guard baseMap[localePath] == nil else { continue }
|
||||
// guard let fallback = fallbackPath(for: localePath, baseMap: baseMap) else {
|
||||
// XCTFail("Unexpected variation \(localePath) for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// let expected = normalizedPlaceholders(baseMap[fallback, default: []])
|
||||
// let actual = normalizedPlaceholders(localeTokens)
|
||||
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(localePath) (fallback \(fallback)) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertPrimaryKeysPresent(context: CatalogContext, keys: [String], catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// for key in keys {
|
||||
// guard let entry = context.catalog.strings[key] else {
|
||||
// XCTFail("Missing primary key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// for locale in context.locales.sorted() {
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// XCTFail("Missing localization for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// let segments = gatherSegments(from: unit)
|
||||
// XCTAssertFalse(segments.isEmpty, "No content for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// for segment in segments {
|
||||
// let trimmed = segment.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// XCTAssertFalse(trimmed.isEmpty, "Empty translation for key \(key) at \(segment.path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertLocaleStringValue(context: CatalogContext, locale: String, key: String, expectedValue: String, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// guard let entry = context.catalog.strings[key] else {
|
||||
// XCTFail("Missing key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// XCTFail("Missing \(locale) localization for key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// // For simple strings (non-pluralized)
|
||||
// if let actualValue = unit.value {
|
||||
// XCTAssertEqual(actualValue, expectedValue, "\(locale) translation mismatch for key \(key) in \(catalogName) catalog. Expected: '\(expectedValue)', Actual: '\(actualValue)'", file: file, line: line)
|
||||
// } else {
|
||||
// XCTFail("Key \(key) has no value in \(locale) localization for \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // MARK: - Loading
|
||||
//
|
||||
// private func loadContext(relativePath: String) throws -> CatalogContext {
|
||||
// let catalog = try loadCatalog(relativePath: relativePath)
|
||||
// let locales = catalog.locales
|
||||
// let baseLocale = catalog.sourceLanguage
|
||||
// var keysByLocale: [String: Set<String>] = [:]
|
||||
// var placeholderSignature: [String: [String: [String: [String]]]] = [:]
|
||||
//
|
||||
// for locale in locales {
|
||||
// var localeKeys: Set<String> = []
|
||||
// var localePlaceholders: [String: [String: [String]]] = [:]
|
||||
// for (key, entry) in catalog.strings {
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// continue
|
||||
// }
|
||||
// localeKeys.insert(key)
|
||||
// let segments = gatherSegments(from: unit)
|
||||
// var pathMap: [String: [String]] = [:]
|
||||
// for segment in segments {
|
||||
// pathMap[segment.path] = placeholders(in: segment.value)
|
||||
// }
|
||||
// localePlaceholders[key] = pathMap
|
||||
// }
|
||||
// keysByLocale[locale] = localeKeys
|
||||
// placeholderSignature[locale] = localePlaceholders
|
||||
// }
|
||||
//
|
||||
// return CatalogContext(catalog: catalog, locales: locales, baseLocale: baseLocale, keysByLocale: keysByLocale, placeholderSignature: placeholderSignature)
|
||||
// }
|
||||
//
|
||||
// private func loadCatalog(relativePath: String) throws -> StringCatalog {
|
||||
// let url = repoRootURL.appendingPathComponent(relativePath)
|
||||
// let data = try Data(contentsOf: url)
|
||||
// return try JSONDecoder().decode(StringCatalog.self, from: data)
|
||||
// }
|
||||
//
|
||||
// private func loadPrimaryKeys() throws -> PrimaryKeyConfig {
|
||||
// let url = localizationTestsDirectoryURL.appendingPathComponent("PrimaryLocalizationKeys.json")
|
||||
// let data = try Data(contentsOf: url)
|
||||
// return try JSONDecoder().decode(PrimaryKeyConfig.self, from: data)
|
||||
// }
|
||||
//
|
||||
// // MARK: - Regression Tests
|
||||
//
|
||||
// /// Helper method to access the app bundle for localization testing.
|
||||
// /// This ensures tests use the actual app's Localizable.xcstrings instead of
|
||||
// /// the test bundle, preventing false positives where localization keys are
|
||||
// /// returned instead of translated strings.
|
||||
// private func appBundle() -> Bundle {
|
||||
// Bundle(for: ChatViewModel.self)
|
||||
// }
|
||||
//
|
||||
// /// Tests to prevent regression of the pluralization format string issue
|
||||
// /// These tests ensure that String(localized:) properly handles
|
||||
// /// pluralized format strings with %#@variable@ syntax
|
||||
// func testPluralizedFormatStringsDoNotCrash() {
|
||||
// // These are the exact calls that were causing runtime errors
|
||||
//
|
||||
// // Test 1: content.accessibility.people_count with Int argument
|
||||
// XCTAssertNoThrow({
|
||||
// let result = String(
|
||||
// localized: "content.accessibility.people_count",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Accessibility label announcing number of people in header"
|
||||
// )
|
||||
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||
// // Verify we're getting actual localized content, not just the key
|
||||
// XCTAssertNotEqual(result, "content.accessibility.people_count", "Should return localized string, not key")
|
||||
// }(), "People count localization should not throw")
|
||||
//
|
||||
// // Test 2: location_notes.header with String and Int arguments
|
||||
// XCTAssertNoThrow({
|
||||
// let result = String(
|
||||
// localized: "location_notes.header",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Header displaying the geohash and localized note count"
|
||||
// )
|
||||
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||
// // Verify we're getting actual localized content, not just the key
|
||||
// XCTAssertNotEqual(result, "location_notes.header", "Should return localized string, not key")
|
||||
// }(), "Location notes header localization should not throw")
|
||||
// }
|
||||
//
|
||||
// func testFormatStringArgumentMatching() {
|
||||
// // Verify that the format strings properly handle their expected arguments
|
||||
//
|
||||
// // People count expects: %#@people@ format with Int
|
||||
// let peopleResult = String(
|
||||
// localized: "content.accessibility.people_count",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Test"
|
||||
// )
|
||||
//
|
||||
// // Should not show format specifiers in the base string
|
||||
// XCTAssertFalse(peopleResult.isEmpty, "Should return non-empty string")
|
||||
//
|
||||
// // Location notes expects: #%@ • %#@note_count@ format with String, Int
|
||||
// let notesResult = String(
|
||||
// localized: "location_notes.header",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Test"
|
||||
// )
|
||||
//
|
||||
// XCTAssertFalse(notesResult.isEmpty, "Should return non-empty string")
|
||||
// }
|
||||
//
|
||||
// func testPluralizedStringsWithArguments() {
|
||||
// // Test the pluralized strings with actual format arguments
|
||||
//
|
||||
// // Test people count with different values
|
||||
// let testCases = [0, 1, 2, 5]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// count
|
||||
// )
|
||||
//
|
||||
// // Just verify it doesn't crash and returns a reasonable string
|
||||
// XCTAssertFalse(result.isEmpty,
|
||||
// "People count should return non-empty string for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain the count number for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testLocationNotesHeaderWithArguments() {
|
||||
// let geohash = "abc123"
|
||||
// let testCases = [0, 1, 2, 10]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// geohash, count
|
||||
// )
|
||||
//
|
||||
// // Verify basic structure is maintained
|
||||
// XCTAssertTrue(result.contains(geohash),
|
||||
// "Result should contain geohash for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain count for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testLocationChannelsRowTitleWithArguments() {
|
||||
// let label = "test-channel"
|
||||
// let testCases = [0, 1, 2, 5]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "location_channels.row_title", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// label, count
|
||||
// )
|
||||
//
|
||||
// // Verify basic structure is maintained
|
||||
// XCTAssertTrue(result.contains(label),
|
||||
// "Result should contain label for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain count for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testNoArgumentsLocalization() {
|
||||
// // Test that strings without arguments still work
|
||||
// let result = String(
|
||||
// localized: "common.ok",
|
||||
// bundle: appBundle(),
|
||||
// comment: "OK button text"
|
||||
// )
|
||||
//
|
||||
// XCTAssertFalse(result.isEmpty, "Simple localization should work")
|
||||
// }
|
||||
//
|
||||
// func testLocalizationEdgeCases() {
|
||||
// // Test edge cases that might cause issues
|
||||
//
|
||||
// // Large numbers
|
||||
// let largeNumberResult = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// 1000000
|
||||
// )
|
||||
// XCTAssertTrue(largeNumberResult.contains("1000000"), "Should handle large numbers")
|
||||
//
|
||||
// // Zero
|
||||
// let zeroResult = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// 0
|
||||
// )
|
||||
// XCTAssertTrue(zeroResult.contains("0"), "Should handle zero")
|
||||
//
|
||||
// // Empty geohash (edge case)
|
||||
// let emptyGeohashResult = String(
|
||||
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// "", 1
|
||||
// )
|
||||
// XCTAssertFalse(emptyGeohashResult.isEmpty, "Should handle empty geohash")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// MARK: - Helpers
|
||||
//
|
||||
//private struct CatalogContext {
|
||||
// let catalog: StringCatalog
|
||||
// let locales: [String]
|
||||
// let baseLocale: String
|
||||
// let keysByLocale: [String: Set<String>]
|
||||
// let placeholderSignature: [String: [String: [String: [String]]]]
|
||||
//}
|
||||
//
|
||||
//private struct StringCatalog: Decodable {
|
||||
// let sourceLanguage: String
|
||||
// let strings: [String: CatalogEntry]
|
||||
//
|
||||
// var locales: [String] {
|
||||
// var localeSet: Set<String> = []
|
||||
// for entry in strings.values {
|
||||
// localeSet.formUnion(entry.localizations.keys)
|
||||
// }
|
||||
// return localeSet.sorted()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//private struct CatalogEntry: Decodable {
|
||||
// let localizations: [String: CatalogLocalization]
|
||||
//}
|
||||
//
|
||||
//private struct CatalogLocalization: Decodable {
|
||||
// let stringUnit: CatalogStringUnit?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogStringUnit: Decodable {
|
||||
// let state: String
|
||||
// let value: String?
|
||||
// let variations: CatalogVariations?
|
||||
// let comment: String?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogVariations: Decodable {
|
||||
// let plural: [String: [String: CatalogVariationValue]]?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogVariationValue: Decodable {
|
||||
// let stringUnit: CatalogStringUnit?
|
||||
//}
|
||||
//
|
||||
//private struct Segment {
|
||||
// let components: [String]
|
||||
// let value: String
|
||||
//
|
||||
// var path: String {
|
||||
// components.isEmpty ? "base" : components.joined(separator: ".")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//private func gatherSegments(from unit: CatalogStringUnit, prefix: [String] = []) -> [Segment] {
|
||||
// var segments: [Segment] = []
|
||||
// if let value = unit.value {
|
||||
// segments.append(Segment(components: prefix, value: value))
|
||||
// } else if prefix.isEmpty {
|
||||
// segments.append(Segment(components: [], value: ""))
|
||||
// }
|
||||
// if let plural = unit.variations?.plural {
|
||||
// for (variable, categories) in plural.sorted(by: { $0.key < $1.key }) {
|
||||
// for (category, variation) in categories.sorted(by: { $0.key < $1.key }) {
|
||||
// if let nested = variation.stringUnit {
|
||||
// var nextPrefix = prefix
|
||||
// nextPrefix.append("plural")
|
||||
// nextPrefix.append(variable)
|
||||
// nextPrefix.append(category)
|
||||
// segments.append(contentsOf: gatherSegments(from: nested, prefix: nextPrefix))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return segments
|
||||
//}
|
||||
//
|
||||
//private func normalizedPlaceholders(_ tokens: [String]) -> [String] {
|
||||
// tokens.sorted()
|
||||
//}
|
||||
//
|
||||
//private func fallbackPath(for localePath: String, baseMap: [String: [String]]) -> String? {
|
||||
// let parts = localePath.split(separator: ".")
|
||||
// guard parts.count == 3, parts.first == "plural" else {
|
||||
// return nil
|
||||
// }
|
||||
// let variable = parts[1]
|
||||
// let otherKey = "plural.\(variable).other"
|
||||
// if baseMap[otherKey] != nil {
|
||||
// return otherKey
|
||||
// }
|
||||
// let oneKey = "plural.\(variable).one"
|
||||
// if baseMap[oneKey] != nil {
|
||||
// return oneKey
|
||||
// }
|
||||
// return nil
|
||||
//}
|
||||
//
|
||||
//private let placeholderRegex: NSRegularExpression = {
|
||||
// let pattern = "%(?:\\d+\\$)?#@[A-Za-z0-9_]+@|%(?:\\d+\\$)?[#0\\- +'\"]*(?:\\d+|\\*)?(?:\\.\\d+)?(?:hh|h|ll|l|z|t|L)?[a-zA-Z@]"
|
||||
// return try! NSRegularExpression(pattern: pattern, options: [])
|
||||
//}()
|
||||
//
|
||||
//private func placeholders(in string: String) -> [String] {
|
||||
// let range = NSRange(location: 0, length: (string as NSString).length)
|
||||
// let matches = placeholderRegex.matches(in: string, options: [], range: range)
|
||||
// var tokens: [String] = []
|
||||
// for match in matches {
|
||||
// if let range = Range(match.range, in: string) {
|
||||
// let token = String(string[range])
|
||||
// if token == "%%" { continue }
|
||||
// tokens.append(token)
|
||||
// }
|
||||
// }
|
||||
// return tokens
|
||||
//}
|
||||
//
|
||||
//private struct PrimaryKeyConfig: Decodable {
|
||||
// let app: [String]
|
||||
// let shareExtension: [String]
|
||||
// let expectedValues: [String: [String: String]]?
|
||||
// let testLocales: [String]?
|
||||
//}
|
||||
@@ -13,6 +13,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var blockedFingerprints: Set<String> = []
|
||||
private var blockedNostrPubkeys: Set<String> = []
|
||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||
|
||||
init(_ keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
@@ -25,7 +26,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
func forceSave() {}
|
||||
|
||||
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
|
||||
nil
|
||||
socialIdentities[fingerprint]
|
||||
}
|
||||
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
|
||||
@@ -34,7 +35,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
[]
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {}
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
socialIdentities[identity.fingerprint] = identity
|
||||
if identity.isBlocked {
|
||||
blockedFingerprints.insert(identity.fingerprint)
|
||||
} else {
|
||||
blockedFingerprints.remove(identity.fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func getFavorites() -> Set<String> {
|
||||
Set()
|
||||
@@ -47,10 +55,25 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func isBlocked(fingerprint: String) -> Bool {
|
||||
blockedFingerprints.contains(fingerprint)
|
||||
blockedFingerprints.contains(fingerprint) || socialIdentities[fingerprint]?.isBlocked == true
|
||||
}
|
||||
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
if var identity = socialIdentities[fingerprint] {
|
||||
identity.isBlocked = isBlocked
|
||||
socialIdentities[fingerprint] = identity
|
||||
} else {
|
||||
let identity = SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: isBlocked,
|
||||
notes: nil
|
||||
)
|
||||
socialIdentities[fingerprint] = identity
|
||||
}
|
||||
if isBlocked {
|
||||
blockedFingerprints.insert(fingerprint)
|
||||
} else {
|
||||
|
||||
@@ -184,6 +184,7 @@ final class MockTransport: Transport {
|
||||
}
|
||||
delegate?.didConnectToPeer(peerID)
|
||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||
publishPeerSnapshots()
|
||||
}
|
||||
|
||||
/// Simulates a peer disconnecting
|
||||
@@ -192,6 +193,7 @@ final class MockTransport: Transport {
|
||||
peerNicknames.removeValue(forKey: peerID)
|
||||
delegate?.didDisconnectFromPeer(peerID)
|
||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||
publishPeerSnapshots()
|
||||
}
|
||||
|
||||
/// Simulates receiving a message
|
||||
@@ -224,5 +226,22 @@ final class MockTransport: Transport {
|
||||
/// Updates the peer snapshot publisher
|
||||
func updatePeerSnapshots(_ snapshots: [TransportPeerSnapshot]) {
|
||||
peerSnapshotSubject.send(snapshots)
|
||||
Task { @MainActor [weak self] in
|
||||
self?.peerEventsDelegate?.didUpdatePeerSnapshots(snapshots)
|
||||
}
|
||||
}
|
||||
|
||||
private func publishPeerSnapshots() {
|
||||
let now = Date()
|
||||
let snapshots = connectedPeers.map { peerID in
|
||||
TransportPeerSnapshot(
|
||||
peerID: peerID,
|
||||
nickname: peerNicknames[peerID] ?? "",
|
||||
isConnected: true,
|
||||
noisePublicKey: Data(hexString: peerID.bare),
|
||||
lastSeen: now
|
||||
)
|
||||
}
|
||||
updatePeerSnapshots(snapshots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,8 +746,8 @@ struct NoiseProtocolTests {
|
||||
}
|
||||
|
||||
// Get transport ciphers
|
||||
let (initSend, initRecv) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false)
|
||||
let (respSend, respRecv) = try responderHandshake.getTransportCiphers(useExtractedNonce: false)
|
||||
let (initSend, initRecv, _) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false)
|
||||
let (respSend, respRecv, _) = try responderHandshake.getTransportCiphers(useExtractedNonce: false)
|
||||
|
||||
// Test transport messages (messages after the 3 handshake messages)
|
||||
for index in 3..<testVector.messages.count {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//
|
||||
// PublicMessagePipelineTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for PublicMessagePipeline ordering and deduplication.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
private final class TestPipelineDelegate: PublicMessagePipelineDelegate {
|
||||
private let dedupService = MessageDeduplicationService()
|
||||
var messages: [BitchatMessage] = []
|
||||
|
||||
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
|
||||
messages
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
|
||||
self.messages = messages
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
|
||||
dedupService.normalizedContentKey(content)
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
|
||||
dedupService.contentTimestamp(forKey: key)
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
|
||||
dedupService.recordContentKey(key, timestamp: timestamp)
|
||||
}
|
||||
|
||||
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {}
|
||||
|
||||
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {}
|
||||
|
||||
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {}
|
||||
}
|
||||
|
||||
struct PublicMessagePipelineTests {
|
||||
|
||||
@Test @MainActor
|
||||
func flush_sortsByTimestamp() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
|
||||
let earlier = Date().addingTimeInterval(-10)
|
||||
let later = Date()
|
||||
|
||||
let messageA = BitchatMessage(
|
||||
id: "a",
|
||||
sender: "A",
|
||||
content: "Later",
|
||||
timestamp: later,
|
||||
isRelay: false
|
||||
)
|
||||
let messageB = BitchatMessage(
|
||||
id: "b",
|
||||
sender: "A",
|
||||
content: "Earlier",
|
||||
timestamp: earlier,
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
pipeline.enqueue(messageA)
|
||||
pipeline.enqueue(messageB)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.map { $0.id } == ["b", "a"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func flush_deduplicatesByContentWithinWindow() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
|
||||
let now = Date()
|
||||
let messageA = BitchatMessage(
|
||||
id: "a",
|
||||
sender: "A",
|
||||
content: "Same",
|
||||
timestamp: now,
|
||||
isRelay: false
|
||||
)
|
||||
let messageB = BitchatMessage(
|
||||
id: "b",
|
||||
sender: "A",
|
||||
content: "Same",
|
||||
timestamp: now.addingTimeInterval(0.2),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
pipeline.enqueue(messageA)
|
||||
pipeline.enqueue(messageB)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.count == 1)
|
||||
#expect(delegate.messages.first?.content == "Same")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func lateInsert_meshAppendsRecentOlderMessage() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
pipeline.updateActiveChannel(.mesh)
|
||||
|
||||
let base = Date()
|
||||
let newer = BitchatMessage(
|
||||
id: "new",
|
||||
sender: "A",
|
||||
content: "New",
|
||||
timestamp: base,
|
||||
isRelay: false
|
||||
)
|
||||
let older = BitchatMessage(
|
||||
id: "old",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: base.addingTimeInterval(-5),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
delegate.messages = [newer]
|
||||
pipeline.enqueue(older)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.map { $0.id } == ["new", "old"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func lateInsert_locationInsertsByTimestamp() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
|
||||
let base = Date()
|
||||
let newer = BitchatMessage(
|
||||
id: "new",
|
||||
sender: "A",
|
||||
content: "New",
|
||||
timestamp: base,
|
||||
isRelay: false
|
||||
)
|
||||
let older = BitchatMessage(
|
||||
id: "old",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: base.addingTimeInterval(-5),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
delegate.messages = [newer]
|
||||
pipeline.enqueue(older)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.map { $0.id } == ["old", "new"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// MessageRouterTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for MessageRouter transport selection and outbox behavior.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct MessageRouterTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_usesReachableTransport() async {
|
||||
let peerID = PeerID(str: "0000000000000001")
|
||||
let transportA = MockTransport()
|
||||
let transportB = MockTransport()
|
||||
transportB.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transportA, transportB])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m1")
|
||||
|
||||
#expect(transportA.sentPrivateMessages.isEmpty)
|
||||
#expect(transportB.sentPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_queuesThenFlushesWhenReachable() async {
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
let transport = MockTransport()
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Queued", to: peerID, recipientNickname: "Peer", messageID: "m2")
|
||||
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
|
||||
transport.reachablePeers.insert(peerID)
|
||||
router.flushOutbox(for: peerID)
|
||||
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendReadReceipt_usesReachableTransport() async {
|
||||
let peerID = PeerID(str: "0000000000000003")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let receipt = ReadReceipt(originalMessageID: "m3", readerID: transport.myPeerID, readerNickname: "Me")
|
||||
router.sendReadReceipt(receipt, to: peerID)
|
||||
|
||||
#expect(transport.sentReadReceipts.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendFavoriteNotification_usesConnectedOrReachable() async {
|
||||
let peerID = PeerID(str: "0000000000000004")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
#expect(transport.sentFavoriteNotifications.count == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// PrivateChatManagerTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for PrivateChatManager read receipt and selection behavior.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct PrivateChatManagerTests {
|
||||
|
||||
@Test @MainActor
|
||||
func startChat_setsSelectedAndClearsUnread() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let peerID = PeerID(str: "00000000000000AA")
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
BitchatMessage(
|
||||
id: "pm-1",
|
||||
sender: "Peer",
|
||||
content: "Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(peerID)
|
||||
|
||||
manager.startChat(with: peerID)
|
||||
|
||||
#expect(manager.selectedPeer == peerID)
|
||||
#expect(!manager.unreadMessages.contains(peerID))
|
||||
#expect(manager.privateChats[peerID] != nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markAsRead_sendsReadReceiptViaRouter() async {
|
||||
let transport = MockTransport()
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
manager.messageRouter = router
|
||||
|
||||
let peerID = PeerID(str: "00000000000000BB")
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
BitchatMessage(
|
||||
id: "pm-2",
|
||||
sender: "Peer",
|
||||
content: "Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(peerID)
|
||||
|
||||
manager.markAsRead(from: peerID)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(transport.sentReadReceipts.count == 1)
|
||||
#expect(manager.sentReadReceipts.contains("pm-2"))
|
||||
#expect(!manager.unreadMessages.contains(peerID))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// RelayControllerTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for relay decision logic.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct RelayControllerTests {
|
||||
|
||||
@Test
|
||||
func ttlOne_doesNotRelay() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 1,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: false,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: false,
|
||||
isAnnounce: false,
|
||||
degree: 0,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(!decision.shouldRelay)
|
||||
#expect(decision.newTTL == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func handshake_alwaysRelaysWithTTLDecrement() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 3,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: false,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: true,
|
||||
isAnnounce: false,
|
||||
degree: 3,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(decision.shouldRelay)
|
||||
#expect(decision.newTTL == 2)
|
||||
#expect(decision.delayMs >= 10 && decision.delayMs <= 35)
|
||||
}
|
||||
|
||||
@Test
|
||||
func fragment_relaysWithFragmentCap() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 10,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: true,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: false,
|
||||
isAnnounce: false,
|
||||
degree: 3,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
let ttlCap = min(UInt8(10), TransportConfig.bleFragmentRelayTtlCap)
|
||||
let expected = ttlCap &- 1
|
||||
|
||||
#expect(decision.shouldRelay)
|
||||
#expect(decision.newTTL == expected)
|
||||
#expect(decision.delayMs >= TransportConfig.bleFragmentRelayMinDelayMs)
|
||||
#expect(decision.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
func denseGraph_capsTTL() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 10,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: false,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: false,
|
||||
isAnnounce: false,
|
||||
degree: TransportConfig.bleHighDegreeThreshold,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(decision.shouldRelay)
|
||||
#expect(decision.newTTL == 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//
|
||||
// UnifiedPeerServiceTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for UnifiedPeerService fingerprint and block resolution.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct UnifiedPeerServiceTests {
|
||||
|
||||
@Test @MainActor
|
||||
func getFingerprint_prefersMeshService() async {
|
||||
let transport = MockTransport()
|
||||
let identity = TestIdentityManager()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||
|
||||
let peerID = PeerID(str: "00000000000000CC")
|
||||
transport.peerFingerprints[peerID] = "fp-1"
|
||||
|
||||
let fingerprint = service.getFingerprint(for: peerID)
|
||||
|
||||
#expect(fingerprint == "fp-1")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func isBlocked_usesSocialIdentity() async {
|
||||
let transport = MockTransport()
|
||||
let identity = TestIdentityManager()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||
|
||||
let peerID = PeerID(str: "00000000000000DD")
|
||||
let fingerprint = "fp-blocked"
|
||||
transport.peerFingerprints[peerID] = fingerprint
|
||||
identity.setBlocked(fingerprint, isBlocked: true)
|
||||
|
||||
#expect(service.isBlocked(peerID))
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||
private var favorites: Set<String> = []
|
||||
private var blockedNostr: Set<String> = []
|
||||
private var verified: Set<String> = []
|
||||
|
||||
func forceSave() {}
|
||||
|
||||
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
|
||||
socialIdentities[fingerprint]
|
||||
}
|
||||
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
|
||||
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||
[]
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
socialIdentities[identity.fingerprint] = identity
|
||||
}
|
||||
|
||||
func getFavorites() -> Set<String> {
|
||||
favorites
|
||||
}
|
||||
|
||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
||||
if isFavorite {
|
||||
favorites.insert(fingerprint)
|
||||
} else {
|
||||
favorites.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isFavorite(fingerprint: String) -> Bool {
|
||||
favorites.contains(fingerprint)
|
||||
}
|
||||
|
||||
func isBlocked(fingerprint: String) -> Bool {
|
||||
socialIdentities[fingerprint]?.isBlocked ?? false
|
||||
}
|
||||
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
var identity = socialIdentities[fingerprint] ?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
identity.isBlocked = isBlocked
|
||||
socialIdentities[fingerprint] = identity
|
||||
}
|
||||
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||
blockedNostr.contains(pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||
if isBlocked {
|
||||
blockedNostr.insert(pubkeyHexLowercased)
|
||||
} else {
|
||||
blockedNostr.remove(pubkeyHexLowercased)
|
||||
}
|
||||
}
|
||||
|
||||
func getBlockedNostrPubkeys() -> Set<String> {
|
||||
blockedNostr
|
||||
}
|
||||
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
||||
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {}
|
||||
|
||||
func clearAllIdentityData() {
|
||||
socialIdentities.removeAll()
|
||||
favorites.removeAll()
|
||||
blockedNostr.removeAll()
|
||||
verified.removeAll()
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
if verified {
|
||||
self.verified.insert(fingerprint)
|
||||
} else {
|
||||
self.verified.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isVerified(fingerprint: String) -> Bool {
|
||||
verified.contains(fingerprint)
|
||||
}
|
||||
|
||||
func getVerifiedFingerprints() -> Set<String> {
|
||||
verified
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,6 @@ struct SubscriptionRateLimitTests {
|
||||
@Test("Max attempts threshold prevents complete enumeration")
|
||||
func maxAttemptsThresholdPreventsEnumeration() {
|
||||
let maxAttempts = TransportConfig.bleSubscriptionRateLimitMaxAttempts
|
||||
let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds
|
||||
|
||||
// After max attempts within window, announces are suppressed entirely
|
||||
// This means an attacker gets at most maxAttempts announces per window
|
||||
|
||||
@@ -93,6 +93,22 @@ final class TestHelpers {
|
||||
try await sleep(0.01)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func waitUntil(
|
||||
_ condition: @escaping () -> Bool,
|
||||
timeout: TimeInterval = TestConstants.defaultTimeout,
|
||||
pollInterval: TimeInterval = 0.01
|
||||
) async -> Bool {
|
||||
let start = Date()
|
||||
while !condition() {
|
||||
if Date().timeIntervalSince(start) > timeout {
|
||||
return condition()
|
||||
}
|
||||
try? await sleep(pollInterval)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
static func expectAsync<T>(
|
||||
timeout: TimeInterval = TestConstants.defaultTimeout,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Request Sync Manager & V2 Packet Updates
|
||||
|
||||
This document details the implementation of the Request Sync Manager and updates to the V2 packet structure to improve synchronization security and attribution on iOS, mirroring the Android implementation.
|
||||
|
||||
## Overview
|
||||
|
||||
The goal of these changes is to make the request sync functionality "less blind". Previously, sync requests were broadcast, and responses were accepted without strict attribution or timestamp validation (to allow syncing old messages). This opened up potential spoofing vectors and prevented us from enforcing timestamp checks on normal traffic.
|
||||
|
||||
The new implementation introduces a **RequestSyncManager** to track outgoing sync requests and attributes incoming responses (RSR - Request-Sync Response) to specific peers. This allows us to:
|
||||
1. **Enforce Timestamp Validation**: Normal packets now require timestamps to be within 2 minutes of the local clock.
|
||||
2. **Exempt Solicited Sync Responses**: Packets marked as RSR are exempt from timestamp validation *only if* they correspond to a valid, pending sync request sent to that specific peer.
|
||||
3. **Prevent Unsolicited Sync Floods**: Unsolicited RSR packets are rejected.
|
||||
|
||||
## Protocol Changes
|
||||
|
||||
### Binary Protocol Updates
|
||||
* **New Flag**: `IS_RSR` (0x10) added to the packet header flags.
|
||||
* **BitchatPacket**: Updated to include `isRSR: Bool` field.
|
||||
* **Encoding/Decoding**: Updated `BinaryProtocol` to handle the new flag.
|
||||
|
||||
### Request Sync Payload
|
||||
The `REQUEST_SYNC` packet payload (TLV encoded) has been updated to include:
|
||||
* **Future Filters**:
|
||||
* `sinceTimestamp` (Type 0x05): To request packets since a certain time (UInt64 big-endian).
|
||||
* `fragmentIdFilter` (Type 0x06): To request specific fragments (UTF-8 string).
|
||||
|
||||
## Architecture
|
||||
|
||||
### RequestSyncManager
|
||||
A new component (`Sync/RequestSyncManager.swift`) responsible for:
|
||||
* **Tracking**: Stores `peerID -> timestamp` mappings for pending sync requests.
|
||||
* **Validation**: `isValidResponse(from: PeerID, isRSR: Bool)` checks if an incoming RSR packet matches a pending request within the 30-second window.
|
||||
* **Cleanup**: Periodically removes expired requests.
|
||||
|
||||
### GossipSyncManager Updates
|
||||
* **Unicast Sync**: Instead of blind broadcasting, the periodic sync task now iterates over connected peers and sends unicast `REQUEST_SYNC` packets.
|
||||
* **Registration**: Before sending, requests are registered with `RequestSyncManager`.
|
||||
* **Response Marking**: When responding to a `REQUEST_SYNC`, generated packets (Announce/Message) are explicitly marked with `isRSR = true` (and `ttl = 0`).
|
||||
|
||||
### BLEService (Security Manager) Updates
|
||||
* **Timestamp Enforcement**: Checks `abs(now - packetTimestamp) < 2 minutes` for standard packets.
|
||||
* **Conditional Exemption**: If `packet.isRSR` is true (or packet is a legacy TTL=0 response), it queries `RequestSyncManager`.
|
||||
* **Valid**: If solicited, timestamp check is skipped (allowing historical data sync).
|
||||
* **Invalid**: If unsolicited or timed out, the packet is rejected.
|
||||
|
||||
## Usage
|
||||
|
||||
These changes are integrated into `BLEService` and `GossipSyncManager`. No external API changes are required for clients, but all peers must be updated to support the new `IS_RSR` flag and protocol logic to participate in the secure sync process.
|
||||
@@ -19,4 +19,5 @@ public extension OSLog {
|
||||
static let session = OSLog(subsystem: subsystem, category: "session")
|
||||
static let security = OSLog(subsystem: subsystem, category: "security")
|
||||
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
|
||||
static let sync = OSLog(subsystem: subsystem, category: "sync")
|
||||
}
|
||||
|
||||
Vendored
+271
-269
@@ -1,269 +1,271 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
relay-admin.thaliyal.com,40.8218,-74.45
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
strfry.bonsai.com,37.8715,-122.273
|
||||
nostr-relay.online,40.7357,-74.1724
|
||||
shu05.shugur.net,48.8566,2.35222
|
||||
dev-nostr.bityacht.io,25.0797,121.234
|
||||
relay.nostrhub.tech,49.0291,8.35696
|
||||
relay.davidebtc.me,50.1109,8.68213
|
||||
relay.moinsen.com,50.4754,12.3683
|
||||
relay.olas.app,50.4754,12.3683
|
||||
mhp258zrpiiwn.clorecloud.net,43.6532,-79.3832
|
||||
orangepiller.org,60.1699,24.9384
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
relay.guggero.org,47.3769,8.54169
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
wot.sovbit.host,64.1466,-21.9426
|
||||
nostr.huszonegy.world,47.4979,19.0402
|
||||
wot.sebastix.social,51.8933,4.42083
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
nostr.spicyz.io,40.7357,-74.1724
|
||||
nostr.jerrynya.fun,31.2304,121.474
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
vitor.nostr1.com,40.7128,-74.006
|
||||
relay.lumina.rocks,49.0291,8.35695
|
||||
nostr-relay-1.trustlessenterprise.com,40.7357,-74.1724
|
||||
nostr.bilthon.dev,25.8128,-80.2377
|
||||
nostr.now,36.55,139.733
|
||||
nostr.girino.org,40.7357,-74.1724
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
ribo.af.nostria.app,-26.2041,28.0473
|
||||
nostrelay.memory-art.xyz,43.6532,-79.3832
|
||||
relay.evanverma.com,40.8302,-74.1299
|
||||
a.nos.lol,50.4754,12.3683
|
||||
purpura.cloud,40.7357,-74.1724
|
||||
relay.snort.social,43.6532,-79.3832
|
||||
relay.holzeis.me,43.6532,-79.3832
|
||||
nostr.spaceshell.xyz,40.7128,-74.006
|
||||
nostr.liberty.fans,36.9104,-89.5875
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
wot.basspistol.org,49.4521,11.0767
|
||||
ribo.eu.nostria.app,52.3676,4.90414
|
||||
relay.notoshi.win,13.4166,101.335
|
||||
relay.stream.labs.h3.se,59.4016,17.9455
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
relay.satlantis.io,32.8769,-80.0114
|
||||
nostr-2.21crypto.ch,47.4988,8.72369
|
||||
nostr.satstralia.com,64.1476,-21.9392
|
||||
slick.mjex.me,39.048,-77.4817
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
relay.origin.land,35.6673,139.751
|
||||
nostr.fbxl.net,48.382,-89.2502
|
||||
relay.nostr.place,32.7767,-96.797
|
||||
nr.yay.so,46.2126,6.1154
|
||||
nostream.breadslice.com,40.7357,-74.1724
|
||||
wot.tealeaf.dev,33.7488,-84.3877
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
relay.chorus.community,50.1109,8.68213
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr-relay.amethyst.name,39.0438,-77.4874
|
||||
nostr.mehdibekhtaoui.com,49.4939,-1.54813
|
||||
relay.mess.ch,46.948,7.44745
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
relay-rpi.edufeed.org,49.4543,11.0746
|
||||
nostr.faultables.net,43.6532,-79.3832
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
cyberspace.nostr1.com,40.7128,-74.006
|
||||
relay.endfiat.money,43.6532,-79.3832
|
||||
soloco.nl,43.6532,-79.3832
|
||||
nostr.kungfu-g.rip,33.7946,-84.4488
|
||||
nostrelay.circum.space,51.2217,6.77616
|
||||
relay.trustroots.org,43.6532,-79.3832
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.coinos.io,40.7357,-74.1724
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
relay.bitcoinartclock.com,50.4754,12.3683
|
||||
nostr.einundzwanzig.space,50.1109,8.68213
|
||||
nostr.casa21.space,43.6532,-79.3832
|
||||
premium.primal.net,40.7357,-74.1724
|
||||
relay.tagayasu.xyz,43.6715,-79.38
|
||||
nostr.mom,50.4754,12.3683
|
||||
nostr.zenon.network,43.5009,-70.4428
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
relay.g1sms.fr,43.9432,2.07537
|
||||
nostr-rs-relay-ishosta.phamthanh.me,40.7357,-74.1724
|
||||
relay.illuminodes.com,47.6061,-122.333
|
||||
dizzyspells.nostr1.com,40.7057,-74.0136
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
relay.nostr.wirednet.jp,34.706,135.493
|
||||
relay.barine.co,43.6532,-79.3832
|
||||
relay.damus.io,43.6532,-79.3832
|
||||
relay.0xchat.com,1.35208,103.82
|
||||
relay.mattybs.lol,40.7357,-74.1724
|
||||
no.str.cr,9.92857,-84.0528
|
||||
relay.utxo.farm,35.6916,139.768
|
||||
nostr.pleb.one,38.6327,-90.1961
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
relay.nostraddress.com,40.7357,-74.1724
|
||||
satsage.xyz,37.3986,-121.964
|
||||
offchain.pub,36.1809,-115.241
|
||||
noxir.kpherox.dev,34.8587,135.509
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
khatru.nostrver.se,51.8933,4.42083
|
||||
purplerelay.com,50.1109,8.68213
|
||||
relay.tapestry.ninja,40.8054,-74.0241
|
||||
nostr.night7.space,50.4754,12.3683
|
||||
nostr.rikmeijer.nl,50.4754,12.3683
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
nostr.21crypto.ch,47.4988,8.72369
|
||||
wot.soundhsa.com,33.1384,-95.6011
|
||||
relay.orangepill.ovh,49.1689,-0.358841
|
||||
talon.quest,43.6532,-79.3832
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
fanfares.nostr1.com,40.7128,-74.006
|
||||
gnostr.com,42.6978,23.3246
|
||||
nostrcheck.tnsor.network,40.7357,-74.1724
|
||||
nostrelites.org,41.8781,-87.6298
|
||||
relay.bitcoindistrict.org,43.6532,-79.3832
|
||||
relay.fr13nd5.com,52.5233,13.3426
|
||||
wot.nostr.place,30.2672,-97.7431
|
||||
ithurtswhenip.ee,51.223,6.78245
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
relay2.ngengine.org,40.7357,-74.1724
|
||||
relay.nostr.net,50.4754,12.3683
|
||||
nostr-relay.cbrx.io,40.7357,-74.1724
|
||||
dev-relay.lnfi.network,39.0997,-94.5786
|
||||
relay.jeffg.fyi,43.6532,-79.3832
|
||||
relay.ngengine.org,40.7357,-74.1724
|
||||
nos.xmark.cc,50.6924,3.20113
|
||||
relay.21e6.cz,50.1682,14.0546
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr.coincrowd.fund,39.0438,-77.4874
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
relay.digitalezukunft.cyou,45.5019,-73.5674
|
||||
r.lostr.net,52.3676,4.90414
|
||||
relay.etch.social,41.2619,-95.8608
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
nostr-03.dorafactory.org,1.35208,103.82
|
||||
relay.copylaradio.com,51.223,6.78245
|
||||
nostr.camalolo.com,24.1469,120.684
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
r.bitcoinhold.net,43.6532,-79.3832
|
||||
nproxy.kristapsk.lv,60.1699,24.9384
|
||||
adre.su,59.9311,30.3609
|
||||
relay.hasenpfeffr.com,39.0438,-77.4874
|
||||
nos.lol,50.4754,12.3683
|
||||
relay.nostr.band,60.1699,24.9384
|
||||
nostr-02.czas.top,53.471,9.88208
|
||||
relay.nosto.re,51.8933,4.42083
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
nostr.rblb.it,43.4633,11.8796
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
nostr.luisschwab.net,40.7357,-74.1724
|
||||
relay.electriclifestyle.com,26.2897,-80.1293
|
||||
librerelay.aaroniumii.com,43.6532,-79.3832
|
||||
nostr.88mph.life,40.7357,-74.1724
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
relay.hook.cafe,40.7357,-74.1724
|
||||
strfry.elswa-dev.online,48.8566,2.35222
|
||||
wot.sudocarlos.com,51.5072,-0.127586
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
nostr.tadryanom.me,40.7357,-74.1724
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostr.agentcampfire.com,50.8933,6.05805
|
||||
relay.ditto.pub,40.7357,-74.1724
|
||||
relay03.lnfi.network,39.0997,-94.5786
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
srtrelay.c-stellar.net,40.7357,-74.1724
|
||||
relayone.soundhsa.com,33.1384,-95.6011
|
||||
relay.javi.space,43.4633,11.8796
|
||||
nostr.carroarmato0.be,50.9928,3.26317
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
strfry.shock.network,41.8959,-88.2169
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
relay.toastr.net,40.8054,-74.0241
|
||||
relay.bitcoinveneto.org,64.1466,-21.9426
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
temp.iris.to,40.7357,-74.1724
|
||||
relay.vrtmrz.net,40.7357,-74.1724
|
||||
nostr-relay.zimage.com,34.282,-118.439
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
ribo.us.nostria.app,41.5868,-93.625
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay.agora.social,50.7383,15.0648
|
||||
nostr.ovia.to,43.6532,-79.3832
|
||||
nostr.red5d.dev,40.7357,-74.1724
|
||||
orangesync.tech,50.1109,8.68213
|
||||
relay.fountain.fm,39.0997,-94.5786
|
||||
relay.aloftus.io,34.0881,-118.379
|
||||
nostr.hifish.org,47.4043,8.57398
|
||||
relay.siamdev.cc,13.9178,100.424
|
||||
fenrir-s.notoshi.win,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
wheat.happytavern.co,40.7357,-74.1724
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.nostrhub.fr,48.1046,11.6002
|
||||
strfry.openhoofd.nl,51.9229,4.40833
|
||||
relay.usefusion.ai,38.7134,-78.1591
|
||||
relay.credenso.cafe,43.3601,-80.3127
|
||||
nostr.lostr.space,40.7357,-74.1724
|
||||
relay.jmoose.rocks,60.1699,24.9384
|
||||
relay.nostromo.social,49.4543,11.0746
|
||||
nostr.jfischer.org,49.0291,8.35696
|
||||
relay.wolfcoil.com,35.6092,139.73
|
||||
nostr.thaliyal.com,40.8218,-74.45
|
||||
relay.magiccity.live,25.8128,-80.2377
|
||||
relay.puresignal.news,40.7357,-74.1724
|
||||
prl.plus,55.7623,37.6381
|
||||
wot.brightbolt.net,47.6735,-116.781
|
||||
relay.varke.eu,52.6921,6.19372
|
||||
alienos.libretechsystems.xyz,55.4724,9.87335
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
pyramid.fiatjaf.com,51.5072,-0.127586
|
||||
relay02.lnfi.network,39.0997,-94.5786
|
||||
nostr.davidebtc.me,50.1109,8.68213
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
relay.cypherflow.ai,48.8566,2.35222
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
inbox.azzamo.net,52.2633,21.0283
|
||||
shu01.shugur.net,21.4902,39.2246
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
nostr.kalf.org,52.3676,4.90414
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
relay.angor.io,48.1046,11.6002
|
||||
nostr2.girino.org,40.7357,-74.1724
|
||||
relay01.lnfi.network,39.0997,-94.5786
|
||||
nostr.chaima.info,51.223,6.78245
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
shu04.shugur.net,25.2604,55.2989
|
||||
santo.iguanatech.net,40.8302,-74.1299
|
||||
relay.artx.market,43.652,-79.3633
|
||||
alien.macneilmediagroup.com,40.7357,-74.1724
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
zap.watch,45.5029,-73.5723
|
||||
relay.basspistol.org,46.2044,6.14316
|
||||
relay.13room.space,43.6532,-79.3832
|
||||
relay.bullishbounty.com,40.7357,-74.1724
|
||||
theoutpost.life,64.1476,-21.9392
|
||||
nostr.coincards.com,53.5501,-113.469
|
||||
black.nostrcity.club,41.8781,-87.6298
|
||||
relay.npubhaus.com,40.7357,-74.1724
|
||||
relay.freeplace.nl,52.3676,4.90414
|
||||
relay.seq1.net,43.6532,-79.3832
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
relay.nostr.vet,52.6467,4.7395
|
||||
relay.lifpay.me,1.35208,103.82
|
||||
relay.chakany.systems,43.6532,-79.3832
|
||||
relay.lightning.pub,41.8959,-88.2169
|
||||
wot.dtonon.com,43.6532,-79.3832
|
||||
yabu.me,35.6092,139.73
|
||||
wot.nostr.net,43.6532,-79.3832
|
||||
relay.libernet.app,40.7357,-74.1724
|
||||
relay04.lnfi.network,39.0997,-94.5786
|
||||
nostr.0x7e.xyz,47.4988,8.72369
|
||||
nostr.mikoshi.de,50.1109,8.68213
|
||||
wot.nostr.party,36.1627,-86.7816
|
||||
relay.letsfo.com,51.098,17.0321
|
||||
nostr.makibisskey.work,43.6532,-79.3832
|
||||
nostr.simplex.icu,50.8198,-1.08798
|
||||
Relay URL,Latitude,Longitude
|
||||
relay.angor.io,48.1046,11.6002
|
||||
black.nostrcity.club,48.8566,2.35222
|
||||
relay.damus.io,43.6532,-79.3832
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
relay.nuts.cash,34.0362,-118.443
|
||||
lightning.red,53.3498,-6.26031
|
||||
relay.davidebtc.me,43.6532,-79.3832
|
||||
nostr-02.czas.top,51.2277,6.77346
|
||||
inbox.azzamo.net,52.2633,21.0283
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
r.bitcoinhold.net,43.6532,-79.3832
|
||||
nostr.self-determined.de,54.5847,10.0178
|
||||
relay.fr13nd5.com,52.5233,13.3426
|
||||
wons.calva.dev,37.3986,-121.964
|
||||
pyramid-relay.meows.lol,38.7475,-77.5317
|
||||
relay.pinseekr.golf,43.6532,-79.3832
|
||||
nostr-relay.online,43.6532,-79.3832
|
||||
relay.evanverma.com,40.8302,-74.1299
|
||||
relay.nostr.wirednet.jp,34.706,135.493
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
testrelay.era21.space,43.6532,-79.3832
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
purpura.cloud,43.6532,-79.3832
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nostr.jerrynya.fun,31.2304,121.474
|
||||
relay.bitcoindistrict.org,43.6532,-79.3832
|
||||
nostr.ps1829.com,33.8851,130.883
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
wheat.happytavern.co,43.6532,-79.3832
|
||||
plebchain.club,43.6532,-79.3832
|
||||
relay2.ngengine.org,43.6532,-79.3832
|
||||
relay01.lnfi.network,39.0997,-94.5786
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.21e6.cz,50.7383,15.0648
|
||||
relay.erybody.com,41.4513,-81.7021
|
||||
nostr.commonshub.brussels,49.4543,11.0746
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
relay.threenine.services,51.5524,-0.29686
|
||||
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
||||
nostr.zoracle.org,45.6018,-121.185
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
relay.satlantis.io,32.8769,-80.0114
|
||||
relay.malxte.de,52.52,13.405
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.nostr.place,32.7767,-96.797
|
||||
shu05.shugur.net,48.8566,2.35222
|
||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
||||
nostr-relayrs.gateway.in.th,15.2634,100.344
|
||||
relay.nostrverse.net,43.6532,-79.3832
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
relay.bitcoinartclock.com,50.4754,12.3683
|
||||
0m0sef4nb45q6.cloreai.ru,43.6532,-79.3832
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostrcheck.tnsor.network,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0438,-77.4874
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.jeffg.fyi,43.6532,-79.3832
|
||||
relay.seq1.net,43.6532,-79.3832
|
||||
relay.lumina.rocks,49.0291,8.35695
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
nostr.red5d.dev,43.6532,-79.3832
|
||||
slick.mjex.me,39.048,-77.4817
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay-freeharmonypeople.space,38.7223,-9.13934
|
||||
relay.routstr.com,43.6532,-79.3832
|
||||
garden.zap.cooking,40.9481,-79.7428
|
||||
relay.nostrops.com,33.7584,-84.6375
|
||||
spookstr2.nostr1.com,40.7128,-74.006
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
relay.coinos.io,43.6532,-79.3832
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
nostr.night7.space,50.4754,12.3683
|
||||
nostr.casa21.space,43.6532,-79.3832
|
||||
nostr.bgbitcoin.club,50.4754,12.3683
|
||||
strfry.felixzieger.de,50.1013,8.62643
|
||||
wot.brightbolt.net,47.6735,-116.781
|
||||
freelay.sovbit.host,64.1476,-21.9392
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
relay.vantis.ninja,43.6532,-79.3832
|
||||
relay.cyphernomad.com,60.1699,24.9384
|
||||
relay.thebluepulse.com,49.4521,11.0767
|
||||
fenrir-s.notoshi.win,43.6532,-79.3832
|
||||
nostr.rblb.it,43.7094,10.6582
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
wot.sebastix.social,51.1792,5.89444
|
||||
wotr.relatr.xyz,53.3498,-6.26031
|
||||
nostr.mehdibekhtaoui.com,49.4939,-1.54813
|
||||
relay.snort.social,53.3498,-6.26031
|
||||
librerelay.aaroniumii.com,43.6532,-79.3832
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
adre.su,59.8845,30.3184
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
dev-relay.lnfi.network,39.0997,-94.5786
|
||||
nostr-03.dorafactory.org,1.35208,103.82
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
ragnar.nostrops.com,33.7946,-84.4488
|
||||
kitchen.zap.cooking,43.6532,-79.3832
|
||||
nos4smartnkind.tech,40.1872,44.5152
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
relay.origin.land,35.6673,139.751
|
||||
new.orly.dev,55.9349,23.3137
|
||||
strfry.ymir.cloud,34.0965,-117.585
|
||||
ribo.eu.nostria.app,52.3676,4.90414
|
||||
nostr.bilthon.dev,25.7975,-80.23
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
yabu.me,35.6092,139.73
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
relay.guggero.org,47.3769,8.54169
|
||||
shu03.shugur.net,25.2048,55.2708
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
r.alphaama.com,60.1699,24.9384
|
||||
nostr.mom,50.4754,12.3683
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
nostr.faultables.net,43.6532,-79.3832
|
||||
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||
nostr.bitczat.pl,60.1699,24.9384
|
||||
relay.endfiat.money,43.6532,-79.3832
|
||||
nostr.luisschwab.net,43.6532,-79.3832
|
||||
nostr-relay.gateway.in.th,15.2634,100.344
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
wot.nostr.place,32.7767,-96.797
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
czas.xyz,48.8566,2.35222
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
pyramid.aaro.cc,32.7767,-96.797
|
||||
purplerelay.com,50.1109,8.68213
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
relay.agora.social,50.7383,15.0648
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
relay.nostrzh.org,43.6532,-79.3832
|
||||
nostr.n7ekb.net,47.4941,-122.294
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
nostr.mifen.me,43.6532,-79.3832
|
||||
relay.anmore.me,49.704,-124.918
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
relay.0xchat.com,1.35208,103.82
|
||||
bitsat.molonlabe.holdings,51.4012,-1.3147
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
api.freefrom.space/v1/ws,43.6532,-79.3832
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
bbw-nostr.xyz,41.5284,-87.4237
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
relay.artx.market,43.6532,-79.3832
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
relay.bnos.space,43.6532,-79.3832
|
||||
wot.nostr.party,36.1512,-86.7835
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
strfry.openhoofd.nl,51.9229,4.40833
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
relay.chakany.systems,43.6532,-79.3832
|
||||
nostr.thaliyal.com,40.8218,-74.45
|
||||
relay.ryzizub.com,43.6532,-79.3832
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
bcast.seutoba.com.br,43.6532,-79.3832
|
||||
nostrelay.circum.space,52.3676,4.90414
|
||||
relay.janx.com,41.2054,-76.0049
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
relay.toastr.net,40.8054,-74.0241
|
||||
relay.puresignal.news,43.6532,-79.3832
|
||||
relay.magiccity.live,25.7975,-80.23
|
||||
satsage.xyz,37.3986,-121.964
|
||||
nostr.now,36.55,139.733
|
||||
offchain.pub,47.6743,-117.112
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
nostr.chaima.info,50.1109,8.68213
|
||||
freeben666.fr,43.7221,7.15296
|
||||
relay.siamdev.cc,13.8434,100.363
|
||||
wot.nostr.net,43.6532,-79.3832
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.ngengine.org,43.6532,-79.3832
|
||||
relay.tagayasu.xyz,45.4215,-75.6972
|
||||
wot.yesnostr.net,50.9871,2.12554
|
||||
relay.wavefunc.live,34.0362,-118.443
|
||||
relay.snotr.nl:49999,52.0067,4.35556
|
||||
nostr.dler.com,25.0501,121.565
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
relay.comcomponent.com,34.7062,135.493
|
||||
relay.nosto.re,51.1792,5.89444
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
nostr.robosats.org,64.1476,-21.9392
|
||||
strfry.elswa-dev.online,50.1109,8.68213
|
||||
nos.lol,50.4754,12.3683
|
||||
aaa-api.freefrom.space/v1/ws,43.6532,-79.3832
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
prl.plus,42.6978,23.3246
|
||||
relay.illuminodes.com,47.6061,-122.333
|
||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||
nostrelites.org,41.8781,-87.6298
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
nostr.tadryanom.me,43.6532,-79.3832
|
||||
okn.czas.plus,50.1109,8.68213
|
||||
relay03.lnfi.network,39.0997,-94.5786
|
||||
cyberspace.nostr1.com,40.7128,-74.006
|
||||
notemine.io,52.2026,20.9397
|
||||
wot.sovbit.host,64.1466,-21.9426
|
||||
santo.iguanatech.net,40.8302,-74.1299
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
relay.chorus.community,50.1109,8.68213
|
||||
relay.orangepill.ovh,49.1689,-0.358841
|
||||
wot.dtonon.com,43.6532,-79.3832
|
||||
nostr.parallel.hetu.org:8443,1.35208,103.82
|
||||
discovery.eu.nostria.app,52.3676,4.90414
|
||||
nostrcheck.me,43.6532,-79.3832
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
nostr.superfriends.online,43.6532,-79.3832
|
||||
relay.keykeeper.world,40.7824,-74.0711
|
||||
nostr.ovia.to,43.6532,-79.3832
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
nostr-relay.corb.net,38.8353,-104.822
|
||||
relay.credenso.cafe,43.3601,-80.3127
|
||||
relayone.soundhsa.com,33.1384,-95.6011
|
||||
relay.notoshi.win,14.2046,101.213
|
||||
relay.olas.app,50.4754,12.3683
|
||||
nostr.mikoshi.de,47.8786,11.911
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
nostr.noones.com,50.1109,8.68213
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
strfry.bonsai.com,37.8715,-122.273
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
relay.contextvm.org,53.3498,-6.26031
|
||||
relay.westernbtc.com,44.5401,-123.368
|
||||
soloco.nl,43.6532,-79.3832
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr.lkjsxc.com,43.6532,-79.3832
|
||||
theoutpost.life,64.1476,-21.9392
|
||||
relay.javi.space,43.4633,11.8796
|
||||
khatru.nostrver.se,51.1792,5.89444
|
||||
relay.nostrcheck.me,43.6532,-79.3832
|
||||
nostr.bond,50.1109,8.68213
|
||||
relay.cypherflow.ai,48.8566,2.35222
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
|
||||
|
Reference in New Issue
Block a user