Compare commits

..
Author SHA1 Message Date
jack 2b363b7062 Resolve merge conflicts in ChatViewModel.swift (geohash sampling comments and removed background nudge) 2025-08-28 09:09:55 +01:00
jack 7d672f2d69 Geohash notifications: ensure triggering message appears\n\n- On zero→alive sampling, pre-populate geoTimelines[gh] with the triggering message\n- Dedup on per-geohash timeline and UI messages by message ID to avoid duplicates after subscribe\n- Keeps participant update, block/self checks, and cooldown intact 2025-08-28 08:58:12 +01:00
jack d35d3f9612 Notifications: remove background 'new chats!' nudge; geohash activity only on zero→alive; mesh 'nearby' only on zero→alive\n\n- Geohash sampling: notify only when previous participant count in last 5m was zero, with 30s freshness gate\n- Suppress old sampled events after (re)subscribe\n- Remove channel inactivity nudge\n- Mesh: reset rising-edge gate immediately when peer list goes empty (strict zero→alive) 2025-08-28 08:54:55 +01:00
jack 9e79b18dcb Add geohash bookmarks: persistence, sampling integration, and UI\n\n- Add GeohashBookmarksStore with UserDefaults persistence and toggle API\n- Sample union of regional + bookmarked geohashes for activity notifications\n- LocationChannelsSheet: bookmark icons on nearby rows and a Bookmarked section\n- Header toolbar: toggle bookmark for current geohash\n- Tests: GeohashBookmarksStoreTests for normalization and persistence\n\nRationale: Bookmarked geohashes are always sampled for new activity notifications and quickly selectable from the sheet. 2025-08-28 00:44:42 +01:00
12 changed files with 341 additions and 531 deletions
+66 -131
View File
@@ -44,15 +44,8 @@ class NostrRelayManager: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// Message queue for reliability // Message queue for reliability
// Pending sends held only for relays that are not yet connected. private var messageQueue: [(event: NostrEvent, relayUrls: [String])] = []
private struct PendingSend {
var event: NostrEvent
var pendingRelays: Set<String>
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -66,8 +59,6 @@ class NostrRelayManager: ObservableObject {
init() { init() {
// Initialize with default relays // Initialize with default relays
self.relays = Self.defaultRelays.map { Relay(url: $0) } self.relays = Self.defaultRelays.map { Relay(url: $0) }
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
} }
/// Connect to all configured relays /// Connect to all configured relays
@@ -105,56 +96,15 @@ class NostrRelayManager: ObservableObject {
let targetRelays = relayUrls ?? Self.defaultRelays let targetRelays = relayUrls ?? Self.defaultRelays
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
// Attempt immediate send to relays with active connections; queue the rest // Add to queue for reliability
var stillPending = Set<String>() messageQueueLock.lock()
messageQueue.append((event, targetRelays))
messageQueueLock.unlock()
// Attempt immediate send
for relayUrl in targetRelays { for relayUrl in targetRelays {
if let connection = connections[relayUrl] { if let connection = connections[relayUrl] {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
} else {
stillPending.insert(relayUrl)
}
}
if !stillPending.isEmpty {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
messageQueueLock.unlock()
}
}
/// Try to flush any queued messages for relays that are now connected.
private func flushMessageQueue(for relayUrl: String? = nil) {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
guard !messageQueue.isEmpty else { return }
if let target = relayUrl {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connections[target] {
sendToRelay(event: item.event, connection: conn, relayUrl: target)
item.pendingRelays.remove(target)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
}
}
} else {
// Flush for any relays that now have connections
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
for url in item.pendingRelays {
if let conn = connections[url] {
sendToRelay(event: item.event, connection: conn, relayUrl: url)
item.pendingRelays.remove(url)
}
}
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
} }
} }
} }
@@ -174,6 +124,8 @@ class NostrRelayManager: ObservableObject {
let req = NostrRequest.subscribe(id: id, filters: [filter]) let req = NostrRequest.subscribe(id: id, filters: [filter])
do { do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys // For consistent output
let message = try encoder.encode(req) let message = try encoder.encode(req)
guard let messageString = String(data: message, encoding: .utf8) else { guard let messageString = String(data: message, encoding: .utf8) else {
SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error) SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error)
@@ -223,7 +175,7 @@ class NostrRelayManager: ObservableObject {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
let req = NostrRequest.close(id: id) let req = NostrRequest.close(id: id)
let message = try? encoder.encode(req) let message = try? JSONEncoder().encode(req)
guard let messageData = message, guard let messageData = message,
let messageString = String(data: messageData, encoding: .utf8) else { return } let messageString = String(data: messageData, encoding: .utf8) else { return }
@@ -290,20 +242,13 @@ class NostrRelayManager: ObservableObject {
case .success(let message): case .success(let message):
switch message { switch message {
case .string(let text): case .string(let text):
// Parse off-main to reduce UI jank, then hop back for state updates Task { @MainActor in
Task.detached(priority: .utility) { self.handleMessage(text, from: relayUrl)
guard let parsed = parseInboundMessage(text) else { return }
await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
} }
case .data(let data): case .data(let data):
if let text = String(data: data, encoding: .utf8) { if let text = String(data: data, encoding: .utf8) {
Task.detached(priority: .utility) { Task { @MainActor in
guard let parsed = parseInboundMessage(text) else { return } self.handleMessage(text, from: relayUrl)
await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
} }
} }
@unknown default: @unknown default:
@@ -323,31 +268,57 @@ class NostrRelayManager: ObservableObject {
} }
} }
// Parsed inbound message type (off-main) private func handleMessage(_ message: String, from relayUrl: String) {
// Note: declared at file scope below to avoid MainActor isolation inside this class guard let data = message.data(using: .utf8) else { return }
// and keep parsing off the main actor.
// Handle parsed message on MainActor (state updates and handlers) do {
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { // Try to decode as an array first
switch parsed { if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
case .event(let subId, let event): array.count >= 2,
let type = array[0] as? String {
// Received message from relay
switch type {
case "EVENT":
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any] {
let event = try NostrEvent(from: eventDict)
// Only log non-gift-wrap events to reduce noise
if event.kind != 1059 { if event.kind != 1059 {
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
} }
DispatchQueue.main.async {
// Update relay stats
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1 self.relays[index].messagesReceived += 1
} }
// Call handler
if let handler = self.messageHandlers[subId] { if let handler = self.messageHandlers[subId] {
handler(event) handler(event)
} else { } else {
SecureLogger.log("⚠️ No handler for subscription \(subId)", SecureLogger.log("⚠️ No handler for subscription \(subId)",
category: SecureLogger.session, level: .warning) category: SecureLogger.session, level: .warning)
} }
case .eose: }
// No-op for now }
break
case .ok(let eventId, let success, let reason): case "EOSE":
if array.count >= 2 {
// End of stored events
}
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
if success { if success {
_ = Self.pendingGiftWrapIDs.remove(eventId) _ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
@@ -357,8 +328,19 @@ class NostrRelayManager: ObservableObject {
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
category: SecureLogger.session, level: isGiftWrap ? .warning : .error) category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
} }
case .notice: }
break
case "NOTICE":
if array.count >= 2 {
// Server notice received
}
default:
break // Unknown message type
}
}
} catch {
SecureLogger.log("Failed to parse Nostr message: \(error)", category: SecureLogger.session, level: .error)
} }
} }
@@ -366,6 +348,8 @@ class NostrRelayManager: ObservableObject {
let req = NostrRequest.event(event) let req = NostrRequest.event(event)
do { do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
let data = try encoder.encode(req) let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? "" let message = String(data: data, encoding: .utf8) ?? ""
@@ -405,10 +389,6 @@ class NostrRelayManager: ObservableObject {
} }
} }
updateConnectionStatus() updateConnectionStatus()
// If we just connected to this relay, flush any queued sends targeting it
if isConnected {
flushMessageQueue(for: url)
}
} }
private func updateConnectionStatus() { private func updateConnectionStatus() {
@@ -514,51 +494,6 @@ class NostrRelayManager: ObservableObject {
} }
} }
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
private enum ParsedInbound {
case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String)
case eose(subscriptionId: String)
case notice(String)
}
// Off-main JSON parse to avoid UI jank; pure function, not actor-isolated
private func parseInboundMessage(_ message: String) -> ParsedInbound? {
guard let data = message.data(using: .utf8) else { return nil }
do {
if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String {
switch type {
case "EVENT":
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any] {
let event = try NostrEvent(from: eventDict)
return .event(subId: subId, event: event)
}
case "EOSE":
if let subId = array[1] as? String { return .eose(subscriptionId: subId) }
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
return .ok(eventId: eventId, success: success, reason: reason)
}
case "NOTICE":
if array.count >= 2, let msg = array[1] as? String { return .notice(msg) }
default:
return nil
}
}
} catch {
// Ignore
}
return nil
}
// MARK: - Nostr Protocol Types // MARK: - Nostr Protocol Types
enum NostrRequest: Encodable { enum NostrRequest: Encodable {
+9 -8
View File
@@ -40,23 +40,23 @@ extension Data {
extension Data { extension Data {
// MARK: Writing // MARK: Writing
@inlinable mutating func appendUInt8(_ value: UInt8) { mutating func appendUInt8(_ value: UInt8) {
self.append(value) self.append(value)
} }
@inlinable mutating func appendUInt16(_ value: UInt16) { mutating func appendUInt16(_ value: UInt16) {
self.append(UInt8((value >> 8) & 0xFF)) self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 0xFF)) self.append(UInt8(value & 0xFF))
} }
@inlinable mutating func appendUInt32(_ value: UInt32) { mutating func appendUInt32(_ value: UInt32) {
self.append(UInt8((value >> 24) & 0xFF)) self.append(UInt8((value >> 24) & 0xFF))
self.append(UInt8((value >> 16) & 0xFF)) self.append(UInt8((value >> 16) & 0xFF))
self.append(UInt8((value >> 8) & 0xFF)) self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 0xFF)) self.append(UInt8(value & 0xFF))
} }
@inlinable mutating func appendUInt64(_ value: UInt64) { mutating func appendUInt64(_ value: UInt64) {
for i in (0..<8).reversed() { for i in (0..<8).reversed() {
self.append(UInt8((value >> (i * 8)) & 0xFF)) self.append(UInt8((value >> (i * 8)) & 0xFF))
} }
@@ -113,21 +113,21 @@ extension Data {
// MARK: Reading // MARK: Reading
@inlinable func readUInt8(at offset: inout Int) -> UInt8? { func readUInt8(at offset: inout Int) -> UInt8? {
guard offset >= 0 && offset < self.count else { return nil } guard offset >= 0 && offset < self.count else { return nil }
let value = self[offset] let value = self[offset]
offset += 1 offset += 1
return value return value
} }
@inlinable func readUInt16(at offset: inout Int) -> UInt16? { func readUInt16(at offset: inout Int) -> UInt16? {
guard offset + 2 <= self.count else { return nil } guard offset + 2 <= self.count else { return nil }
let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1]) let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])
offset += 2 offset += 2
return value return value
} }
@inlinable func readUInt32(at offset: inout Int) -> UInt32? { func readUInt32(at offset: inout Int) -> UInt32? {
guard offset + 4 <= self.count else { return nil } guard offset + 4 <= self.count else { return nil }
let value = UInt32(self[offset]) << 24 | let value = UInt32(self[offset]) << 24 |
UInt32(self[offset + 1]) << 16 | UInt32(self[offset + 1]) << 16 |
@@ -137,7 +137,7 @@ extension Data {
return value return value
} }
@inlinable func readUInt64(at offset: inout Int) -> UInt64? { func readUInt64(at offset: inout Int) -> UInt64? {
guard offset + 8 <= self.count else { return nil } guard offset + 8 <= self.count else { return nil }
var value: UInt64 = 0 var value: UInt64 = 0
for i in 0..<8 { for i in 0..<8 {
@@ -220,3 +220,4 @@ extension Data {
return data return data
} }
} }
+95 -70
View File
@@ -139,11 +139,6 @@ struct BinaryProtocol {
} }
// Header // Header
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
data.reserveCapacity(estimated)
data.append(packet.version) data.append(packet.version)
data.append(packet.type) data.append(packet.type)
data.append(packet.ttl) data.append(packet.ttl)
@@ -227,106 +222,136 @@ struct BinaryProtocol {
// Core decoding implementation used by decode(_:) with and without padding removal // Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? { private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size: header + senderID // Minimum size check: header + senderID
guard raw.count >= headerSize + senderIDSize else { return nil } guard raw.count >= headerSize + senderIDSize else {
return nil
}
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in // Convert to array for safer indexed access
guard let base = buf.baseAddress else { return nil } let dataArray = Array(raw)
var offset = 0 var offset = 0
func require(_ n: Int) -> Bool { offset + n <= buf.count }
// Read single byte // Header parsing with bounds checks
func read8() -> UInt8? { guard offset < dataArray.count else { return nil }
guard require(1) else { return nil } let version = dataArray[offset]; offset += 1
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
offset += 1 // Check if version is 1 (only supported version)
return v guard version == 1 else {
} return nil
// Read big-endian 16-bit
func read16() -> UInt16? {
guard require(2) else { return nil }
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
offset += 2
return v
}
// Copy N bytes into Data
func readData(_ n: Int) -> Data? {
guard require(n) else { return nil }
let ptr = base.advanced(by: offset)
let d = Data(bytes: ptr, count: n)
offset += n
return d
} }
// Version guard offset < dataArray.count else { return nil }
guard let version = read8(), version == 1 else { return nil } let type = dataArray[offset]; offset += 1
guard let type = read8() else { return nil }
guard let ttl = read8() else { return nil }
// Timestamp 8 bytes BE guard offset < dataArray.count else { return nil }
guard require(8) else { return nil } let ttl = dataArray[offset]; offset += 1
var ts: UInt64 = 0
for _ in 0..<8 { // Timestamp - need 8 bytes
guard let b = read8() else { return nil } guard offset + 8 <= dataArray.count else { return nil }
ts = (ts << 8) | UInt64(b) let timestampData = Data(dataArray[offset..<offset+8])
let timestamp = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
} }
offset += 8
// Flags // Flags
guard let flags = read8() else { return nil } guard offset < dataArray.count else { return nil }
let flags = dataArray[offset]; offset += 1
let hasRecipient = (flags & Flags.hasRecipient) != 0 let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0 let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0 let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length // Payload length - need 2 bytes
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil } guard offset + 2 <= dataArray.count else { return nil }
let payloadLengthData = Data(dataArray[offset..<offset+2])
let payloadLength = payloadLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
}
offset += 2
// SenderID // Validate payloadLength is reasonable (prevent integer overflow)
guard let senderID = readData(senderIDSize) else { return nil } guard payloadLength <= 65535 else { return nil }
// Recipient // SenderID - need 8 bytes
var recipientID: Data? = nil guard offset + senderIDSize <= dataArray.count else { return nil }
let senderID = Data(dataArray[offset..<offset+senderIDSize])
offset += senderIDSize
// RecipientID if present
var recipientID: Data?
if hasRecipient { if hasRecipient {
recipientID = readData(recipientIDSize) guard offset + recipientIDSize <= dataArray.count else { return nil }
if recipientID == nil { return nil } recipientID = Data(dataArray[offset..<offset+recipientIDSize])
offset += recipientIDSize
} }
// Payload // Payload handling with comprehensive bounds checking
let payload: Data let payload: Data
if isCompressed { if isCompressed {
// Need original size (2 bytes) // Compressed payload needs at least 2 bytes for original size
guard let origSize16 = read16() else { return nil } guard Int(payloadLength) >= 2 else { return nil }
let originalSize = Int(origSize16)
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil } // Check we have enough data for the original size prefix
let compSize = Int(payloadLen) - 2 guard offset + 2 <= dataArray.count else { return nil }
guard compSize >= 0, let compressed = readData(compSize) else { return nil } let originalSizeData = Data(dataArray[offset..<offset+2])
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize), let originalSize = Int(originalSizeData.reduce(0) { result, byte in
decompressed.count == originalSize else { return nil } (result << 8) | UInt16(byte)
payload = decompressed })
offset += 2
// Validate original size is reasonable
guard originalSize >= 0 && originalSize <= 1048576 else { return nil } // Max 1MB
// Check we have enough data for the compressed payload
let compressedPayloadSize = Int(payloadLength) - 2
guard compressedPayloadSize >= 0 && offset + compressedPayloadSize <= dataArray.count else {
return nil
}
let compressedPayload = Data(dataArray[offset..<offset+compressedPayloadSize])
offset += compressedPayloadSize
// Decompress with error handling
guard let decompressedPayload = CompressionUtil.decompress(compressedPayload, originalSize: originalSize) else {
return nil
}
// Verify decompressed size matches expected
guard decompressedPayload.count == originalSize else {
return nil
}
payload = decompressedPayload
} else { } else {
guard let p = readData(Int(payloadLen)) else { return nil } // Uncompressed payload
payload = p guard Int(payloadLength) >= 0 && offset + Int(payloadLength) <= dataArray.count else {
return nil
}
payload = Data(dataArray[offset..<offset+Int(payloadLength)])
offset += Int(payloadLength)
} }
// Signature // Signature if present
var signature: Data? = nil var signature: Data?
if hasSignature { if hasSignature {
signature = readData(signatureSize) guard offset + signatureSize <= dataArray.count else { return nil }
if signature == nil { return nil } signature = Data(dataArray[offset..<offset+signatureSize])
offset += signatureSize
} }
guard offset <= buf.count else { return nil } // Final validation: ensure we haven't gone past the end
guard offset <= dataArray.count else { return nil }
return BitchatPacket( return BitchatPacket(
type: type, type: type,
senderID: senderID, senderID: senderID,
recipientID: recipientID, recipientID: recipientID,
timestamp: ts, timestamp: timestamp,
payload: payload, payload: payload,
signature: signature, signature: signature,
ttl: ttl ttl: ttl
) )
} }
}
} }
// Binary encoding for BitchatMessage // Binary encoding for BitchatMessage
-3
View File
@@ -15,8 +15,6 @@ struct AnnouncementPacket {
func encode() -> Data? { func encode() -> Data? {
var data = Data() var data = Data()
// Reserve: TLVs for nickname (2 + n), noise key (2 + 32), signing key (2 + 32)
data.reserveCapacity(2 + min(nickname.count, 255) + 2 + noisePublicKey.count + 2 + signingPublicKey.count)
// TLV for nickname // TLV for nickname
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil } guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
@@ -90,7 +88,6 @@ struct PrivateMessagePacket {
func encode() -> Data? { func encode() -> Data? {
var data = Data() var data = Data()
data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255))
// TLV for messageID // TLV for messageID
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil } guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
+36 -88
View File
@@ -63,9 +63,8 @@ final class BLEService: NSObject {
private let messageDeduplicator = MessageDeduplicator() private let messageDeduplicator = MessageDeduplicator()
// 5. Fragment Reassembly (necessary for messages > MTU) // 5. Fragment Reassembly (necessary for messages > MTU)
private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 } private var incomingFragments: [String: [Int: Data]] = [:]
private var incomingFragments: [FragmentKey: [Int: Data]] = [:] private var fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)] = [:]
private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:]
// Backoff for peripherals that recently timed out connecting // Backoff for peripherals that recently timed out connecting
private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout
@@ -89,7 +88,6 @@ final class BLEService: NSObject {
var myPeerID: String = "" var myPeerID: String = ""
var myNickname: String = "anon" var myNickname: String = "anon"
private let noiseService = NoiseEncryptionService() private let noiseService = NoiseEncryptionService()
private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy // MARK: - Advertising Privacy
// No Local Name by default for maximum privacy. No rotating alias. // No Local Name by default for maximum privacy. No rotating alias.
@@ -225,30 +223,7 @@ final class BLEService: NSObject {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse) peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} else { } else {
self.collectionsQueue.async(flags: .barrier) { self.collectionsQueue.async(flags: .barrier) {
var queue = self.pendingPeripheralWrites[uuid] ?? [] self.pendingPeripheralWrites[uuid, default: []].append(data)
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
let newSize = data.count
// If single chunk exceeds cap, drop it immediately
if newSize > capBytes {
SecureLogger.log("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)",
category: SecureLogger.session, level: .warning)
} else {
// Append and trim from the front to respect cap
var total = queue.reduce(0) { $0 + $1.count }
queue.append(data)
total += newSize
if total > capBytes {
var removedBytes = 0
while total > capBytes && !queue.isEmpty {
let removed = queue.removeFirst()
removedBytes += removed.count
total -= removed.count
}
SecureLogger.log("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B",
category: SecureLogger.session, level: .warning)
}
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
}
} }
} }
} }
@@ -333,7 +308,6 @@ final class BLEService: NSObject {
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars) // Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars)
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
self.myPeerID = String(fingerprint.prefix(16)) self.myPeerID = String(fingerprint.prefix(16))
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
// Set queue key for identification // Set queue key for identification
messageQueue.setSpecific(key: messageQueueKey, value: ()) messageQueue.setSpecific(key: messageQueueKey, value: ())
@@ -400,7 +374,7 @@ final class BLEService: NSObject {
maintenanceTimer = timer maintenanceTimer = timer
// Publish initial empty state // Publish initial empty state
requestPeerDataPublish() publishFullPeerData()
} }
func setNickname(_ nickname: String) { func setNickname(_ nickname: String) {
@@ -489,7 +463,7 @@ final class BLEService: NSObject {
// Send leave message synchronously to ensure delivery // Send leave message synchronously to ensure delivery
let leavePacket = BitchatPacket( let leavePacket = BitchatPacket(
type: MessageType.leave.rawValue, type: MessageType.leave.rawValue,
senderID: myPeerIDData, senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(), payload: Data(),
@@ -617,7 +591,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData, senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -669,7 +643,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(typedPayload, for: peerID) let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData, senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -826,7 +800,7 @@ final class BLEService: NSObject {
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData, senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: recipientData, recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -880,7 +854,7 @@ final class BLEService: NSObject {
// Send handshake init // Send handshake init
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseHandshake.rawValue, type: MessageType.noiseHandshake.rawValue,
senderID: myPeerIDData, senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: handshakeData, payload: handshakeData,
@@ -930,7 +904,7 @@ final class BLEService: NSObject {
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData, senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -1273,11 +1247,8 @@ final class BLEService: NSObject {
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type // Minimum header: 8 bytes ID + 2 index + 2 total + 1 type
guard packet.payload.count >= 13 else { return } guard packet.payload.count >= 13 else { return }
// Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian let senderHex = packet.senderID.hexEncodedString()
var senderU64: UInt64 = 0 let fragmentID = packet.payload[0..<8].map { String(format: "%02x", $0) }.joined()
for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) }
var fragU64: UInt64 = 0
for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) }
// Parse big-endian UInt16 safely without alignment assumptions // Parse big-endian UInt16 safely without alignment assumptions
let idxHi = UInt16(packet.payload[8]) let idxHi = UInt16(packet.payload[8])
let idxLo = UInt16(packet.payload[9]) let idxLo = UInt16(packet.payload[9])
@@ -1292,7 +1263,7 @@ final class BLEService: NSObject {
guard total > 0 && index >= 0 && index < total else { return } guard total > 0 && index >= 0 && index < total else { return }
// Store fragment // Store fragment
let key = FragmentKey(sender: senderU64, id: fragU64) let key = "\(senderHex):\(fragmentID)"
if incomingFragments[key] == nil { if incomingFragments[key] == nil {
// Cap in-flight assemblies to prevent memory/battery blowups // Cap in-flight assemblies to prevent memory/battery blowups
if incomingFragments.count >= maxInFlightAssemblies { if incomingFragments.count >= maxInFlightAssemblies {
@@ -1465,20 +1436,6 @@ final class BLEService: NSObject {
// Suppress announce logs to reduce noise // Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention
let existingPeerForVerify = collectionsQueue.sync { peers[peerID] }
var verifiedAnnounce = false
if packet.signature != nil {
verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
if !verifiedAnnounce {
SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
}
}
if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey {
SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning)
verifiedAnnounce = false
}
// Track if this is a new or reconnected peer // Track if this is a new or reconnected peer
var isNewPeer = false var isNewPeer = false
var isReconnectedPeer = false var isReconnectedPeer = false
@@ -1503,8 +1460,21 @@ final class BLEService: NSObject {
isNewPeer = (existingPeer == nil) isNewPeer = (existingPeer == nil)
isReconnectedPeer = wasDisconnected isReconnectedPeer = wasDisconnected
// Use precomputed verification result // Verify packet signature using the announced signing public key
let verified = verifiedAnnounce var verified = false
if packet.signature != nil {
// Verify that the packet was signed by the signing private key corresponding to the announced signing public key
verified = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
if !verified {
SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
}
}
// If existing peer has a different noise public key, do not consider this verified
if let existing = existingPeer, let existingKey = existing.noisePublicKey, existingKey != announcement.noisePublicKey {
SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning)
verified = false
}
// Require verified announce; ignore otherwise (no backward compatibility) // Require verified announce; ignore otherwise (no backward compatibility)
if !verified { if !verified {
@@ -1595,7 +1565,7 @@ final class BLEService: NSObject {
self.delegate?.didConnectToPeer(peerID) self.delegate?.didConnectToPeer(peerID)
} }
self.requestPeerDataPublish() self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
@@ -1696,7 +1666,7 @@ final class BLEService: NSObject {
// Send response // Send response
let responsePacket = BitchatPacket( let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue, type: MessageType.noiseHandshake.rawValue,
senderID: myPeerIDData, senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: response, payload: response,
@@ -1852,7 +1822,7 @@ final class BLEService: NSObject {
// Create packet with signature using the noise private key // Create packet with signature using the noise private key
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.announce.rawValue, type: MessageType.announce.rawValue,
senderID: myPeerIDData, senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
@@ -1886,7 +1856,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData, senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -1923,7 +1893,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData, senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -1990,28 +1960,6 @@ final class BLEService: NSObject {
} }
} }
// Debounced publish to coalesce rapid changes
private var lastPeerPublishAt: Date = .distantPast
private var peerPublishPending: Bool = false
private let peerPublishMinInterval: TimeInterval = 0.1
private func requestPeerDataPublish() {
let now = Date()
let elapsed = now.timeIntervalSince(lastPeerPublishAt)
if elapsed >= peerPublishMinInterval {
lastPeerPublishAt = now
publishFullPeerData()
} else if !peerPublishPending {
peerPublishPending = true
let delay = peerPublishMinInterval - elapsed
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self else { return }
self.lastPeerPublishAt = Date()
self.peerPublishPending = false
self.publishFullPeerData()
}
}
}
// MARK: - Consolidated Maintenance // MARK: - Consolidated Maintenance
private func performMaintenance() { private func performMaintenance() {
@@ -2124,7 +2072,7 @@ final class BLEService: NSObject {
self.delegate?.didDisconnectFromPeer(peerID) self.delegate?.didDisconnectFromPeer(peerID)
} }
// Publish snapshots so UnifiedPeerService updates connection/reachability icons // Publish snapshots so UnifiedPeerService updates connection/reachability icons
self.requestPeerDataPublish() self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -2506,7 +2454,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
if let peerID = peerID { if let peerID = peerID {
self.notifyPeerDisconnectedDebounced(peerID) self.notifyPeerDisconnectedDebounced(peerID)
} }
self.requestPeerDataPublish() self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -2907,7 +2855,7 @@ extension BLEService: CBPeripheralManagerDelegate {
self.notifyPeerDisconnectedDebounced(peerID) self.notifyPeerDisconnectedDebounced(peerID)
// Publish snapshots so UnifiedPeerService can refresh icons promptly // Publish snapshots so UnifiedPeerService can refresh icons promptly
self.requestPeerDataPublish() self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
+5 -20
View File
@@ -89,20 +89,11 @@ final class MessageRouter {
// MARK: - Outbox Management // MARK: - Outbox Management
private func canSendViaNostr(peerID: String) -> Bool { private func canSendViaNostr(peerID: String) -> Bool {
// Two forms are supported: guard let noiseKey = Data(hexString: peerID) else { return false }
// - 64-hex Noise public key (32 bytes)
// - 16-hex short peer ID (derived from Noise pubkey)
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil { fav.peerNostrPublicKey != nil {
return true return true
} }
} else if peerID.count == 16 {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
fav.peerNostrPublicKey != nil {
return true
}
}
return false return false
} }
@@ -110,7 +101,6 @@ final class MessageRouter {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists // Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued { for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
@@ -122,19 +112,14 @@ final class MessageRouter {
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else { } else {
// Keep unsent items queued continue
remaining.append((content, nickname, messageID))
} }
} }
// Persist only items we could not send // Remove all flushed items (remaining ones, if any, will be re-queued on next call)
if remaining.isEmpty { outbox[peerID]?.removeAll()
outbox.removeValue(forKey: peerID)
} else {
outbox[peerID] = remaining
}
} }
func flushAllOutbox() { func flushAllOutbox() {
for key in Array(outbox.keys) { flushOutbox(for: key) } for key in outbox.keys { flushOutbox(for: key) }
} }
} }
+1 -7
View File
@@ -27,7 +27,6 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private var peerIndex: [String: BitchatPeer] = [:] private var peerIndex: [String: BitchatPeer] = [:]
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
private let meshService: Transport private let meshService: Transport
weak var messageRouter: MessageRouter?
private let favoritesService = FavoritesPersistenceService.shared private let favoritesService = FavoritesPersistenceService.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -331,13 +330,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// Send favorite notification to the peer via router (mesh or Nostr) // Send favorite notification to the peer
if let router = messageRouter {
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
} else {
// Fallback to mesh-only if router not yet wired
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite) meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
}
// Force update of peers to reflect the change // Force update of peers to reflect the change
updatePeers() updatePeers()
+7 -21
View File
@@ -9,7 +9,6 @@ final class MessageDeduplicator {
} }
private var entries: [Entry] = [] private var entries: [Entry] = []
private var head: Int = 0
private var lookup = Set<String>() private var lookup = Set<String>()
private let lock = NSLock() private let lock = NSLock()
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
@@ -29,18 +28,10 @@ final class MessageDeduplicator {
entries.append(Entry(messageID: messageID, timestamp: Date())) entries.append(Entry(messageID: messageID, timestamp: Date()))
lookup.insert(messageID) lookup.insert(messageID)
// Soft-cap and advance head by a chunk to avoid O(n) shifting if entries.count > maxCount {
if (entries.count - head) > maxCount { let toRemove = entries.prefix(100)
let removeCount = min(100, entries.count - head) toRemove.forEach { lookup.remove($0.messageID) }
for i in head..<(head + removeCount) { entries.removeFirst(100)
lookup.remove(entries[i].messageID)
}
head += removeCount
// Periodically compact to reclaim memory
if head > entries.count / 2 {
entries.removeFirst(head)
head = 0
}
} }
return false return false
@@ -70,7 +61,6 @@ final class MessageDeduplicator {
defer { lock.unlock() } defer { lock.unlock() }
entries.removeAll() entries.removeAll()
head = 0
lookup.removeAll() lookup.removeAll()
} }
@@ -88,13 +78,9 @@ final class MessageDeduplicator {
private func cleanupOldEntries() { private func cleanupOldEntries() {
let cutoff = Date().addingTimeInterval(-maxAge) let cutoff = Date().addingTimeInterval(-maxAge)
while head < entries.count, entries[head].timestamp < cutoff { while let first = entries.first, first.timestamp < cutoff {
lookup.remove(entries[head].messageID) lookup.remove(first.messageID)
head += 1 entries.removeFirst()
}
if head > 0 && head > entries.count / 2 {
entries.removeFirst(head)
head = 0
} }
} }
} }
+4 -4
View File
@@ -140,11 +140,11 @@ class SecureLogger {
} }
/// Log general messages with automatic sensitive data filtering /// Log general messages with automatic sensitive data filtering
static func log(_ message: @autoclosure () -> String, category: OSLog = noise, level: LogLevel = .debug, static func log(_ message: String, category: OSLog = noise, level: LogLevel = .debug,
file: String = #file, line: Int = #line, function: String = #function) { file: String = #file, line: Int = #line, function: String = #function) {
guard shouldLog(level) else { return } guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize("\(location) \(message())") let sanitized = sanitize("\(location) \(message)")
#if DEBUG #if DEBUG
os_log("%{public}@", log: category, type: level.osLogType, sanitized) os_log("%{public}@", log: category, type: level.osLogType, sanitized)
@@ -157,10 +157,10 @@ class SecureLogger {
} }
/// Log errors with context /// Log errors with context
static func logError(_ error: Error, context: @autoclosure () -> String, category: OSLog = noise, static func logError(_ error: Error, context: String, category: OSLog = noise,
file: String = #file, line: Int = #line, function: String = #function) { file: String = #file, line: Int = #line, function: String = #function) {
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context()) let sanitized = sanitize(context)
let errorDesc = sanitize(error.localizedDescription) let errorDesc = sanitize(error.localizedDescription)
#if DEBUG #if DEBUG
+13 -68
View File
@@ -383,11 +383,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published var bluetoothAlertMessage = "" @Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown @Published var bluetoothState: CBManagerState = .unknown
// Presentation state for privacy gating
@Published var isLocationChannelsSheetPresented: Bool = false
@Published var isAppInfoPresented: Bool = false
@Published var showScreenshotPrivacyWarning: Bool = false
// Messages are naturally ephemeral - no persistent storage // Messages are naturally ephemeral - no persistent storage
// Persist mesh public timeline across channel switches // Persist mesh public timeline across channel switches
private var meshTimeline: [BitchatMessage] = [] private var meshTimeline: [BitchatMessage] = []
@@ -492,8 +487,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport) self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
// Route receipts from PrivateChatManager through MessageRouter // Route receipts from PrivateChatManager through MessageRouter
self.privateChatManager.messageRouter = self.messageRouter self.privateChatManager.messageRouter = self.messageRouter
// Allow UnifiedPeerService to route favorite notifications via mesh/Nostr
self.unifiedPeerService.messageRouter = self.messageRouter
self.autocompleteService = AutocompleteService() self.autocompleteService = AutocompleteService()
// Wire up dependencies // Wire up dependencies
@@ -1414,11 +1407,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
switch channel { switch channel {
case .mesh: case .mesh:
messages = meshTimeline messages = meshTimeline
// Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyMesh > 0 {
SecureLogger.log("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: SecureLogger.session, level: .debug)
}
stopGeoParticipantsTimer() stopGeoParticipantsTimer()
geohashPeople = [] geohashPeople = []
teleportedGeo.removeAll() teleportedGeo.removeAll()
@@ -1426,26 +1414,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Sanitize existing timeline (filter any prior empty-content entries) // Sanitize existing timeline (filter any prior empty-content entries)
var arr = geoTimelines[ch.geohash] ?? [] var arr = geoTimelines[ch.geohash] ?? []
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
// Deduplicate by ID while preserving order (from oldest to newest) // Ensure chronological order when returning to a geohash
if arr.count > 1 { if arr.count > 1 {
var seen = Set<String>() arr.sort { $0.timestamp < $1.timestamp }
var dedup: [BitchatMessage] = []
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(m.id) {
dedup.append(m)
seen.insert(m.id)
}
}
arr = dedup
} }
// Persist the cleaned/sorted timeline for this geohash // Persist the cleaned/sorted timeline for this geohash
geoTimelines[ch.geohash] = arr geoTimelines[ch.geohash] = arr
messages = arr messages = arr
// Debug: log if any empty messages are present post-sanitize
let emptyGeo = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyGeo > 0 {
SecureLogger.log("RenderGuard: geohash \(ch.geohash) timeline has \(emptyGeo) empty messages after sanitize", category: SecureLogger.session, level: .debug)
}
} }
// Unsubscribe previous // Unsubscribe previous
if let sub = geoSubscriptionID { if let sub = geoSubscriptionID {
@@ -2613,17 +2588,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
@objc private func userDidTakeScreenshot() { @objc private func userDidTakeScreenshot() {
// Respect privacy: do not broadcast screenshots taken from non-chat sheets
if isLocationChannelsSheetPresented {
// Show a warning about sharing location screenshots publicly
showScreenshotPrivacyWarning = true
return
}
if isAppInfoPresented {
// Silently ignore screenshots of app info
return
}
// Send screenshot notification based on current context // Send screenshot notification based on current context
let screenshotMessage = "* \(nickname) took a screenshot *" let screenshotMessage = "* \(nickname) took a screenshot *"
@@ -2643,7 +2607,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} }
// Show local notification immediately as system message (only in chat) // Show local notification immediately as system message
let localNotification = BitchatMessage( let localNotification = BitchatMessage(
sender: "system", sender: "system",
content: "you took a screenshot", content: "you took a screenshot",
@@ -2694,7 +2658,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Show local notification immediately as system message (only in chat) // Show local notification immediately as system message
let localNotification = BitchatMessage( let localNotification = BitchatMessage(
sender: "system", sender: "system",
content: "you took a screenshot", content: "you took a screenshot",
@@ -3079,10 +3043,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let nsContent = contentText as NSString let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
let nsLen = nsContent.length let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
// Combine and sort all matches // Combine and sort all matches
var allMatches: [(range: NSRange, type: String)] = [] var allMatches: [(range: NSRange, type: String)] = []
@@ -3099,7 +3061,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (matchRange, matchType) in allMatches { for (matchRange, matchType) in allMatches {
// Add text before the match // Add text before the match
if let range = Range(matchRange, in: contentText) { if let range = Range(matchRange, in: contentText) {
if lastEndIndex < range.lowerBound {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound]) let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty { if !beforeText.isEmpty {
var normalStyle = AttributeContainer() var normalStyle = AttributeContainer()
@@ -3107,7 +3068,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
normalStyle.foregroundColor = isDark ? Color.white : Color.black normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle)) processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
} }
}
// Add the match with appropriate styling // Add the match with appropriate styling
let matchText = String(contentText[range]) let matchText = String(contentText[range])
@@ -3124,7 +3084,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle)) processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound } lastEndIndex = range.upperBound
} }
} }
@@ -3201,12 +3161,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// For extremely long content, render as plain text to avoid heavy regex/layout work, // For extremely long content, render as plain text to avoid heavy regex/layout work,
// unless the content includes Cashu tokens we want to chip-render below // unless the content includes Cashu tokens we want to chip-render below
// Compute NSString-backed length for regex/nsrange correctness with multi-byte characters
let nsContent = content as NSString
let nsLen = nsContent.length
let containsCashuEarly: Bool = { let containsCashuEarly: Bool = {
let rx = Regexes.quickCashuPresence let rx = Regexes.quickCashuPresence
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0 return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0
}() }()
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
var plainStyle = AttributeContainer() var plainStyle = AttributeContainer()
@@ -3224,6 +3181,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let lnurlRegex = Regexes.lnurl let lnurlRegex = Regexes.lnurl
let lightningSchemeRegex = Regexes.lightningScheme let lightningSchemeRegex = Regexes.lightningScheme
let detector = Regexes.linkDetector let detector = Regexes.linkDetector
let nsLen = content.count
let hasMentionsHint = content.contains("@") let hasMentionsHint = content.contains("@")
let hasHashtagsHint = content.contains("#") let hasHashtagsHint = content.contains("#")
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http") let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
@@ -3303,7 +3262,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (range, type) in allMatches { for (range, type) in allMatches {
// Add text before match // Add text before match
if let nsRange = Range(range, in: content) { if let nsRange = Range(range, in: content) {
if lastEnd < nsRange.lowerBound {
let beforeText = String(content[lastEnd..<nsRange.lowerBound]) let beforeText = String(content[lastEnd..<nsRange.lowerBound])
if !beforeText.isEmpty { if !beforeText.isEmpty {
var beforeStyle = AttributeContainer() var beforeStyle = AttributeContainer()
@@ -3316,7 +3274,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle)) result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
} }
}
// Add styled match // Add styled match
let matchText = String(content[nsRange]) let matchText = String(content[nsRange])
@@ -3423,12 +3380,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
result.append(AttributedString(matchText).mergingAttributes(matchStyle)) result.append(AttributedString(matchText).mergingAttributes(matchStyle))
} }
} }
// Advance lastEnd safely in case of overlaps
if lastEnd < nsRange.upperBound {
lastEnd = nsRange.upperBound lastEnd = nsRange.upperBound
} }
} }
}
// Add remaining text // Add remaining text
if lastEnd < content.endIndex { if lastEnd < content.endIndex {
@@ -3524,16 +3478,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Regular expression to find @mentions // Regular expression to find @mentions
let pattern = "@([\\p{L}0-9_]+)" let pattern = "@([\\p{L}0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: []) let regex = try? NSRegularExpression(pattern: pattern, options: [])
let nsContent = contentText as NSString let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
let nsLen = nsContent.length
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
var lastEndIndex = contentText.startIndex var lastEndIndex = contentText.startIndex
for match in matches { for match in matches {
// Add text before the mention // Add text before the mention
if let range = Range(match.range(at: 0), in: contentText) { if let range = Range(match.range(at: 0), in: contentText) {
if lastEndIndex < range.lowerBound {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound]) let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty { if !beforeText.isEmpty {
var normalStyle = AttributeContainer() var normalStyle = AttributeContainer()
@@ -3541,7 +3492,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
normalStyle.foregroundColor = isDark ? Color.white : Color.black normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle)) processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
} }
}
// Add the mention with highlight // Add the mention with highlight
let mentionText = String(contentText[range]) let mentionText = String(contentText[range])
@@ -3550,7 +3500,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
mentionStyle.foregroundColor = Color.orange mentionStyle.foregroundColor = Color.orange
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle)) processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound } lastEndIndex = range.upperBound
} }
} }
@@ -4748,9 +4698,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames // Allow optional disambiguation suffix '#abcd' for duplicate nicknames
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)" let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
let regex = try? NSRegularExpression(pattern: pattern, options: []) let regex = try? NSRegularExpression(pattern: pattern, options: [])
let nsContent = content as NSString let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let nsLen = nsContent.length
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
var mentions: [String] = [] var mentions: [String] = []
let peerNicknames = meshService.getPeerNicknames() let peerNicknames = meshService.getPeerNicknames()
@@ -5817,14 +5765,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if isGeo && finalMessage.sender != "system" { if isGeo && finalMessage.sender != "system" {
if let gh = currentGeohash { if let gh = currentGeohash {
var arr = geoTimelines[gh] ?? [] var arr = geoTimelines[gh] ?? []
// Dedup by message ID before appending to per-geohash timeline
if !arr.contains(where: { $0.id == finalMessage.id }) {
arr.append(finalMessage) arr.append(finalMessage)
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) } if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
geoTimelines[gh] = arr geoTimelines[gh] = arr
} }
} }
}
// Only add message to current timeline if it matches active channel or is system // Only add message to current timeline if it matches active channel or is system
let isSystem = finalMessage.sender == "system" let isSystem = finalMessage.sender == "system"
+1 -12
View File
@@ -165,8 +165,6 @@ struct ContentView: View {
} }
.sheet(isPresented: $showAppInfo) { .sheet(isPresented: $showAppInfo) {
AppInfoView() AppInfoView()
.onAppear { viewModel.isAppInfoPresented = true }
.onDisappear { viewModel.isAppInfoPresented = false }
} }
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
get: { viewModel.showingFingerprintFor != nil }, get: { viewModel.showingFingerprintFor != nil },
@@ -281,10 +279,8 @@ struct ContentView: View {
} }
}() }()
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
// Filter out empty/whitespace-only messages to avoid blank rows
let filteredItems = items.filter { !$0.message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
ForEach(filteredItems, id: \.uiID) { item in ForEach(items, id: \.uiID) { item in
let message = item.message let message = item.message
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned // Check if current user is mentioned
@@ -1186,13 +1182,6 @@ struct ContentView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) { .sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet) LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
}
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
Button("ok", role: .cancel) {}
} message: {
Text("screenshots of location channels will reveal your location. think before sharing publicly.")
} }
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
} }
+12 -7
View File
@@ -304,18 +304,23 @@ struct LocationChannelsSheet: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
let subtitleFull: String = { HStack(spacing: 0) {
if let name = subtitleName, !name.isEmpty { Text(subtitlePrefix)
return subtitlePrefix + "" + name
}
return subtitlePrefix
}()
Text(subtitleFull)
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
if let name = subtitleName {
Text("")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Text(name)
.font(.system(size: 12, design: .monospaced))
.fontWeight(subtitleNameBold ? .bold : .regular)
.foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
} }
}
}
Spacer() Spacer()
if isSelected { if isSelected {
Text("✔︎") Text("✔︎")