Compare commits

...
Author SHA1 Message Date
97fc21c23f Add localizable strings in show commands view (#828)
* Add localizable strings in show commands view n refactor code to remove duplicate string declarations.

* Modify struct to enum in separate file to Models/CommandsInfo.

* Add corrections code in refactory and enum.

* Correct code in command enum and ContentView.

* Dedicated CommandSuggestionsView to extract code

* Simplify CommandInfo + minor optimization

* Fix preview

---------

Co-authored-by: islam <2553451+qalandarov@users.noreply.github.com>
2025-10-29 22:09:43 +00:00
Jithin RenjiandGitHub 79bd4af912 Remove redundant conditional compilation directive (#863) 2025-10-29 16:02:34 +00:00
96c78ef5a2 Fix verification persistence on iOS when app backgrounds (#822)
* Fix verification persistence on iOS when app backgrounds (#785)

Verification status was not being saved when the iOS app entered background
state, causing verified contacts to lose their verification status after the
app was closed. This happened because iOS can terminate backgrounded apps
without calling applicationWillTerminate.

Changes:
- Add saveIdentityState() method to force-save identity data without stopping services
- Call saveIdentityState() when iOS app enters background state
- Refactor applicationWillTerminate() to use new method
- Fix selector name typo (appWillTerminate -> applicationWillTerminate)

The verification data is now properly persisted to encrypted keychain storage
whenever the app backgrounds, ensuring verification status persists across
app launches.

Fixes #785

* Save identity state during verification events

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-28 19:19:00 +01:00
01256f3041 Add source-based routing support (#862)
* Add source-based routing support

* include neighbors in ANNOUNCE

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2025-10-28 12:38:44 +01:00
2edbe2bbbd Extract public message pipeline from ChatViewModel (#870)
* Extract public timeline helpers and rate limiter

* Extract public message pipeline

* Fix Swift concurrency warnings

* Incorporate public chat review feedback

* Tighten nostr key removal loop

* Address review feedback

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-28 12:10:09 +01:00
jackandGitHub a91978c10e Extract public timeline helpers and rate limiter (#869) 2025-10-27 14:13:57 +01:00
14c4e586fc Improve background BLE stability and nearby alerts (#864)
* Harden background BLE restoration and notifications

* Guard BLE restoration paths to iOS

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-23 19:54:06 +02:00
83779240ae Prevent mesh self-sync duplicates (#856)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-23 10:41:28 +02:00
5034732515 Simplify validation, compression heuristics, and notification scheduling (#841)
* Simplify validation, compression heuristics, and notification scheduling

* Consolidate notification logic and add InputValidator monitoring

Follow-up improvements to address PR feedback:

1. Consolidate notification functions
   - Add interruptionLevel parameter to sendLocalNotification
   - Refactor sendNetworkAvailableNotification to use consolidated function
   - Removes 12 lines of duplicate code

2. Add monitoring to InputValidator
   - Log control character rejections for production monitoring
   - Privacy-preserving: logs length + count, not actual content
   - Uses .security category for proper log routing

3. Add comprehensive InputValidator tests
   - 28 test cases covering validation, control characters, unicode, edge cases
   - Ensures behavioral changes are well-tested and documented

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-22 12:33:37 +02:00
b6ce4fae43 Add type-aware REQUEST_SYNC sync rounds (#853)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-22 12:24:29 +02:00
98fa02cc16 Prevent duplicate action emote echoes (#852)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-21 13:47:24 +02:00
0812cafd55 Improve mesh media throughput (#845)
* Improve mesh media throughput

* Reserve media slots atomically

* Prioritize small fragment trains

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-21 12:53:54 +02:00
IslamandGitHub 450955525c No fallback mime-type + remove dead code (#844) 2025-10-20 01:50:33 +02:00
IslamandGitHub 475bc70c71 Extract MimeTypes into a separate file + add tests (#843) 2025-10-20 01:21:23 +02:00
IslamandGitHub af6136c01c Minor cleanup (#842) 2025-10-20 01:11:44 +02:00
b839ce5f6c PeerID 31/n: Remove String interop to be explicit (#840)
* PeerID 28/n: `ChatViewModel.getShortIDForNoiseKey`

* PeerID 29/n: `BLEService` + remove dupe funcs from #823

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

* PeerID 30/n: Update some leftovers

* `lowercased()` inside PeerID for normalization

* PeerID 31/n: Remove String interop to be explicit

* MockBLEService: Remove direct target delivery

This causes a delivery even if the sender and receiver are not connected

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-19 20:50:44 +02:00
0776c9813c PeerID 30/n: Update some leftovers + case normalization (#839)
* PeerID 28/n: `ChatViewModel.getShortIDForNoiseKey`

* PeerID 29/n: `BLEService` + remove dupe funcs from #823

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

* PeerID 30/n: Update some leftovers

* `lowercased()` inside PeerID for normalization

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-19 20:40:45 +02:00
0dd999af6b PeerID 29/n: BLEService + remove dupe funcs from #823 (#838)
* PeerID 28/n: `ChatViewModel.getShortIDForNoiseKey`

* PeerID 29/n: `BLEService` + remove dupe funcs from #823

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-19 20:35:41 +02:00
IslamandGitHub c3a1af7023 PeerID 28/n: ChatViewModel.getShortIDForNoiseKey (#836) 2025-10-19 20:30:10 +02:00
880813f256 Fix GeoRelay prefetch isolation (#837)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-19 17:24:17 +01:00
64fb634166 Fix camera icon in DM sheets using parent-child presentation pattern (#834)
Implements mutually-exclusive image picker presentations to fix the error
"Currently, only presenting a single sheet is supported" when tapping
camera in a DM.

Solution:
- ContentView: Only presents image picker when NOT in a sheet
  (when showSidebar=false and no private chat active)
- peopleSheetView: Only presents image picker when IN a sheet
  (when showSidebar=true or private chat active)

This ensures only ONE presentation is active at any time, preventing
conflicts. Uses fullScreenCover on iOS to allow presentation over sheets.

Addresses feedback from @qalandarov in PR #834 with minimal approach.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-19 15:23:05 +02:00
46 changed files with 3349 additions and 2003 deletions
@@ -98,8 +98,8 @@
</BuildableProductRunnable> </BuildableProductRunnable>
<EnvironmentVariables> <EnvironmentVariables>
<EnvironmentVariable <EnvironmentVariable
key = "-DBITCHAT_DEV_ALLOW_CLEARNET" key = "BITCHAT_LOG_LEVEL"
value = "" value = "debug"
isEnabled = "YES"> isEnabled = "YES">
</EnvironmentVariable> </EnvironmentVariable>
</EnvironmentVariables> </EnvironmentVariables>
+7 -5
View File
@@ -57,11 +57,9 @@ struct BitchatApp: App {
let npub = try? idBridge.getCurrentNostrIdentity()?.npub let npub = try? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub) _ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
} }
#if os(iOS)
appDelegate.chatViewModel = chatViewModel appDelegate.chatViewModel = chatViewModel
#elseif os(macOS)
appDelegate.chatViewModel = chatViewModel
#endif
// Initialize network activation policy; will start Tor/Nostr only when allowed // Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start() NetworkActivationService.shared.start()
// Check for shared content // Check for shared content
@@ -189,6 +187,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true return true
} }
func applicationWillTerminate(_ application: UIApplication) {
chatViewModel?.applicationWillTerminate()
}
} }
#endif #endif
@@ -246,7 +248,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
// Get peer ID from userInfo // Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String { if let peerID = userInfo["peerID"] as? String {
// Don't show notification if the private chat is already open // Don't show notification if the private chat is already open
if chatViewModel?.selectedPrivateChatPeer == peerID { if chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
completionHandler([]) completionHandler([])
return return
} }
+5 -5
View File
@@ -13,10 +13,10 @@ enum ImageUtilsError: Error {
} }
enum ImageUtils { enum ImageUtils {
private static let compressionQuality: CGFloat = 0.85 private static let compressionQuality: CGFloat = 0.82
private static let targetImageBytes: Int = 60_000 private static let targetImageBytes: Int = 45_000
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL { static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
// Security H1: Check file size BEFORE reading into memory // Security H1: Check file size BEFORE reading into memory
let attrs = try FileManager.default.attributesOfItem(atPath: url.path) let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attrs[.size] as? Int else { guard let fileSize = attrs[.size] as? Int else {
@@ -38,7 +38,7 @@ enum ImageUtils {
} }
#if os(iOS) #if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL { static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
return try autoreleasepool { return try autoreleasepool {
// Scale the image first // Scale the image first
let scaled = scaledImage(image, maxDimension: maxDimension) let scaled = scaledImage(image, maxDimension: maxDimension)
@@ -106,7 +106,7 @@ enum ImageUtils {
return data as Data return data as Data
} }
#else #else
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL { static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
return try autoreleasepool { return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension) let scaled = scaledImage(image, maxDimension: maxDimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else { guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+3 -2
View File
@@ -14,6 +14,7 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder") private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
private let paddingInterval: TimeInterval = 0.5 private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
private var recorder: AVAudioRecorder? private var recorder: AVAudioRecorder?
private var currentURL: URL? private var currentURL: URL?
@@ -75,14 +76,14 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
AVFormatIDKey: kAudioFormatMPEG4AAC, AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000, AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1, AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 20_000 AVEncoderBitRateKey: 16_000
] ]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings) let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.delegate = self audioRecorder.delegate = self
audioRecorder.isMeteringEnabled = true audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord() audioRecorder.prepareToRecord()
audioRecorder.record() audioRecorder.record(forDuration: maxRecordingDuration)
recorder = audioRecorder recorder = audioRecorder
currentURL = outputURL currentURL = outputURL
+6 -2
View File
@@ -21,8 +21,9 @@ struct BitchatPacket: Codable {
let payload: Data let payload: Data
var signature: Data? var signature: Data?
var ttl: UInt8 var ttl: UInt8
var route: [Data]?
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) { init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
self.version = version self.version = version
self.type = type self.type = type
self.senderID = senderID self.senderID = senderID
@@ -31,6 +32,7 @@ struct BitchatPacket: Codable {
self.payload = payload self.payload = payload
self.signature = signature self.signature = signature
self.ttl = ttl self.ttl = ttl
self.route = route
} }
// Convenience initializer for new binary format // Convenience initializer for new binary format
@@ -53,6 +55,7 @@ struct BitchatPacket: Codable {
self.payload = payload self.payload = payload
self.signature = nil self.signature = nil
self.ttl = ttl self.ttl = ttl
self.route = nil
} }
var data: Data? { var data: Data? {
@@ -81,7 +84,8 @@ struct BitchatPacket: Codable {
payload: payload, payload: payload,
signature: nil, // Remove signature for signing signature: nil, // Remove signature for signing
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
version: version version: version,
route: route
) )
return BinaryProtocol.encode(unsignedPacket) return BinaryProtocol.encode(unsignedPacket)
} }
+58
View File
@@ -0,0 +1,58 @@
//
// CommandsInfo.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
// MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable {
case block
case clear
case hug
case message = "dm"
case slap
case unblock
case who
case favorite
case unfavorite
var id: String { rawValue }
var alias: String { "/" + rawValue }
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .who:
return nil
}
}
var description: String {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap")
case .unblock: String(localized: "content.commands.unblock")
case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
if isGeoPublic || isGeoDM {
return baseCommands + [.favorite, .unfavorite]
}
return baseCommands
}
}
+22 -18
View File
@@ -35,7 +35,7 @@ struct PeerID: Equatable, Hashable {
// Private so the callers have to go through a convenience init // Private so the callers have to go through a convenience init
private init(prefix: Prefix, bare: any StringProtocol) { private init(prefix: Prefix, bare: any StringProtocol) {
self.prefix = prefix self.prefix = prefix
self.bare = String(bare) self.bare = String(bare).lowercased()
} }
} }
@@ -76,6 +76,12 @@ extension PeerID {
init(hexData: Data) { init(hexData: Data) {
self.init(str: hexData.hexEncodedString()) self.init(str: hexData.hexEncodedString())
} }
/// Convenience init to "hide" hex-encoding implementation detail
init?(hexData: Data?) {
guard let hexData else { return nil }
self.init(hexData: hexData)
}
} }
// MARK: - Noise Public Key Helpers // MARK: - Noise Public Key Helpers
@@ -130,6 +136,20 @@ extension PeerID {
} }
} }
extension PeerID {
var routingData: Data? {
if let direct = Data(hexString: id), direct.count == 8 { return direct }
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
let short = toShort()
return Data(hexString: short.id)
}
init?(routingData: Data) {
guard routingData.count == 8 else { return nil }
self.init(hexData: routingData)
}
}
// MARK: - Validation // MARK: - Validation
extension PeerID { extension PeerID {
@@ -191,9 +211,7 @@ extension PeerID: Comparable {
} }
} }
// MARK: - String Interop Helpers // MARK: - CustomStringConvertible
// MARK: CustomStringConvertible
extension PeerID: CustomStringConvertible { extension PeerID: CustomStringConvertible {
/// So it returns the actual `id` like before even inside another String /// So it returns the actual `id` like before even inside another String
@@ -201,17 +219,3 @@ extension PeerID: CustomStringConvertible {
id id
} }
} }
// MARK: Custom Equatable w/ String & Optionality
// PeerID <> String
extension Optional where Wrapped == PeerID {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
}
// String <> PeerID
extension Optional where Wrapped == String {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
}
+17 -1
View File
@@ -8,6 +8,14 @@ struct RequestSyncPacket {
let p: Int let p: Int
let m: UInt32 let m: UInt32
let data: Data let data: Data
let types: SyncTypeFlags?
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
self.p = p
self.m = m
self.data = data
self.types = types
}
func encode() -> Data { func encode() -> Data {
var out = Data() var out = Data()
@@ -25,6 +33,9 @@ struct RequestSyncPacket {
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) }) putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
// data // data
putTLV(0x03, data) putTLV(0x03, data)
if let typesData = types?.toData() {
putTLV(0x04, typesData)
}
return out return out
} }
@@ -33,6 +44,7 @@ struct RequestSyncPacket {
var p: Int? = nil var p: Int? = nil
var m: UInt32? = nil var m: UInt32? = nil
var payload: Data? = nil var payload: Data? = nil
var types: SyncTypeFlags? = nil
while off + 3 <= data.count { while off + 3 <= data.count {
let t = Int(data[off]); off += 1 let t = Int(data[off]); off += 1
@@ -52,12 +64,16 @@ struct RequestSyncPacket {
case 0x03: case 0x03:
if v.count > maxAcceptBytes { return nil } if v.count > maxAcceptBytes { return nil }
payload = v payload = v
case 0x04:
if let decoded = SyncTypeFlags.decode(v) {
types = decoded
}
default: default:
break // forward compatible; ignore unknown TLVs break // forward compatible; ignore unknown TLVs
} }
} }
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil } 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) return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
} }
} }
+12 -3
View File
@@ -284,7 +284,10 @@ final class GeoRelayDirectory {
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
self?.prefetchIfNeeded(force: true) guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded(force: true)
}
} }
observers.append(torReady) observers.append(torReady)
@@ -294,7 +297,10 @@ final class GeoRelayDirectory {
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
self?.prefetchIfNeeded() guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
} }
observers.append(didBecomeActive) observers.append(didBecomeActive)
#elseif os(macOS) #elseif os(macOS)
@@ -303,7 +309,10 @@ final class GeoRelayDirectory {
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
self?.prefetchIfNeeded() guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
} }
observers.append(didBecomeActive) observers.append(didBecomeActive)
#endif #endif
+51 -12
View File
@@ -137,6 +137,7 @@ struct BinaryProtocol {
static let hasRecipient: UInt8 = 0x01 static let hasRecipient: UInt8 = 0x01
static let hasSignature: UInt8 = 0x02 static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04 static let isCompressed: UInt8 = 0x04
static let hasRoute: UInt8 = 0x08
} }
// Encode BitchatPacket to binary format // Encode BitchatPacket to binary format
@@ -160,8 +161,21 @@ struct BinaryProtocol {
} }
let lengthFieldBytes = lengthFieldSize(for: version) let lengthFieldBytes = lengthFieldSize(for: version)
let originalRoute = packet.route ?? []
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
let sanitizedRoute: [Data] = originalRoute.map { hop in
if hop.count == senderIDSize { return hop }
if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) }
var padded = hop
padded.append(Data(repeating: 0, count: senderIDSize - hop.count))
return padded
}
guard sanitizedRoute.count <= 255 else { return nil }
let hasRoute = !sanitizedRoute.isEmpty
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0 let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
let payloadDataSize = payload.count + originalSizeFieldBytes let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil } if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil } if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
@@ -185,6 +199,7 @@ struct BinaryProtocol {
if packet.recipientID != nil { flags |= Flags.hasRecipient } if packet.recipientID != nil { flags |= Flags.hasRecipient }
if packet.signature != nil { flags |= Flags.hasSignature } if packet.signature != nil { flags |= Flags.hasSignature }
if isCompressed { flags |= Flags.isCompressed } if isCompressed { flags |= Flags.isCompressed }
if hasRoute { flags |= Flags.hasRoute }
data.append(flags) data.append(flags)
if version == 2 { if version == 2 {
@@ -212,6 +227,13 @@ struct BinaryProtocol {
} }
} }
if hasRoute {
data.append(UInt8(sanitizedRoute.count))
for hop in sanitizedRoute {
data.append(hop)
}
}
if isCompressed, let originalSize = originalPayloadSize { if isCompressed, let originalSize = originalPayloadSize {
if version == 2 { if version == 2 {
let value = UInt32(originalSize) let value = UInt32(originalSize)
@@ -321,9 +343,27 @@ struct BinaryProtocol {
if recipientID == nil { return nil } if recipientID == nil { return nil }
} }
var route: [Data]? = nil
var remainingPayloadBytes = payloadLength
if (flags & Flags.hasRoute) != 0 {
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
remainingPayloadBytes -= 1
if routeCount > 0 {
var hops: [Data] = []
for _ in 0..<Int(routeCount) {
guard remainingPayloadBytes >= senderIDSize,
let hop = readData(senderIDSize) else { return nil }
remainingPayloadBytes -= senderIDSize
hops.append(hop)
}
route = hops
}
}
let payload: Data let payload: Data
if isCompressed { if isCompressed {
guard payloadLength >= lengthFieldBytes else { return nil } guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
let originalSize: Int let originalSize: Int
if version == 2 { if version == 2 {
guard let rawSize = read32() else { return nil } guard let rawSize = read32() else { return nil }
@@ -332,16 +372,12 @@ struct BinaryProtocol {
guard let rawSize = read16() else { return nil } guard let rawSize = read16() else { return nil }
originalSize = Int(rawSize) originalSize = Int(rawSize)
} }
// Guard to keep decompression bounded to sane BLE payload limits remainingPayloadBytes -= lengthFieldBytes
// Use maxFramedFileBytes to account for TLV overhead in file transfer payloads
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil } guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
let compressedSize = payloadLength - lengthFieldBytes let compressedSize = remainingPayloadBytes
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil } guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
remainingPayloadBytes = 0
// Validate compression ratio to prevent zip bomb attacks
// Primary protection: originalSize capped at 1MB (line 336)
// Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation)
guard compressedSize > 0 else { return nil }
let compressionRatio = Double(originalSize) / Double(compressedSize) let compressionRatio = Double(originalSize) / Double(compressedSize)
guard compressionRatio <= 50_000.0 else { guard compressionRatio <= 50_000.0 else {
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security) SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
@@ -352,7 +388,9 @@ struct BinaryProtocol {
decompressed.count == originalSize else { return nil } decompressed.count == originalSize else { return nil }
payload = decompressed payload = decompressed
} else { } else {
guard let rawPayload = readData(payloadLength) else { return nil } guard remainingPayloadBytes >= 0,
let rawPayload = readData(remainingPayloadBytes) else { return nil }
remainingPayloadBytes = 0
payload = rawPayload payload = rawPayload
} }
@@ -372,7 +410,8 @@ struct BinaryProtocol {
payload: payload, payload: payload,
signature: signature, signature: signature,
ttl: ttl, ttl: ttl,
version: version version: version,
route: route
) )
} }
} }
+2 -2
View File
@@ -178,7 +178,7 @@ protocol BitchatDelegate: AnyObject {
// Bluetooth state updates for user notifications // Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState) func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
} }
// Provide default implementation to make it effectively optional // Provide default implementation to make it effectively optional
@@ -195,7 +195,7 @@ extension BitchatDelegate {
// Default empty implementation // Default empty implementation
} }
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
// Default empty implementation // Default empty implementation
} }
} }
+14
View File
@@ -116,4 +116,18 @@ enum ChannelID: Equatable, Codable {
case .location(let ch): return ch.geohash case .location(let ch): return ch.geohash
} }
} }
var isMesh: Bool {
switch self {
case .mesh: true
case .location: false
}
}
var isLocation: Bool {
switch self {
case .mesh: false
case .location: true
}
}
} }
+26 -1
View File
@@ -6,11 +6,13 @@ struct AnnouncementPacket {
let nickname: String let nickname: String
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
let signingPublicKey: Data // Ed25519 public key for signing let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs
private enum TLVType: UInt8 { private enum TLVType: UInt8 {
case nickname = 0x01 case nickname = 0x01
case noisePublicKey = 0x02 case noisePublicKey = 0x02
case signingPublicKey = 0x03 case signingPublicKey = 0x03
case directNeighbors = 0x04
} }
func encode() -> Data? { func encode() -> Data? {
@@ -35,6 +37,16 @@ struct AnnouncementPacket {
data.append(TLVType.signingPublicKey.rawValue) data.append(TLVType.signingPublicKey.rawValue)
data.append(UInt8(signingPublicKey.count)) data.append(UInt8(signingPublicKey.count))
data.append(signingPublicKey) data.append(signingPublicKey)
// TLV for direct neighbors (optional)
if let neighbors = directNeighbors, !neighbors.isEmpty {
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
data.append(TLVType.directNeighbors.rawValue)
data.append(UInt8(neighborsData.count))
data.append(neighborsData)
}
}
return data return data
} }
@@ -44,6 +56,7 @@ struct AnnouncementPacket {
var nickname: String? var nickname: String?
var noisePublicKey: Data? var noisePublicKey: Data?
var signingPublicKey: Data? var signingPublicKey: Data?
var directNeighbors: [Data]?
while offset + 2 <= data.count { while offset + 2 <= data.count {
let typeRaw = data[offset] let typeRaw = data[offset]
@@ -63,6 +76,17 @@ struct AnnouncementPacket {
noisePublicKey = Data(value) noisePublicKey = Data(value)
case .signingPublicKey: case .signingPublicKey:
signingPublicKey = Data(value) signingPublicKey = Data(value)
case .directNeighbors:
if length > 0 && length % 8 == 0 {
var neighbors = [Data]()
let count = length / 8
for i in 0..<count {
let start = value.startIndex + i * 8
let end = start + 8
neighbors.append(Data(value[start..<end]))
}
directNeighbors = neighbors
}
} }
} else { } else {
// Unknown TLV; skip (tolerant decoder for forward compatibility) // Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -74,7 +98,8 @@ struct AnnouncementPacket {
return AnnouncementPacket( return AnnouncementPacket(
nickname: nickname, nickname: nickname,
noisePublicKey: noisePublicKey, noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors
) )
} }
} }
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
//
// MimeType.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import UniformTypeIdentifiers
// MARK: - Extensions for missing UTTypes
extension UTType {
static let webP = UTType(importedAs: "image/webp")
static let aac = UTType(importedAs: "audio/aac")
static let m4a = UTType(importedAs: "audio/m4a")
static let ogg = UTType(importedAs: "audio/ogg")
}
// MARK: - MimeType Enum
enum MimeType: CaseIterable, Hashable {
case jpeg
case jpg
case png
case gif
case webp
case mp4Audio
case m4a
case aac
case mpeg
case mp3
case wav
case xWav
case ogg
case pdf
case octetStream
var utType: UTType {
switch self {
case .jpeg, .jpg: .jpeg
case .png: .png
case .gif: .gif
case .webp: .webP
case .aac: .aac
case .m4a: .m4a
case .mp4Audio: .mpeg4Audio
case .mp3, .mpeg: .mp3
case .wav, .xWav: .wav
case .ogg: .ogg
case .pdf: .pdf
case .octetStream: .data
}
}
var category: Category {
switch self {
case .jpeg, .jpg, .png, .gif, .webp:
return .image
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
return .audio
case .pdf, .octetStream:
return .file
}
}
var mimeString: String {
switch self {
case .jpeg, .jpg: "image/jpeg"
case .png: "image/png"
case .gif: "image/gif"
case .webp: "image/webp"
case .mp4Audio: "audio/mp4"
case .m4a: "audio/m4a"
case .aac: "audio/aac"
case .mpeg: "audio/mpeg"
case .mp3: "audio/mp3"
case .wav: "audio/wav"
case .xWav: "audio/x-wav"
case .ogg: "audio/ogg"
case .pdf: "application/pdf"
case .octetStream: "application/octet-stream"
}
}
var defaultExtension: String {
switch self {
case .jpeg, .jpg: "jpg"
case .png: "png"
case .webp: "webp"
case .gif: "gif"
case .mp4Audio, .m4a, .aac: "m4a"
case .mpeg, .mp3: "mp3"
case .wav, .xWav: "wav"
case .ogg: "ogg"
case .pdf: "pdf"
case .octetStream: "bin"
}
}
static var allowed: Set<MimeType> = [
.jpeg, .jpg, .png, .gif, .webp,
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
.wav, .xWav, .ogg,
.pdf, .octetStream
]
var isAllowed: Bool {
Self.allowed.contains(self)
}
// MARK: - Byte signature validation
func matches(data: Data) -> Bool {
guard !data.isEmpty else { return false }
// Generic type skip validation
if self == .octetStream { return true }
switch self {
case .jpeg, .jpg:
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
case .png:
return data.count >= 8 &&
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
case .gif:
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
case .webp:
return data.count >= 12 &&
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
case .m4a, .mp4Audio, .aac:
// AVAudioRecorder output varies by platform - be lenient
// Security: size already capped + sandboxed execution
return data.count > 100
case .mpeg, .mp3:
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
return true // ID3 header
}
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
case .wav, .xWav:
return data.count >= 12 &&
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
case .ogg:
return data.count >= 4 &&
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
case .pdf:
return data.count >= 4 &&
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
default:
return false
}
}
// MARK: - Convenience Initializers
init?(_ mimeString: String?) {
guard let mimeString else { return nil }
let normalized = mimeString.lowercased()
// Direct match with our canonical list
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
self = match
return
}
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
if let type = UTType(mimeType: normalized),
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
self = match
return
}
return nil
}
}
extension MimeType {
enum Category: String {
case audio, image, file
}
}
+120
View File
@@ -0,0 +1,120 @@
import Foundation
/// Tracks observed mesh topology and computes hop-by-hop routes.
final class MeshTopologyTracker {
private typealias RoutingID = Data
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
private let hopSize = 8
private var adjacency: [RoutingID: Set<RoutingID>] = [:]
func reset() {
queue.sync(flags: .barrier) {
self.adjacency.removeAll()
}
}
func recordDirectLink(between a: Data?, and b: Data?) {
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
queue.sync(flags: .barrier) {
var setA = self.adjacency[left] ?? []
setA.insert(right)
self.adjacency[left] = setA
var setB = self.adjacency[right] ?? []
setB.insert(left)
self.adjacency[right] = setB
}
}
func removeDirectLink(between a: Data?, and b: Data?) {
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
queue.sync(flags: .barrier) {
if var setA = self.adjacency[left] {
setA.remove(right)
self.adjacency[left] = setA.isEmpty ? nil : setA
}
if var setB = self.adjacency[right] {
setB.remove(left)
self.adjacency[right] = setB.isEmpty ? nil : setB
}
}
}
func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) {
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
for neighbor in neighbors {
if var set = self.adjacency[neighbor] {
set.remove(peer)
self.adjacency[neighbor] = set.isEmpty ? nil : set
}
}
}
}
func recordRoute(_ hops: [Data]) {
let sanitized = hops.compactMap { sanitize($0) }
guard sanitized.count >= 2 else { return }
queue.sync(flags: .barrier) {
for idx in 0..<(sanitized.count - 1) {
let left = sanitized[idx]
let right = sanitized[idx + 1]
guard left != right else { continue }
var setA = self.adjacency[left] ?? []
setA.insert(right)
self.adjacency[left] = setA
var setB = self.adjacency[right] ?? []
setB.insert(left)
self.adjacency[right] = setB
}
}
}
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
if source == target { return [source] }
let graph = queue.sync { adjacency }
guard graph[source] != nil, graph[target] != nil else { return nil }
var visited: Set<RoutingID> = [source]
var queuePaths: [[RoutingID]] = [[source]]
var index = 0
while index < queuePaths.count {
let path = queuePaths[index]
index += 1
guard path.count <= maxHops else { continue }
guard let last = path.last, let neighbors = graph[last] else { continue }
for neighbor in neighbors {
if visited.contains(neighbor) { continue }
var nextPath = path
nextPath.append(neighbor)
if neighbor == target { return nextPath }
if nextPath.count <= maxHops {
queuePaths.append(nextPath)
}
visited.insert(neighbor)
}
}
return nil
}
// MARK: - Helpers
private func sanitize(_ data: Data?) -> Data? {
guard var value = data, !value.isEmpty else { return nil }
if value.count > hopSize {
value = Data(value.prefix(hopSize))
} else if value.count < hopSize {
value.append(Data(repeating: 0, count: hopSize - value.count))
}
return value
}
}
@@ -177,18 +177,18 @@ final class NoiseEncryptionService {
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
// Callbacks // Callbacks
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
// Add a handler for peer authentication // Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) { func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
serviceQueue.async(flags: .barrier) { [weak self] in serviceQueue.async(flags: .barrier) { [weak self] in
self?.onPeerAuthenticatedHandlers.append(handler) self?.onPeerAuthenticatedHandlers.append(handler)
} }
} }
// Legacy support - setting this will add to the handlers array // Legacy support - setting this will add to the handlers array
var onPeerAuthenticated: ((String, String) -> Void)? { var onPeerAuthenticated: ((PeerID, String) -> Void)? {
get { nil } // Always return nil for backward compatibility get { nil } // Always return nil for backward compatibility
set { set {
if let handler = newValue { if let handler = newValue {
@@ -546,7 +546,7 @@ final class NoiseEncryptionService {
// Notify all handlers about authentication // Notify all handlers about authentication
serviceQueue.async { [weak self] in serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedHandlers.forEach { handler in self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID.id, fingerprint) handler(peerID, fingerprint)
} }
} }
} }
+32 -43
View File
@@ -29,28 +29,30 @@ final class NotificationService {
} }
} }
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) { func sendLocalNotification(
// For now, skip app state check entirely to avoid thread issues title: String,
// The NotificationDelegate will handle foreground presentation body: String,
DispatchQueue.main.async { identifier: String,
let content = UNMutableNotificationContent() userInfo: [String: Any]? = nil,
content.title = title interruptionLevel: UNNotificationInterruptionLevel = .active
content.body = body ) {
content.sound = .default let content = UNMutableNotificationContent()
if let userInfo = userInfo { content.title = title
content.userInfo = userInfo content.body = body
} content.sound = .default
content.interruptionLevel = interruptionLevel
let request = UNNotificationRequest(
identifier: identifier, if let userInfo = userInfo {
content: content, content.userInfo = userInfo
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
} }
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request)
} }
func sendMentionNotification(from sender: String, message: String) { func sendMentionNotification(from sender: String, message: String) {
@@ -61,11 +63,11 @@ final class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier) sendLocalNotification(title: title, body: body, identifier: identifier)
} }
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) { func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
let title = "🔒 DM from \(sender)" let title = "🔒 DM from \(sender)"
let body = message let body = message
let identifier = "private-\(UUID().uuidString)" let identifier = "private-\(UUID().uuidString)"
let userInfo = ["peerID": peerID, "senderName": sender] let userInfo = ["peerID": peerID.id, "senderName": sender]
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
} }
@@ -83,25 +85,12 @@ final class NotificationService {
let title = "👥 bitchatters nearby!" let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around" let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
let identifier = "network-available-\(Date().timeIntervalSince1970)" let identifier = "network-available-\(Date().timeIntervalSince1970)"
// For network notifications, we want to show them even in foreground sendLocalNotification(
// No app state check - let the notification delegate handle presentation title: title,
DispatchQueue.main.async { body: body,
let content = UNMutableNotificationContent() identifier: identifier,
content.title = title interruptionLevel: .timeSensitive
content.body = body )
content.sound = .default
content.interruptionLevel = .timeSensitive // Make it more prominent
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
}
} }
} }
+11
View File
@@ -13,6 +13,7 @@ struct RelayController {
senderIsSelf: Bool, senderIsSelf: Bool,
isEncrypted: Bool, isEncrypted: Bool,
isDirectedEncrypted: Bool, isDirectedEncrypted: Bool,
isFragment: Bool,
isDirectedFragment: Bool, isDirectedFragment: Bool,
isHandshake: Bool, isHandshake: Bool,
isAnnounce: Bool, isAnnounce: Bool,
@@ -36,6 +37,16 @@ struct RelayController {
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
} }
if isFragment {
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
guard ttlLimit > 1 else {
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
}
let newTTL = ttlLimit &- 1
let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs)
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
// TTL clamping for broadcast // TTL clamping for broadcast
// - Dense graphs: keep lower but still allow multi-hop bridging // - Dense graphs: keep lower but still allow multi-hop bridging
// - Announces get a bit more headroom // - Announces get a bit more headroom
+5
View File
@@ -45,6 +45,7 @@ protocol Transport: AnyObject {
// Messaging // Messaging
func sendMessage(_ content: String, mentions: [String]) func sendMessage(_ content: String, mentions: [String])
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
@@ -65,6 +66,10 @@ extension Transport {
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {} func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions)
}
} }
protocol TransportPeerEventsDelegate: AnyObject { protocol TransportPeerEventsDelegate: AnyObject {
+5
View File
@@ -8,6 +8,10 @@ enum TransportConfig {
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
// UI / Storage Caps // UI / Storage Caps
static let privateChatCap: Int = 1337 static let privateChatCap: Int = 1337
@@ -66,6 +70,7 @@ enum TransportConfig {
static let uiAnimationMediumSeconds: TimeInterval = 0.2 static let uiAnimationMediumSeconds: TimeInterval = 0.2
static let uiAnimationSidebarSeconds: TimeInterval = 0.25 static let uiAnimationSidebarSeconds: TimeInterval = 0.25
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60 static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
// BLE maintenance & thresholds // BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 5.0 static let bleMaintenanceInterval: TimeInterval = 5.0
+204 -99
View File
@@ -8,6 +8,55 @@ final class GossipSyncManager {
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
} }
private struct PacketStore {
private(set) var packets: [String: BitchatPacket] = [:]
private(set) var order: [String] = []
mutating func insert(idHex: String, packet: BitchatPacket, capacity: Int) {
guard capacity > 0 else { return }
if packets[idHex] != nil {
packets[idHex] = packet
return
}
packets[idHex] = packet
order.append(idHex)
while order.count > capacity {
let victim = order.removeFirst()
packets.removeValue(forKey: victim)
}
}
func allPackets(isFresh: (BitchatPacket) -> Bool) -> [BitchatPacket] {
order.compactMap { key in
guard let packet = packets[key], isFresh(packet) else { return nil }
return packet
}
}
mutating func remove(where shouldRemove: (BitchatPacket) -> Bool) {
var nextOrder: [String] = []
for key in order {
guard let packet = packets[key] else { continue }
if shouldRemove(packet) {
packets.removeValue(forKey: key)
} else {
nextOrder.append(key)
}
}
order = nextOrder
}
mutating func removeExpired(isFresh: (BitchatPacket) -> Bool) {
remove { !isFresh($0) }
}
}
private struct SyncSchedule {
let types: SyncTypeFlags
let interval: TimeInterval
var lastSent: Date
}
struct Config { struct Config {
var seenCapacity: Int = 1000 // max packets per sync (cap across types) var seenCapacity: Int = 1000 // max packets per sync (cap across types)
var gcsMaxBytes: Int = 400 // filter size budget (128..1024) var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
@@ -16,25 +65,43 @@ final class GossipSyncManager {
var maintenanceIntervalSeconds: TimeInterval = 30.0 var maintenanceIntervalSeconds: TimeInterval = 30.0
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0 var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
var stalePeerTimeoutSeconds: TimeInterval = 60.0 var stalePeerTimeoutSeconds: TimeInterval = 60.0
var fragmentCapacity: Int = 600
var fileTransferCapacity: Int = 200
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
var messageSyncIntervalSeconds: TimeInterval = 15.0
} }
private let myPeerID: PeerID private let myPeerID: PeerID
private let config: Config private let config: Config
weak var delegate: Delegate? weak var delegate: Delegate?
// Storage: broadcast messages (ordered by insert), and latest announce per sender // Storage: broadcast packets by type, and latest announce per sender
private var messages: [String: BitchatPacket] = [:] // idHex -> packet private var messages = PacketStore()
private var messageOrder: [String] = [] private var fragments = PacketStore()
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:] private var fileTransfers = PacketStore()
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
// Timer // Timer
private var periodicTimer: DispatchSourceTimer? private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility) private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
private var lastStalePeerCleanup: Date = .distantPast private var lastStalePeerCleanup: Date = .distantPast
private var syncSchedules: [SyncSchedule] = []
init(myPeerID: PeerID, config: Config = Config()) { init(myPeerID: PeerID, config: Config = Config()) {
self.myPeerID = myPeerID self.myPeerID = myPeerID
self.config = config self.config = config
var schedules: [SyncSchedule] = []
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
}
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
}
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
}
syncSchedules = schedules
} }
func start() { func start() {
@@ -55,7 +122,18 @@ final class GossipSyncManager {
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) { func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
self?.sendRequestSync(to: peerID) guard let self = self else { return }
self.sendRequestSync(to: peerID, types: .publicMessages)
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.sendRequestSync(to: peerID, types: .fragment)
}
}
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.sendRequestSync(to: peerID, types: .fileTransfer)
}
}
} }
} }
@@ -87,47 +165,45 @@ final class GossipSyncManager {
} }
private func _onPublicPacketSeen(_ packet: BitchatPacket) { private func _onPublicPacketSeen(_ packet: BitchatPacket) {
let mt = MessageType(rawValue: packet.type) guard let messageType = MessageType(rawValue: packet.type) else { return }
let isBroadcastRecipient: Bool = { let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true } guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF } return r.count == 8 && r.allSatisfy { $0 == 0xFF }
}() }()
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
let isAnnounce = (mt == .announce)
guard isBroadcastMessage || isAnnounce else { return }
// Reject expired packets to prevent ghost peers and old messages switch messageType {
guard isPacketFresh(packet) else { return } case .announce:
guard isPacketFresh(packet) else { return }
if isAnnounce {
guard isAnnouncementFresh(packet) else { guard isAnnouncementFresh(packet) else {
let sender = packet.senderID.hexEncodedString().lowercased() let sender = PeerID(hexData: packet.senderID)
removeState(forNormalizedPeerID: sender) removeState(for: sender)
return return
} }
} let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
let sender = PeerID(hexData: packet.senderID)
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
if isBroadcastMessage {
if messages[idHex] == nil {
messages[idHex] = packet
messageOrder.append(idHex)
// Enforce capacity
let cap = max(1, config.seenCapacity)
while messageOrder.count > cap {
let victim = messageOrder.removeFirst()
messages.removeValue(forKey: victim)
}
}
} else if isAnnounce {
let sender = packet.senderID.hexEncodedString().lowercased()
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet) latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
case .message:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
case .fragment:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fragments.insert(idHex: idHex, packet: packet, capacity: max(1, config.fragmentCapacity))
case .fileTransfer:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
default:
break
} }
} }
private func sendRequestSync() { private func sendRequestSync(for types: SyncTypeFlags) {
let payload = buildGcsPayload() let payload = buildGcsPayload(for: types)
let pkt = BitchatPacket( let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue, type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID.id) ?? Data(), senderID: Data(hexString: myPeerID.id) ?? Data(),
@@ -141,8 +217,8 @@ final class GossipSyncManager {
delegate?.sendPacket(signed) delegate?.sendPacket(signed)
} }
private func sendRequestSync(to peerID: PeerID) { private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
let payload = buildGcsPayload() let payload = buildGcsPayload(for: types)
var recipient = Data() var recipient = Data()
var temp = peerID.id var temp = peerID.id
while temp.count >= 2 && recipient.count < 8 { while temp.count >= 2 && recipient.count < 8 {
@@ -170,6 +246,7 @@ final class GossipSyncManager {
} }
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) { private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
let requestedTypes = (request.types ?? .publicMessages)
// Decode GCS into sorted set and prepare membership checker // Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool { func mightContain(_ id: Data) -> Bool {
@@ -177,60 +254,100 @@ final class GossipSyncManager {
return GCSFilter.contains(sortedValues: sorted, candidate: bucket) return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
} }
// 1) Announcements: send latest per peer if requester lacks them (and not expired) if requestedTypes.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer { for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue } guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data() let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) { if !mightContain(idBytes) {
var toSend = pkt var toSend = pkt
toSend.ttl = 0 toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
}
} }
} }
// 2) Broadcast messages: send all missing (and not expired) if requestedTypes.contains(.message) {
let toSendMsgs = messageOrder.compactMap { messages[$0] } let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
for pkt in toSendMsgs { for pkt in toSendMsgs {
guard isPacketFresh(pkt) else { continue } let idBytes = PacketIdUtil.computeId(pkt)
let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) {
if !mightContain(idBytes) { var toSend = pkt
var toSend = pkt toSend.ttl = 0
toSend.ttl = 0 delegate?.sendPacket(to: peerID, packet: toSend)
delegate?.sendPacket(to: peerID, packet: toSend) }
}
}
if requestedTypes.contains(.fragment) {
let frags = fragments.allPackets(isFresh: isPacketFresh)
for pkt in frags {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.fileTransfer) {
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
for pkt in files {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
} }
} }
} }
// Build REQUEST_SYNC payload using current candidates and GCS params // Build REQUEST_SYNC payload using current candidates and GCS params
private func buildGcsPayload() -> Data { private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
var candidates: [BitchatPacket] = [] var candidates: [BitchatPacket] = []
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count) if types.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer { for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
if isPacketFresh(pair.packet) {
candidates.append(pair.packet) candidates.append(pair.packet)
} }
} }
for id in messageOrder { if types.contains(.message) {
if let p = messages[id], isPacketFresh(p) { candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
candidates.append(p)
}
} }
if types.contains(.fragment) {
candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh))
}
if types.contains(.fileTransfer) {
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
}
if candidates.isEmpty {
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
return req.encode()
}
// Sort by timestamp desc // Sort by timestamp desc
candidates.sort { $0.timestamp > $1.timestamp } candidates.sort { $0.timestamp > $1.timestamp }
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr) let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p) let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
let cap = max(1, config.seenCapacity) let cap: Int
if types == .fragment {
cap = max(1, config.fragmentCapacity)
} else if types == .fileTransfer {
cap = max(1, config.fileTransferCapacity)
} else {
cap = max(1, config.seenCapacity)
}
let takeN = min(candidates.count, min(nMax, cap)) let takeN = min(candidates.count, min(nMax, cap))
if takeN <= 0 { if takeN <= 0 {
let req = RequestSyncPacket(p: p, m: 1, data: Data()) let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
return req.encode() return req.encode()
} }
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) } let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr) let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data) let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
return req.encode() return req.encode()
} }
@@ -241,20 +358,21 @@ final class GossipSyncManager {
isPacketFresh(pair.packet) isPacketFresh(pair.packet)
} }
// Remove expired messages messages.removeExpired(isFresh: isPacketFresh)
let expiredMessageIds = messages.compactMap { id, pkt in fragments.removeExpired(isFresh: isPacketFresh)
isPacketFresh(pkt) ? nil : id fileTransfers.removeExpired(isFresh: isPacketFresh)
}
for id in expiredMessageIds {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
} }
private func performPeriodicMaintenance(now: Date = Date()) { private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages() cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now) cleanupStaleAnnouncementsIfNeeded(now: now)
sendRequestSync() 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)
}
}
} }
private func cleanupStaleAnnouncementsIfNeeded(now: Date) { private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
@@ -270,40 +388,27 @@ final class GossipSyncManager {
let nowMs = UInt64(now.timeIntervalSince1970 * 1000) let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMs >= timeoutMs else { return } guard nowMs >= timeoutMs else { return }
let cutoff = nowMs - timeoutMs let cutoff = nowMs - timeoutMs
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil pair.packet.timestamp < cutoff ? peerID : nil
} }
guard !stalePeerIDs.isEmpty else { return } guard !stalePeerIDs.isEmpty else { return }
for peerKey in stalePeerIDs { for peerKey in stalePeerIDs {
removeState(forNormalizedPeerID: peerKey) removeState(for: peerKey)
} }
} }
// Explicit removal hook for LEAVE/stale peer // Explicit removal hook for LEAVE/stale peer
func removeAnnouncementForPeer(_ peerID: PeerID) { func removeAnnouncementForPeer(_ peerID: PeerID) {
queue.async { [weak self] in queue.async { [weak self] in
self?._removeAnnouncementForPeer(peerID) self?.removeState(for: peerID)
} }
} }
private func _removeAnnouncementForPeer(_ peerID: PeerID) { private func removeState(for peerID: PeerID) {
let normalizedPeerID = peerID.id.lowercased() _ = latestAnnouncementByPeer.removeValue(forKey: peerID)
removeState(forNormalizedPeerID: normalizedPeerID) messages.remove { PeerID(hexData: $0.senderID) == peerID }
} fragments.remove { PeerID(hexData: $0.senderID) == peerID }
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
// Remove messages from this peer
// Collect IDs to remove first to avoid concurrent modification
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
}
// Remove messages and update messageOrder
for id in messageIdsToRemove {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
} }
} }
@@ -317,13 +422,13 @@ extension GossipSyncManager {
func _hasAnnouncement(for peerID: PeerID) -> Bool { func _hasAnnouncement(for peerID: PeerID) -> Bool {
queue.sync { queue.sync {
latestAnnouncementByPeer[peerID.id.lowercased()] != nil latestAnnouncementByPeer[peerID] != nil
} }
} }
func _messageCount(for peerID: PeerID) -> Int { func _messageCount(for peerID: PeerID) -> Int {
queue.sync { queue.sync {
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
} }
} }
} }
+104
View File
@@ -0,0 +1,104 @@
import Foundation
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
/// Matches the Android mapping (bit index -> message type).
struct SyncTypeFlags: OptionSet {
let rawValue: UInt64
init(rawValue: UInt64) {
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
}
private static func bitIndex(for type: MessageType) -> Int? {
switch type {
case .announce: return 0
case .message: return 1
case .leave: return 2
case .noiseHandshake: return 3
case .noiseEncrypted: return 4
case .fragment: return 5
case .requestSync: return 6
case .fileTransfer: return 7
}
}
private static func type(forBit index: Int) -> MessageType? {
switch index {
case 0: return .announce
case 1: return .message
case 2: return .leave
case 3: return .noiseHandshake
case 4: return .noiseEncrypted
case 5: return .fragment
case 6: return .requestSync
case 7: return .fileTransfer
default:
return nil
}
}
static let announce = SyncTypeFlags(messageTypes: [.announce])
static let message = SyncTypeFlags(messageTypes: [.message])
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
init(messageTypes: [MessageType]) {
var raw: UInt64 = 0
for type in messageTypes {
guard let bit = SyncTypeFlags.bitIndex(for: type) else { continue }
raw |= (1 << UInt64(bit))
}
self.init(rawValue: raw)
}
func contains(_ type: MessageType) -> Bool {
guard let bit = SyncTypeFlags.bitIndex(for: type) else { return false }
return contains(SyncTypeFlags(rawValue: 1 << UInt64(bit)))
}
func union(_ other: SyncTypeFlags) -> SyncTypeFlags {
SyncTypeFlags(rawValue: rawValue | other.rawValue)
}
func intersection(_ other: SyncTypeFlags) -> SyncTypeFlags {
SyncTypeFlags(rawValue: rawValue & other.rawValue)
}
func toMessageTypes() -> [MessageType] {
guard rawValue != 0 else { return [] }
var types: [MessageType] = []
for bit in 0..<64 {
guard (rawValue & (1 << UInt64(bit))) != 0 else { continue }
if let type = SyncTypeFlags.type(forBit: bit) {
types.append(type)
}
}
return types
}
func toData() -> Data? {
guard rawValue != 0 else { return nil }
var value = rawValue
var bytes: [UInt8] = []
while value > 0 && bytes.count < 8 {
bytes.append(UInt8(value & 0xFF))
value >>= 8
}
while let last = bytes.last, last == 0 {
bytes.removeLast()
}
guard !bytes.isEmpty, bytes.count <= 8 else { return nil }
return Data(bytes)
}
static func decode(_ data: Data) -> SyncTypeFlags? {
guard (1...8).contains(data.count) else { return nil }
var raw: UInt64 = 0
for (index, byte) in data.enumerated() {
raw |= UInt64(byte) << UInt64(index * 8)
}
return SyncTypeFlags(rawValue: raw)
}
}
+7 -9
View File
@@ -61,15 +61,13 @@ struct CompressionUtil {
// 1. Data is too small // 1. Data is too small
// 2. Data appears to be already compressed (high entropy) // 2. Data appears to be already compressed (high entropy)
guard data.count >= compressionThreshold else { return false } guard data.count >= compressionThreshold else { return false }
// Simple entropy check - count unique bytes // Quick uniqueness check a high diversity of bytes usually means the
var byteFrequency = [UInt8: Int]() // payload is already compressed. We only need to know how many unique
for byte in data { // values exist rather than keeping full frequency counts.
byteFrequency[byte, default: 0] += 1 let uniqueByteCount = Set(data).count
} let sampleSize = min(data.count, 256)
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
// If we have very high byte diversity, data is likely already compressed
let uniqueByteRatio = Double(byteFrequency.count) / Double(min(data.count, 256))
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
} }
} }
+2 -2
View File
@@ -5,9 +5,9 @@ enum FileTransferLimits {
/// Absolute ceiling enforced for any file payload (voice, image, other). /// Absolute ceiling enforced for any file payload (voice, image, other).
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// Voice notes stay small for low-latency relays. /// Voice notes stay small for low-latency relays.
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
/// Compressed images after downscaling should comfortably fit under this budget. /// Compressed images after downscaling should comfortably fit under this budget.
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB static let maxImageBytes: Int = 512 * 1024 // 512 KiB
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads. /// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
static let maxFramedFileBytes: Int = { static let maxFramedFileBytes: Int = {
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
+17 -17
View File
@@ -1,4 +1,5 @@
import Foundation import Foundation
import BitLogger
/// Comprehensive input validation for BitChat protocol /// Comprehensive input validation for BitChat protocol
/// Prevents injection attacks, buffer overflows, and malformed data /// Prevents injection attacks, buffer overflows, and malformed data
@@ -16,29 +17,28 @@ struct InputValidator {
// MARK: - String Content Validation // MARK: - String Content Validation
/// Validates and sanitizes user-provided strings used in UI /// Validates and sanitizes user-provided strings used in UI
///
/// Rejects strings containing control characters to prevent potential security issues
/// and UI rendering problems. This strict approach ensures data integrity at input time.
static func validateUserString(_ string: String, maxLength: Int) -> String? { static func validateUserString(_ string: String, maxLength: Int) -> String? {
// Check empty
guard !string.isEmpty else { return nil }
// Trim whitespace
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil } guard !trimmed.isEmpty else { return nil }
// Check length
guard trimmed.count <= maxLength else { return nil } guard trimmed.count <= maxLength else { return nil }
// Remove control characters // Reject control characters outright instead of rewriting the string.
// This prevents injection attacks and ensures consistent UI rendering.
let controlChars = CharacterSet.controlCharacters let controlChars = CharacterSet.controlCharacters
let cleaned = trimmed.components(separatedBy: controlChars).joined() if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
// Log rejection for monitoring, without exposing actual content for privacy
// Ensure valid UTF-8 (should already be, but double-check) let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
guard cleaned.data(using: .utf8) != nil else { return nil } SecureLogger.debug(
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
// Prevent zero-width characters and other invisible unicode category: .security
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}") )
let visible = cleaned.components(separatedBy: invisibleChars).joined() return nil
}
return visible.isEmpty ? nil : visible
return trimmed
} }
/// Validates nickname /// Validates nickname
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,118 @@
//
// GeoChannelCoordinator.swift
// bitchat
//
// Centralizes Combine wiring for location channel selection and sampling.
//
import Combine
import Foundation
import Tor
@MainActor
final class GeoChannelCoordinator {
private let locationManager: LocationChannelManager
private let bookmarksStore: GeohashBookmarksStore
private let torManager: TorManager
private let onChannelSwitch: (ChannelID) -> Void
private let beginSampling: ([String]) -> Void
private let endSampling: () -> Void
private var cancellables = Set<AnyCancellable>()
private var regionalGeohashes: [String] = []
private var bookmarkedGeohashes: [String] = []
init(
locationManager: LocationChannelManager? = nil,
bookmarksStore: GeohashBookmarksStore? = nil,
torManager: TorManager? = nil,
onChannelSwitch: @escaping (ChannelID) -> Void,
beginSampling: @escaping ([String]) -> Void,
endSampling: @escaping () -> Void
) {
self.locationManager = locationManager ?? Self.defaultLocationManager()
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
self.torManager = torManager ?? Self.defaultTorManager()
self.onChannelSwitch = onChannelSwitch
self.beginSampling = beginSampling
self.endSampling = endSampling
start()
}
func start() {
regionalGeohashes = locationManager.availableChannels.map { $0.geohash }
bookmarkedGeohashes = bookmarksStore.bookmarks
locationManager.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
guard let self else { return }
Task { @MainActor in
self.onChannelSwitch(channel)
}
}
.store(in: &cancellables)
locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] channels in
guard let self else { return }
self.regionalGeohashes = channels.map { $0.geohash }
self.updateSampling()
}
.store(in: &cancellables)
bookmarksStore.$bookmarks
.receive(on: DispatchQueue.main)
.sink { [weak self] bookmarks in
guard let self else { return }
self.bookmarkedGeohashes = bookmarks
self.updateSampling()
}
.store(in: &cancellables)
locationManager.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self, state == .authorized else { return }
Task { @MainActor [weak self] in
self?.locationManager.refreshChannels()
}
}
.store(in: &cancellables)
Task { @MainActor in
self.onChannelSwitch(self.locationManager.selectedChannel)
}
updateSampling()
}
private func updateSampling() {
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
Task { @MainActor in
guard !union.isEmpty else {
endSampling()
return
}
if torManager.isForeground() {
beginSampling(union)
} else {
endSampling()
}
}
}
func refreshSampling() {
updateSampling()
}
private static func defaultLocationManager() -> LocationChannelManager {
LocationChannelManager.shared
}
@MainActor
private static func defaultTorManager() -> TorManager {
TorManager.shared
}
}
@@ -0,0 +1,77 @@
//
// MessageRateLimiter.swift
// bitchat
//
// Handles per-sender and per-content token buckets for public message intake.
//
import Foundation
struct MessageRateLimiter {
private struct TokenBucket {
var capacity: Double
var tokens: Double
var refillPerSec: Double
var lastRefill: Date
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
let dt = now.timeIntervalSince(lastRefill)
if dt > 0 {
tokens = min(capacity, tokens + dt * refillPerSec)
lastRefill = now
}
if tokens >= cost {
tokens -= cost
return true
}
return false
}
}
private var senderBuckets: [String: TokenBucket] = [:]
private var contentBuckets: [String: TokenBucket] = [:]
private let senderCapacity: Double
private let senderRefill: Double
private let contentCapacity: Double
private let contentRefill: Double
init(
senderCapacity: Double,
senderRefillPerSec: Double,
contentCapacity: Double,
contentRefillPerSec: Double
) {
self.senderCapacity = senderCapacity
self.senderRefill = senderRefillPerSec
self.contentCapacity = contentCapacity
self.contentRefill = contentRefillPerSec
}
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
lastRefill: now
)
let senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
capacity: contentCapacity,
tokens: contentCapacity,
refillPerSec: contentRefill,
lastRefill: now
)
let contentAllowed = contentBucket.allow(now: now)
contentBuckets[contentKey] = contentBucket
return senderAllowed && contentAllowed
}
mutating func reset() {
senderBuckets.removeAll()
contentBuckets.removeAll()
}
}
@@ -0,0 +1,210 @@
//
// MinimalDistancePalette.swift
// bitchat
//
// Lightweight palette generator that keeps peer colors evenly spaced.
//
import Foundation
import SwiftUI
final class MinimalDistancePalette {
struct Config {
let slotCount: Int
let avoidCenterHue: Double
let avoidHueDelta: Double
let saturationLight: Double
let saturationDark: Double
let baseBrightnessLight: Double
let baseBrightnessDark: Double
let ringBrightnessDeltaLight: Double
let ringBrightnessDeltaDark: Double
let preferredBiasWeight: Double
let goldenStep: Int
init(
slotCount: Int,
avoidCenterHue: Double,
avoidHueDelta: Double,
saturationLight: Double,
saturationDark: Double,
baseBrightnessLight: Double,
baseBrightnessDark: Double,
ringBrightnessDeltaLight: Double,
ringBrightnessDeltaDark: Double,
preferredBiasWeight: Double = 0.05,
goldenStep: Int = 7
) {
self.slotCount = slotCount
self.avoidCenterHue = avoidCenterHue
self.avoidHueDelta = avoidHueDelta
self.saturationLight = saturationLight
self.saturationDark = saturationDark
self.baseBrightnessLight = baseBrightnessLight
self.baseBrightnessDark = baseBrightnessDark
self.ringBrightnessDeltaLight = ringBrightnessDeltaLight
self.ringBrightnessDeltaDark = ringBrightnessDeltaDark
self.preferredBiasWeight = preferredBiasWeight
self.goldenStep = goldenStep
}
}
private struct Entry {
let slot: Int
let ring: Int
let hue: Double
}
private let config: Config
private var currentSeeds: [String: String] = [:]
private var entries: [String: Entry] = [:]
private var previousEntries: [String: Entry] = [:]
init(config: Config) {
self.config = config
}
@MainActor
func ensurePalette(for seeds: [String: String]) {
guard seeds != currentSeeds || entries.count != seeds.count else { return }
previousEntries = entries
currentSeeds = seeds
rebuildEntries()
}
@MainActor
func color(for identifier: String, isDark: Bool) -> Color? {
guard let entry = entries[identifier] else { return nil }
let saturation = isDark ? config.saturationDark : config.saturationLight
let baseBrightness = isDark ? config.baseBrightnessDark : config.baseBrightnessLight
let ringDelta = isDark ? config.ringBrightnessDeltaDark : config.ringBrightnessDeltaLight
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(entry.ring)))
return Color(hue: entry.hue, saturation: saturation, brightness: brightness)
}
@MainActor
func reset() {
currentSeeds.removeAll()
entries.removeAll()
previousEntries.removeAll()
}
@MainActor
private func rebuildEntries() {
guard !currentSeeds.isEmpty else {
entries.removeAll()
return
}
let slotCount = max(8, config.slotCount)
var slots: [Double] = []
for idx in 0..<slotCount {
let hue = Double(idx) / Double(slotCount)
if abs(hue - config.avoidCenterHue) < config.avoidHueDelta {
continue
}
slots.append(hue)
}
if slots.isEmpty {
for idx in 0..<slotCount {
slots.append(Double(idx) / Double(slotCount))
}
}
func circularDistance(_ a: Double, _ b: Double) -> Double {
let diff = abs(a - b)
return diff > 0.5 ? 1.0 - diff : diff
}
let peerIDs = currentSeeds.keys.sorted()
let preferredIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peerIDs.map { id in
let seed = currentSeeds[id] ?? id
let hash = seed.djb2()
let index = Int(hash % UInt64(slots.count))
return (id, index)
})
var mapping: [String: Entry] = [:]
var usedSlots = Set<Int>()
var usedHues: [Double] = []
let prior = entries.isEmpty ? previousEntries : entries
for (id, entry) in prior {
guard currentSeeds.keys.contains(id), entry.slot < slots.count else { continue }
let hue = slots[entry.slot]
mapping[id] = Entry(slot: entry.slot, ring: entry.ring, hue: hue)
usedSlots.insert(entry.slot)
usedHues.append(hue)
}
let unassigned = peerIDs.filter { mapping[$0] == nil }
for id in unassigned {
let preferred = preferredIndex[id] ?? 0
if !usedSlots.contains(preferred), preferred < slots.count {
let hue = slots[preferred]
mapping[id] = Entry(slot: preferred, ring: 0, hue: hue)
usedSlots.insert(preferred)
usedHues.append(hue)
continue
}
var bestSlot: Int?
var bestScore = -Double.infinity
for slot in 0..<slots.count where !usedSlots.contains(slot) {
let hue = slots[slot]
let minDistance = usedHues.isEmpty ? 1.0 : usedHues.map { circularDistance(hue, $0) }.min() ?? 1.0
let bias = 1.0 - (Double((abs(slot - (preferredIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
let score = minDistance + config.preferredBiasWeight * bias
if score > bestScore {
bestScore = score
bestSlot = slot
}
}
if let slot = bestSlot {
let hue = slots[slot]
mapping[id] = Entry(slot: slot, ring: 0, hue: hue)
usedSlots.insert(slot)
usedHues.append(hue)
}
}
let remaining = peerIDs.filter { mapping[$0] == nil }
if !remaining.isEmpty {
for (index, id) in remaining.enumerated() {
let preferred = preferredIndex[id] ?? 0
let slot = (preferred + index * config.goldenStep) % slots.count
let hue = slots[slot]
mapping[id] = Entry(slot: slot, ring: 1, hue: hue)
}
}
entries = mapping
}
}
extension MinimalDistancePalette.Config {
static let mesh = MinimalDistancePalette.Config(
slotCount: TransportConfig.uiPeerPaletteSlots,
avoidCenterHue: 30.0 / 360.0,
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
saturationLight: 0.70,
saturationDark: 0.80,
baseBrightnessLight: 0.45,
baseBrightnessDark: 0.75,
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
)
static let nostr = MinimalDistancePalette.Config(
slotCount: TransportConfig.uiPeerPaletteSlots,
avoidCenterHue: 30.0 / 360.0,
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
saturationLight: 0.70,
saturationDark: 0.80,
baseBrightnessLight: 0.45,
baseBrightnessDark: 0.75,
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
)
}
@@ -0,0 +1,190 @@
//
// PublicMessagePipeline.swift
// bitchat
//
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
//
import Foundation
@MainActor
protocol PublicMessagePipelineDelegate: AnyObject {
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage]
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage])
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date)
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline)
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage)
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)
}
@MainActor
final class PublicMessagePipeline {
weak var delegate: PublicMessagePipelineDelegate?
private var buffer: [BitchatMessage] = []
private var timer: Timer?
private let baseFlushInterval: TimeInterval
private var dynamicFlushInterval: TimeInterval
private var recentBatchSizes: [Int] = []
private let maxRecentBatchSamples: Int
private let dedupWindow: TimeInterval
private var activeChannel: ChannelID = .mesh
init(
baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,
maxRecentBatchSamples: Int = 10,
dedupWindow: TimeInterval = 1.0
) {
self.baseFlushInterval = baseFlushInterval
self.dynamicFlushInterval = baseFlushInterval
self.maxRecentBatchSamples = maxRecentBatchSamples
self.dedupWindow = dedupWindow
}
deinit {
timer?.invalidate()
}
func updateActiveChannel(_ channel: ChannelID) {
activeChannel = channel
}
func enqueue(_ message: BitchatMessage) {
buffer.append(message)
scheduleFlush()
}
func flushIfNeeded() {
flushBuffer()
}
func reset() {
timer?.invalidate()
timer = nil
buffer.removeAll(keepingCapacity: false)
}
}
private extension PublicMessagePipeline {
func scheduleFlush() {
guard timer == nil else { return }
timer = Timer.scheduledTimer(withTimeInterval: dynamicFlushInterval, repeats: false) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.flushBuffer()
}
}
}
func flushBuffer() {
timer?.invalidate()
timer = nil
guard !buffer.isEmpty else { return }
guard let delegate = delegate else {
buffer.removeAll(keepingCapacity: false)
return
}
delegate.pipelineSetBatchingState(self, isBatching: true)
var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id })
var pending: [(message: BitchatMessage, contentKey: String)] = []
var batchContentLatest: [String: Date] = [:]
for message in buffer {
if existingIDs.contains(message.id) { continue }
let contentKey = delegate.pipeline(self, normalizeContent: message.content)
if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
continue
}
if let ts = batchContentLatest[contentKey],
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
continue
}
existingIDs.insert(message.id)
pending.append((message, contentKey))
batchContentLatest[contentKey] = message.timestamp
}
buffer.removeAll(keepingCapacity: true)
guard !pending.isEmpty else {
delegate.pipelineSetBatchingState(self, isBatching: false)
if !buffer.isEmpty { scheduleFlush() }
return
}
pending.sort { $0.message.timestamp < $1.message.timestamp }
var messages = delegate.pipelineCurrentMessages(self)
let threshold = lateInsertThreshold(for: activeChannel)
let lastTimestamp = messages.last?.timestamp ?? .distantPast
for item in pending {
let message = item.message
if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) {
let index = insertionIndex(for: message.timestamp, in: messages)
if index >= messages.count {
messages.append(message)
} else {
messages.insert(message, at: index)
}
} else {
messages.append(message)
}
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp)
}
delegate.pipeline(self, setMessages: messages)
delegate.pipelineTrimMessages(self)
updateFlushInterval(withBatchSize: pending.count)
for item in pending {
delegate.pipelinePrewarmMessage(self, message: item.message)
}
delegate.pipelineSetBatchingState(self, isBatching: false)
if !buffer.isEmpty {
scheduleFlush()
}
}
func updateFlushInterval(withBatchSize size: Int) {
recentBatchSizes.append(size)
if recentBatchSizes.count > maxRecentBatchSamples {
recentBatchSizes.removeFirst(recentBatchSizes.count - maxRecentBatchSamples)
}
let avg = recentBatchSizes.isEmpty
? 0.0
: Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval
}
func lateInsertThreshold(for channel: ChannelID) -> TimeInterval {
switch channel {
case .mesh:
return TransportConfig.uiLateInsertThreshold
case .location:
return TransportConfig.uiLateInsertThresholdGeo
}
}
func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int {
var low = 0
var high = messages.count
while low < high {
let mid = (low + high) / 2
if messages[mid].timestamp < timestamp {
low = mid + 1
} else {
high = mid
}
}
return low
}
}
@@ -0,0 +1,124 @@
//
// PublicTimelineStore.swift
// bitchat
//
// Maintains mesh and geohash public timelines with simple caps and helpers.
//
import Foundation
struct PublicTimelineStore {
private var meshTimeline: [BitchatMessage] = []
private var geohashTimelines: [String: [BitchatMessage]] = [:]
private var pendingGeohashSystemMessages: [String] = []
private let meshCap: Int
private let geohashCap: Int
init(meshCap: Int, geohashCap: Int) {
self.meshCap = meshCap
self.geohashCap = geohashCap
}
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
switch channel {
case .mesh:
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }
meshTimeline.append(message)
trimMeshTimelineIfNeeded()
case .location(let channel):
append(message, toGeohash: channel.geohash)
}
}
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
var timeline = geohashTimelines[geohash] ?? []
guard !timeline.contains(where: { $0.id == message.id }) else { return }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
}
/// Append message if absent, returning true when stored.
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
var timeline = geohashTimelines[geohash] ?? []
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
return true
}
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
switch channel {
case .mesh:
return meshTimeline
case .location(let channel):
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
geohashTimelines[channel.geohash] = cleaned
return cleaned
}
}
mutating func clear(channel: ChannelID) {
switch channel {
case .mesh:
meshTimeline.removeAll()
case .location(let channel):
geohashTimelines[channel.geohash] = []
}
}
@discardableResult
mutating func removeMessage(withID id: String) -> BitchatMessage? {
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
return meshTimeline.remove(at: index)
}
for key in Array(geohashTimelines.keys) {
var timeline = geohashTimelines[key] ?? []
if let index = timeline.firstIndex(where: { $0.id == id }) {
let removed = timeline.remove(at: index)
geohashTimelines[key] = timeline.isEmpty ? nil : timeline
return removed
}
}
return nil
}
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
var timeline = geohashTimelines[geohash] ?? []
timeline.removeAll(where: predicate)
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
}
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
var timeline = geohashTimelines[geohash] ?? []
transform(&timeline)
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
}
mutating func queueGeohashSystemMessage(_ content: String) {
pendingGeohashSystemMessages.append(content)
}
mutating func drainPendingGeohashSystemMessages() -> [String] {
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
return pendingGeohashSystemMessages
}
func geohashKeys() -> [String] {
Array(geohashTimelines.keys)
}
private mutating func trimMeshTimelineIfNeeded() {
guard meshTimeline.count > meshCap else { return }
meshTimeline = Array(meshTimeline.suffix(meshCap))
}
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {
guard timeline.count > geohashCap else { return }
timeline = Array(timeline.suffix(geohashCap))
}
}
@@ -0,0 +1,90 @@
//
// CommandSuggestionsView.swift
// bitchat
//
// Created by Islam on 29/10/2025.
//
import SwiftUI
struct CommandSuggestionsView: View {
@EnvironmentObject private var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@Binding var messageText: String
let textColor: Color
let backgroundColor: Color
let secondaryTextColor: Color
private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
let isGeoPublic = locationManager.selectedChannel.isLocation
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
command.alias.starts(with: messageText.lowercased())
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in
Button {
messageText = command.alias + " "
} label: {
buttonRow(for: command)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
}
private func buttonRow(for command: CommandInfo) -> some View {
HStack {
Text(command.alias)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(textColor)
.fontWeight(.medium)
if let placeholder = command.placeholder {
Text(placeholder)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
}
Spacer()
Text(command.description)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@available(iOS 17, macOS 14, *)
#Preview {
@Previewable @State var messageText: String = "/"
let keychain = KeychainManager()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: NostrIdentityBridge(),
identityManager: SecureIdentityStateManager(keychain)
)
CommandSuggestionsView(
messageText: $messageText,
textColor: .green,
backgroundColor: .primary,
secondaryTextColor: .secondary
)
.environmentObject(viewModel)
}
+81 -153
View File
@@ -43,11 +43,9 @@ struct ContentView: View {
@State private var showPeerList = false @State private var showPeerList = false
@State private var showSidebar = false @State private var showSidebar = false
@State private var showAppInfo = false @State private var showAppInfo = false
@State private var showCommandSuggestions = false
@State private var commandSuggestions: [String] = []
@State private var showMessageActions = false @State private var showMessageActions = false
@State private var selectedMessageSender: String? @State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: String? @State private var selectedMessageSenderID: PeerID?
@FocusState private var isNicknameFieldFocused: Bool @FocusState private var isNicknameFieldFocused: Bool
@State private var isAtBottomPublic: Bool = true @State private var isAtBottomPublic: Bool = true
@State private var isAtBottomPrivate: Bool = true @State private var isAtBottomPrivate: Bool = true
@@ -203,7 +201,15 @@ struct ContentView: View {
} }
} }
#if os(iOS) #if os(iOS)
.sheet(isPresented: $showImagePicker) { // Only present image picker from main view when NOT in a sheet
.fullScreenCover(isPresented: Binding(
get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
set: { newValue in
if !newValue {
showImagePicker = false
}
}
)) {
ImagePickerView(sourceType: imagePickerSourceType) { image in ImagePickerView(sourceType: imagePickerSourceType) { image in
showImagePicker = false showImagePicker = false
if let image = image { if let image = image {
@@ -223,7 +229,15 @@ struct ContentView: View {
} }
#endif #endif
#if os(macOS) #if os(macOS)
.sheet(isPresented: $showMacImagePicker) { // Only present Mac image picker from main view when NOT in a sheet
.sheet(isPresented: Binding(
get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
set: { newValue in
if !newValue {
showMacImagePicker = false
}
}
)) {
MacImagePickerView { url in MacImagePickerView { url in
showMacImagePicker = false showMacImagePicker = false
if let url = url { if let url = url {
@@ -269,12 +283,12 @@ struct ContentView: View {
Button("content.actions.direct_message") { Button("content.actions.direct_message") {
if let peerID = selectedMessageSenderID { if let peerID = selectedMessageSenderID {
if peerID.hasPrefix("nostr:") { if peerID.isGeoChat {
if let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)) { if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
viewModel.startGeohashDM(withPubkeyHex: full) viewModel.startGeohashDM(withPubkeyHex: full)
} }
} else { } else {
viewModel.startPrivateChat(with: PeerID(str: peerID)) viewModel.startPrivateChat(with: peerID)
} }
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true showSidebar = true
@@ -296,8 +310,8 @@ struct ContentView: View {
Button("content.actions.block", role: .destructive) { Button("content.actions.block", role: .destructive) {
// Prefer direct geohash block when we have a Nostr sender ID // Prefer direct geohash block when we have a Nostr sender ID
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"), if let peerID = selectedMessageSenderID, peerID.isGeoChat,
let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)), let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
let sender = selectedMessageSender { let sender = selectedMessageSender {
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender) viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
} else if let sender = selectedMessageSender { } else if let sender = selectedMessageSender {
@@ -589,77 +603,12 @@ struct ContentView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
// Command suggestions CommandSuggestionsView(
if showCommandSuggestions && !commandSuggestions.isEmpty { messageText: $messageText,
VStack(alignment: .leading, spacing: 0) { textColor: textColor,
// Define commands with aliases and syntax backgroundColor: backgroundColor,
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [ secondaryTextColor: secondaryTextColor
(["/block"], "[nickname]", "block or list blocked peers"), )
(["/clear"], nil, "clear chat messages"),
(["/hug"], "<nickname>", "send someone a warm hug"),
(["/m", "/msg"], "<nickname> [message]", "send private message"),
(["/slap"], "<nickname>", "slap someone with a trout"),
(["/unblock"], "<nickname>", "unblock a peer"),
(["/w"], nil, "see who's online")
]
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
let favInfo: [(commands: [String], syntax: String?, description: String)] = [
(["/fav"], "<nickname>", "add to favorites"),
(["/unfav"], "<nickname>", "remove from favorites")
]
let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo)
// Build the display
let allCommands = commandInfo
// Show matching commands
ForEach(commandSuggestions, id: \.self) { command in
// Find the command info for this suggestion
if let info = allCommands.first(where: { $0.commands.contains(command) }) {
Button(action: {
// Replace current text with selected command
messageText = command + " "
showCommandSuggestions = false
commandSuggestions = []
}) {
HStack {
// Show all aliases together
Text(info.commands.joined(separator: ", "))
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(textColor)
.fontWeight(.medium)
// Show syntax if any
if let syntax = info.syntax {
Text(syntax)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
}
Spacer()
// Show description
Text(info.description)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
.padding(.horizontal, 12)
}
// Recording indicator // Recording indicator
if isPreparingVoiceNote || isRecordingVoiceNote { if isPreparingVoiceNote || isRecordingVoiceNote {
@@ -693,68 +642,11 @@ struct ContentView: View {
) )
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.onChange(of: messageText) { newValue in .onChange(of: messageText) { newValue in
// Cancel previous debounce timer
autocompleteDebounceTimer?.invalidate() autocompleteDebounceTimer?.invalidate()
// Debounce autocomplete updates to reduce calls during rapid typing
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
// Get cursor position (approximate - end of text for now)
let cursorPosition = newValue.count let cursorPosition = newValue.count
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
} }
// Check for command autocomplete (instant, no debounce needed)
if newValue.hasPrefix("/") && newValue.count >= 1 {
// Build context-aware command list
let isGeoPublic: Bool = {
if case .location = locationManager.selectedChannel { return true }
return false
}()
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
var commandDescriptions = [
("/block", String(localized: "content.commands.block", comment: "Description for /block command")),
("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")),
("/hug", String(localized: "content.commands.hug", comment: "Description for /hug command")),
("/m", String(localized: "content.commands.message", comment: "Description for /m command")),
("/slap", String(localized: "content.commands.slap", comment: "Description for /slap command")),
("/unblock", String(localized: "content.commands.unblock", comment: "Description for /unblock command")),
("/w", String(localized: "content.commands.who", comment: "Description for /w command"))
]
// Only show favorites commands when not in geohash context
if !(isGeoPublic || isGeoDM) {
commandDescriptions.append(("/fav", String(localized: "content.commands.favorite", comment: "Description for /fav command")))
commandDescriptions.append(("/unfav", String(localized: "content.commands.unfavorite", comment: "Description for /unfav command")))
}
let input = newValue.lowercased()
// Map of aliases to primary commands
let aliases: [String: String] = [
"/join": "/j",
"/msg": "/m"
]
// Filter commands, but convert aliases to primary
commandSuggestions = commandDescriptions
.filter { $0.0.starts(with: input) }
.map { $0.0 }
// Also check if input matches an alias
for (alias, primary) in aliases {
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
if commandDescriptions.contains(where: { $0.0 == primary }) {
commandSuggestions.append(primary)
}
}
}
// Remove duplicates and sort
commandSuggestions = Array(Set(commandSuggestions)).sorted()
showCommandSuggestions = !commandSuggestions.isEmpty
} else {
showCommandSuggestions = false
commandSuggestions = []
}
} }
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
@@ -771,14 +663,14 @@ struct ContentView: View {
.padding(.bottom, 8) .padding(.bottom, 8)
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
} }
private func handleOpenURL(_ url: URL) { private func handleOpenURL(_ url: URL) {
guard url.scheme == "bitchat" else { return } guard url.scheme == "bitchat" else { return }
switch url.host { switch url.host {
case "user": case "user":
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = PeerID(str: id.removingPercentEncoding ?? id) let peerID = PeerID(str: id.removingPercentEncoding ?? id)
selectedMessageSenderID = peerID.id selectedMessageSenderID = peerID
if peerID.isGeoDM || peerID.isGeoChat { if peerID.isGeoDM || peerID.isGeoChat {
selectedMessageSender = viewModel.geohashDisplayName(for: peerID) selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
@@ -906,6 +798,53 @@ struct ContentView: View {
#if os(macOS) #if os(macOS)
.frame(minWidth: 420, minHeight: 520) .frame(minWidth: 420, minHeight: 520)
#endif #endif
// Present image picker from sheet context when IN a sheet (parent-child pattern)
#if os(iOS)
.fullScreenCover(isPresented: Binding(
get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },
set: { newValue in
if !newValue {
showImagePicker = false
}
}
)) {
ImagePickerView(sourceType: imagePickerSourceType) { image in
showImagePicker = false
if let image = image {
Task {
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run {
viewModel.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
}
.ignoresSafeArea()
}
#endif
#if os(macOS)
.sheet(isPresented: $showMacImagePicker) {
MacImagePickerView { url in
showMacImagePicker = false
if let url = url {
Task {
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run {
viewModel.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
}
}
#endif
} }
// MARK: - People Sheet Views // MARK: - People Sheet Views
@@ -1150,12 +1089,7 @@ struct ContentView: View {
.foregroundColor(textColor) .foregroundColor(textColor)
if !privatePeerID.isGeoDM { if !privatePeerID.isGeoDM {
let statusPeerID: PeerID = { let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
if privatePeerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID.id) {
return short
}
return context.headerPeerID
}()
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
if let icon = encryptionStatus.icon { if let icon = encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
@@ -1189,13 +1123,7 @@ struct ContentView: View {
} }
private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext { private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {
let headerPeerID: PeerID = { let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
if privatePeerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID.id) {
return short
}
return privatePeerID
}()
let peer = viewModel.getPeer(byID: headerPeerID) let peer = viewModel.getPeer(byID: headerPeerID)
let displayName: String = { let displayName: String = {
+1 -4
View File
@@ -65,10 +65,7 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status // Prefer short mesh ID for session/encryption status
let statusPeerID: PeerID = { let statusPeerID = viewModel.getShortIDForNoiseKey(peerID)
if peerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID.id) { return short }
return peerID
}()
// Resolve a friendly name // Resolve a friendly name
let peerNickname: String = { let peerNickname: String = {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName } if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
+1 -1
View File
@@ -298,5 +298,5 @@ private final class MockBitchatDelegate: BitchatDelegate {
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {} func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
func didUpdateBluetoothState(_ state: CBManagerState) {} func didUpdateBluetoothState(_ state: CBManagerState) {}
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {} func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
} }
@@ -186,11 +186,11 @@ struct PrivateChatE2ETests {
// Bob relays private messages for Charlie // Bob relays private messages for Charlie
bob.packetDeliveryHandler = { packet in bob.packetDeliveryHandler = { packet in
if let recipientID = packet.recipientID, if let recipientID = packet.recipientID,
String(data: recipientID, encoding: .utf8) == charlie.peerID { PeerID(data: recipientID) == charlie.peerID {
// Relay to Charlie // Relay to Charlie
var relayPacket = packet var relayPacket = packet
relayPacket.ttl = packet.ttl - 1 relayPacket.ttl = packet.ttl - 1
self.charlie.simulateIncomingPacket(relayPacket) charlie.simulateIncomingPacket(relayPacket)
} }
} }
@@ -388,7 +388,7 @@ struct PublicChatE2ETests {
if let message = BitchatMessage(packet.payload) { if let message = BitchatMessage(packet.payload) {
// Don't relay own messages // Don't relay own messages
guard message.senderPeerID?.id != node.peerID else { return } guard message.senderPeerID != node.peerID else { return }
// Create relay message // Create relay message
let relayMessage = BitchatMessage( let relayMessage = BitchatMessage(
@@ -209,7 +209,7 @@ extension FragmentationTests {
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {} func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
func didUpdateBluetoothState(_ state: CBManagerState) {} func didUpdateBluetoothState(_ state: CBManagerState) {}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
publicMessages.append((peerID, nickname, content)) publicMessages.append((peerID, nickname, content))
} }
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {} func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
+122
View File
@@ -125,16 +125,138 @@ struct GossipSyncManagerTests {
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false) #expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0) #expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
} }
@Test func maintenanceEmitsTypedSyncRequests() throws {
var config = GossipSyncManager.Config()
config.seenCapacity = 10
config.fragmentCapacity = 5
config.fileTransferCapacity = 4
config.messageSyncIntervalSeconds = 1
config.fragmentSyncIntervalSeconds = 1
config.fileTransferSyncIntervalSeconds = 1
config.maintenanceIntervalSeconds = 0
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let delegate = RecordingDelegate()
manager.delegate = delegate
let sender = try #require(Data(hexString: "1122334455667788"))
let now = UInt64(Date().timeIntervalSince1970 * 1000)
let announcePacket = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data(),
signature: nil,
ttl: 1
)
let messagePacket = BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0x01]),
signature: nil,
ttl: 1
)
let fragmentPacket = BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0xAA]),
signature: nil,
ttl: 1
)
let filePacket = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0xBB]),
signature: nil,
ttl: 1,
version: 2
)
manager.onPublicPacketSeen(announcePacket)
manager.onPublicPacketSeen(messagePacket)
manager.onPublicPacketSeen(fragmentPacket)
manager.onPublicPacketSeen(filePacket)
manager._performMaintenanceSynchronously(now: Date())
let sentPackets = delegate.packets
#expect(sentPackets.count == 3)
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
#expect(decoded.count == 3)
#expect(decoded[0].types == .publicMessages)
#expect(decoded[1].types == .fragment)
#expect(decoded[2].types == .fileTransfer)
}
@Test func handleRequestSyncHonorsTypeFilter() async throws {
var config = GossipSyncManager.Config()
config.seenCapacity = 5
config.fragmentCapacity = 5
config.fileTransferCapacity = 0
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let delegate = RecordingDelegate()
manager.delegate = delegate
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
let now = UInt64(Date().timeIntervalSince1970 * 1000)
let messagePacket = BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0x10]),
signature: nil,
ttl: 1
)
let fragmentPacket = BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0x20]),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(messagePacket)
manager.onPublicPacketSeen(fragmentPacket)
let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
manager.handleRequestSync(from: peer, request: request)
try await sleep(0.01)
let sentPackets = delegate.packets
#expect(sentPackets.count == 1)
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
}
} }
private final class RecordingDelegate: GossipSyncManager.Delegate { private final class RecordingDelegate: GossipSyncManager.Delegate {
var onSend: (() -> Void)? var onSend: (() -> Void)?
private(set) var lastPacket: BitchatPacket? private(set) var lastPacket: BitchatPacket?
private(set) var packets: [BitchatPacket] = []
private let lock = NSLock() private let lock = NSLock()
func sendPacket(_ packet: BitchatPacket) { func sendPacket(_ packet: BitchatPacket) {
lock.lock() lock.lock()
lastPacket = packet lastPacket = packet
packets.append(packet)
lock.unlock() lock.unlock()
onSend?() onSend?()
} }
+192
View File
@@ -0,0 +1,192 @@
//
// InputValidatorTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
struct InputValidatorTests {
// MARK: - Basic Validation Tests
@Test func validStringPassesValidation() throws {
let result = InputValidator.validateUserString("Hello World", maxLength: 100)
#expect(result == "Hello World")
}
@Test func emptyStringReturnsNil() throws {
let result = InputValidator.validateUserString("", maxLength: 100)
#expect(result == nil)
}
@Test func whitespaceOnlyStringReturnsNil() throws {
let result = InputValidator.validateUserString(" \n\t ", maxLength: 100)
#expect(result == nil)
}
@Test func stringExceedingMaxLengthReturnsNil() throws {
let longString = String(repeating: "a", count: 101)
let result = InputValidator.validateUserString(longString, maxLength: 100)
#expect(result == nil)
}
@Test func stringAtMaxLengthIsAccepted() throws {
let exactString = String(repeating: "a", count: 100)
let result = InputValidator.validateUserString(exactString, maxLength: 100)
#expect(result == exactString)
}
@Test func whitespaceIsTrimmed() throws {
let result = InputValidator.validateUserString(" Hello ", maxLength: 100)
#expect(result == "Hello")
}
// MARK: - Control Character Tests
@Test func nullCharacterIsRejected() throws {
let stringWithNull = "Hello\u{0000}World"
let result = InputValidator.validateUserString(stringWithNull, maxLength: 100)
#expect(result == nil)
}
@Test func bellCharacterIsRejected() throws {
let stringWithBell = "Hello\u{0007}World"
let result = InputValidator.validateUserString(stringWithBell, maxLength: 100)
#expect(result == nil)
}
@Test func backspaceCharacterIsRejected() throws {
let stringWithBackspace = "Hello\u{0008}World"
let result = InputValidator.validateUserString(stringWithBackspace, maxLength: 100)
#expect(result == nil)
}
@Test func escapeCharacterIsRejected() throws {
let stringWithEscape = "Hello\u{001B}World"
let result = InputValidator.validateUserString(stringWithEscape, maxLength: 100)
#expect(result == nil)
}
@Test func deleteCharacterIsRejected() throws {
let stringWithDelete = "Hello\u{007F}World"
let result = InputValidator.validateUserString(stringWithDelete, maxLength: 100)
#expect(result == nil)
}
@Test func multipleControlCharactersAreRejected() throws {
let stringWithMultiple = "Hello\u{0000}\u{0007}\u{001B}World"
let result = InputValidator.validateUserString(stringWithMultiple, maxLength: 100)
#expect(result == nil)
}
// MARK: - Unicode and Special Character Tests
@Test func emojiIsAccepted() throws {
let result = InputValidator.validateUserString("Hello 👋 World", maxLength: 100)
#expect(result == "Hello 👋 World")
}
@Test func unicodeCharactersAreAccepted() throws {
let result = InputValidator.validateUserString("Hello 世界 مرحبا", maxLength: 100)
#expect(result == "Hello 世界 مرحبا")
}
@Test func specialCharactersAreAccepted() throws {
let result = InputValidator.validateUserString("Hello!@#$%^&*()_+-=[]{}|;':\",./<>?", maxLength: 100)
#expect(result == "Hello!@#$%^&*()_+-=[]{}|;':\",./<>?")
}
// MARK: - Nickname Validation Tests
@Test func validNicknameIsAccepted() throws {
let result = InputValidator.validateNickname("Alice")
#expect(result == "Alice")
}
@Test func nicknameWithEmojiIsAccepted() throws {
let result = InputValidator.validateNickname("Alice 🚀")
#expect(result == "Alice 🚀")
}
@Test func nicknameTooLongIsRejected() throws {
let longNickname = String(repeating: "a", count: 51)
let result = InputValidator.validateNickname(longNickname)
#expect(result == nil)
}
@Test func nicknameAtMaxLengthIsAccepted() throws {
let exactNickname = String(repeating: "a", count: 50)
let result = InputValidator.validateNickname(exactNickname)
#expect(result == exactNickname)
}
@Test func nicknameWithControlCharacterIsRejected() throws {
let result = InputValidator.validateNickname("Alice\u{0000}")
#expect(result == nil)
}
// MARK: - Timestamp Validation Tests
@Test func currentTimestampIsValid() throws {
let now = Date()
let result = InputValidator.validateTimestamp(now)
#expect(result == true)
}
@Test func timestampWithinOneHourIsValid() throws {
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
let result = InputValidator.validateTimestamp(thirtyMinutesAgo)
#expect(result == true)
}
@Test func timestampTwoHoursAgoIsInvalid() throws {
let twoHoursAgo = Date().addingTimeInterval(-2 * 3600)
let result = InputValidator.validateTimestamp(twoHoursAgo)
#expect(result == false)
}
@Test func timestampTwoHoursInFutureIsInvalid() throws {
let twoHoursFromNow = Date().addingTimeInterval(2 * 3600)
let result = InputValidator.validateTimestamp(twoHoursFromNow)
#expect(result == false)
}
@Test func timestampAtOneHourBoundaryIsValid() throws {
// Just slightly within the one-hour window
let almostOneHourAgo = Date().addingTimeInterval(-3599)
let result = InputValidator.validateTimestamp(almostOneHourAgo)
#expect(result == true)
}
// MARK: - Edge Cases
@Test func singleCharacterStringIsAccepted() throws {
let result = InputValidator.validateUserString("a", maxLength: 100)
#expect(result == "a")
}
@Test func stringWithOnlyNewlinesIsRejected() throws {
let result = InputValidator.validateUserString("\n\n\n", maxLength: 100)
#expect(result == nil)
}
@Test func stringWithMixedWhitespaceIsTrimmed() throws {
let result = InputValidator.validateUserString(" \t\nHello\n\t ", maxLength: 100)
#expect(result == "Hello")
}
@Test func stringWithLeadingControlCharacterIsRejected() throws {
let result = InputValidator.validateUserString("\u{0000}Hello", maxLength: 100)
#expect(result == nil)
}
@Test func stringWithTrailingControlCharacterIsRejected() throws {
let result = InputValidator.validateUserString("Hello\u{0000}", maxLength: 100)
#expect(result == nil)
}
}
+90
View File
@@ -0,0 +1,90 @@
//
// MimeTypeTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
// MARK: - MimeType Mapping and Signature Tests
struct MimeTypeTests {
// MARK: MIME Enum Parsing + Default Extension
@Test(arguments: [
("image/jpeg", MimeType.jpeg, "jpg"),
("image/jpg", MimeType.jpeg, "jpg"),
("image/png", MimeType.png, "png"),
("image/gif", MimeType.gif, "gif"),
("image/webp", MimeType.webp, "webp"),
("audio/mp4", MimeType.mp4Audio, "m4a"),
("audio/m4a", MimeType.m4a, "m4a"),
("audio/aac", MimeType.aac, "m4a"),
("audio/mpeg", MimeType.mpeg, "mp3"),
("audio/mp3", MimeType.mp3, "mp3"),
("audio/wav", MimeType.wav, "wav"),
("audio/x-wav", MimeType.xWav, "wav"),
("audio/ogg", MimeType.ogg, "ogg"),
("application/pdf", MimeType.pdf, "pdf"),
("application/octet-stream", MimeType.octetStream, "bin")
])
func mimeTypeParsingAndExtensions(
mimeString: String,
expectedType: MimeType,
expectedExt: String
) throws {
guard let mime = MimeType(mimeString) else {
Issue.record("Failed to parse \(mimeString)")
return
}
#expect(mime == expectedType, "Expected \(expectedType) for \(mimeString)")
#expect(mime.mimeString == expectedType.mimeString)
#expect(mime.defaultExtension == expectedExt)
#expect(mime.isAllowed)
}
// MARK: - File Signature Validation
@Test(arguments: [
// === Image types ===
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
// === Audio types ===
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
// === Application types ===
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
])
func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
let data = Data(bytes)
#expect(mime.matches(data: data),
"Expected \(mime.mimeString) to match its signature")
}
// MARK: - Negative Tests
@Test func invalidDataDoesNotMatch() throws {
let badData = Data(repeating: 0x00, count: 16)
for mime in MimeType.allCases where mime != .octetStream {
#expect(!mime.matches(data: badData),
"Unexpectedly matched \(mime.mimeString) with zeroed data")
}
}
// MARK: - Octet-stream (generic binary)
@Test func octetStreamAlwaysMatches() throws {
let randomData = Data([0x00, 0x11, 0x22, 0x33])
#expect(MimeType.octetStream.matches(data: randomData),
"application/octet-stream should always be considered valid")
}
}
+1 -4
View File
@@ -203,10 +203,7 @@ final class MockBLEService: NSObject {
let target = bus.service(for: recipientPeerID) { let target = bus.service(for: recipientPeerID) {
target.simulateIncomingPacket(packet) target.simulateIncomingPacket(packet)
} else { } else {
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known // Not directly connected: deliver to neighbors for relay
if let target = bus.service(for: recipientPeerID) {
target.simulateIncomingPacket(packet)
}
for neighbor in neighbors() where neighbor.peerID != recipientPeerID { for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
neighbor.simulateIncomingPacket(packet) neighbor.simulateIncomingPacket(packet)
} }
@@ -54,6 +54,86 @@ struct BinaryProtocolTests {
#expect(decodedPacket.signature != nil) #expect(decodedPacket.signature != nil)
#expect(decodedPacket.signature == TestConstants.testSignature) #expect(decodedPacket.signature == TestConstants.testSignature)
} }
@Test func packetWithRouteRoundTrip() throws {
let route: [Data] = [
try #require(Data(hexString: "0102030405060708")),
try #require(Data(hexString: "1112131415161718")),
try #require(Data(hexString: "2122232425262728"))
]
var packet = BitchatPacket(
type: 0x01,
senderID: route[0],
recipientID: route.last,
timestamp: 1_720_000_000_000,
payload: Data("route-test".utf8),
signature: nil,
ttl: 6
)
packet.route = route
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route")
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0)
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route")
let decodedRoute = try #require(decoded.route)
#expect(decodedRoute.count == route.count)
for (expected, actual) in zip(route, decodedRoute) {
#expect(actual == expected)
}
}
@Test func packetWithRoutePadsShortHop() throws {
let sender = try #require(Data(hexString: "0011223344556677"))
let destination = try #require(Data(hexString: "8899aabbccddeeff"))
let shortHop = Data([0xAA, 0xBB, 0xCC])
var packet = BitchatPacket(
type: 0x02,
senderID: sender,
recipientID: destination,
timestamp: 1_730_000_000_000,
payload: Data("pad-test".utf8),
signature: nil,
ttl: 5
)
packet.route = [shortHop, destination]
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with short hop route")
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with short hop route")
let decodedRoute = try #require(decoded.route)
let firstHop = try #require(decodedRoute.first)
#expect(firstHop.count == BinaryProtocol.senderIDSize)
#expect(firstHop.prefix(shortHop.count) == shortHop)
let paddingBytes = firstHop.suffix(firstHop.count - shortHop.count)
#expect(paddingBytes.allSatisfy { $0 == 0 })
}
@Test func packetWithRouteAndCompressedPayload() throws {
let route: [Data] = [
try #require(Data(hexString: "0101010101010101")),
try #require(Data(hexString: "0202020202020202"))
]
let repeatedString = String(repeating: "compress-me", count: 150)
var packet = BitchatPacket(
type: 0x03,
senderID: route[0],
recipientID: route.last,
timestamp: 1_740_000_000_000,
payload: Data(repeatedString.utf8),
signature: nil,
ttl: 7
)
packet.route = route
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route and compression")
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route and compression")
#expect(decoded.payload == Data(repeatedString.utf8))
let decodedRoute = try #require(decoded.route)
#expect(decodedRoute == route)
}
// MARK: - Compression Tests // MARK: - Compression Tests
@@ -0,0 +1,86 @@
//
// MeshTopologyTrackerTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
struct MeshTopologyTrackerTests {
private func hex(_ value: String) throws -> Data {
try #require(Data(hexString: value))
}
@Test func directLinkProducesRoute() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0102030405060708")
let b = try hex("1112131415161718")
tracker.recordDirectLink(between: a, and: b)
let route = try #require(tracker.computeRoute(from: a, to: b))
#expect(route == [a, b])
}
@Test func multiHopRouteComputation() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0001020304050607")
let b = try hex("1011121314151617")
let c = try hex("2021222324252627")
let d = try hex("3031323334353637")
tracker.recordDirectLink(between: a, and: b)
tracker.recordDirectLink(between: b, and: c)
tracker.recordDirectLink(between: c, and: d)
let route = try #require(tracker.computeRoute(from: a, to: d))
#expect(route == [a, b, c, d])
}
@Test func recordRouteAddsEdges() throws {
let tracker = MeshTopologyTracker()
var a = Data([0xAA, 0xBB, 0xCC])
let b = try hex("4445464748494A4B")
let c = try hex("5455565758595A5B")
tracker.recordRoute([a, b, c])
a.append(Data(repeating: 0, count: BinaryProtocol.senderIDSize - a.count))
let route = try #require(tracker.computeRoute(from: a, to: c))
#expect(route.first == a)
#expect(route.last == c)
}
@Test func removingDirectLinkBreaksRoute() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0101010101010101")
let b = try hex("0202020202020202")
let c = try hex("0303030303030303")
tracker.recordDirectLink(between: a, and: b)
tracker.recordDirectLink(between: b, and: c)
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
#expect(initialRoute == [a, b, c])
tracker.removeDirectLink(between: b, and: c)
#expect(tracker.computeRoute(from: a, to: c) == nil)
}
@Test func removingPeerClearsEdges() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0F0E0D0C0B0A0908")
let b = try hex("0A0B0C0D0E0F0001")
let c = try hex("0011223344556677")
tracker.recordRoute([a, b, c])
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
#expect(initialRoute == [a, b, c])
tracker.removePeer(b)
#expect(tracker.computeRoute(from: a, to: c) == nil)
}
}
+31 -37
View File
@@ -217,7 +217,27 @@ struct PeerIDTests {
let short = peerID.toShort() let short = peerID.toShort()
#expect(short == peerID) #expect(short == peerID)
} }
@Test func routingData_fromShortID() throws {
let peerID = PeerID(str: hex16)
let routing = try #require(peerID.routingData)
#expect(routing.count == 8)
#expect(routing == Data(hexString: hex16))
}
@Test func routingData_fromNoiseKey() throws {
let peerID = PeerID(str: hex64)
let routing = try #require(peerID.routingData)
let expectedShort = peerID.toShort()
#expect(routing == Data(hexString: expectedShort.id))
}
@Test func routingPeerRoundTrip() throws {
let raw = try #require(Data(hexString: hex16))
let peerID = try #require(PeerID(routingData: raw))
#expect(peerID.routingData == raw)
}
// MARK: - Codable // MARK: - Codable
@Test func codable_emptyPrefix() throws { @Test func codable_emptyPrefix() throws {
@@ -273,7 +293,7 @@ struct PeerIDTests {
@Test func comparable_sorting_and_equality() { @Test func comparable_sorting_and_equality() {
let p1 = PeerID(str: "aaa") let p1 = PeerID(str: "aaa")
let p2 = PeerID(str: "bbb") let p2 = PeerID(str: "bbb")
let p3 = PeerID(str: "bbb") let p3 = PeerID(str: "BBB")
#expect(p1 < p2) #expect(p1 < p2)
#expect(p2 >= p1) #expect(p2 >= p1)
@@ -284,44 +304,18 @@ struct PeerIDTests {
} }
@Test func equality() { @Test func equality() {
let string = "aaa" let peerID = PeerID(str: "aaa")
let peerID = PeerID(str: string)
let badString = "bbb"
// PeerID == String
#expect(peerID == string)
#expect(peerID == Optional(string))
#expect(Optional(peerID) == string)
#expect(Optional(peerID) == Optional(string))
// PeerID != String
#expect(peerID != badString)
#expect(peerID != Optional(badString))
#expect(Optional(peerID) != badString)
#expect(Optional(peerID) != Optional(badString))
// String == PeerID
#expect(string == peerID)
#expect(Optional(string) == peerID)
#expect(string == Optional(peerID))
#expect(Optional(string) == Optional(peerID))
// String != PeerID
#expect(badString != peerID)
#expect(Optional(badString) != peerID)
#expect(badString != Optional(peerID))
#expect(Optional(badString) != Optional(peerID))
// Regular PeerID <> PeerID // Regular PeerID <> PeerID
#expect(peerID == PeerID(str: "aaa")) #expect(peerID == PeerID(str: "AAA"))
#expect(peerID == Optional(PeerID(str: "aaa"))) #expect(peerID == Optional(PeerID(str: "AAA")))
#expect(PeerID(str: "aaa") == peerID) #expect(PeerID(str: "AAA") == peerID)
#expect(Optional(PeerID(str: "aaa")) == Optional(peerID)) #expect(Optional(PeerID(str: "AAA")) == Optional(peerID))
#expect(peerID != PeerID(str: "bbb")) #expect(peerID != PeerID(str: "BBB"))
#expect(peerID != Optional(PeerID(str: "bbb"))) #expect(peerID != Optional(PeerID(str: "BBB")))
#expect(PeerID(str: "bbb") != peerID) #expect(PeerID(str: "BBB") != peerID)
#expect(Optional(PeerID(str: "bbb")) != Optional(peerID)) #expect(Optional(PeerID(str: "BBB")) != Optional(peerID))
} }
// MARK: - Computed properties // MARK: - Computed properties