mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:45:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97fc21c23f | ||
|
|
79bd4af912 | ||
|
|
96c78ef5a2 | ||
|
|
01256f3041 | ||
|
|
2edbe2bbbd | ||
|
|
a91978c10e |
@@ -57,11 +57,9 @@ struct BitchatApp: App {
|
||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#elseif os(macOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#endif
|
||||
|
||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||
NetworkActivationService.shared.start()
|
||||
// Check for shared content
|
||||
@@ -189,6 +187,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
chatViewModel?.applicationWillTerminate()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -21,8 +21,9 @@ struct BitchatPacket: Codable {
|
||||
let payload: Data
|
||||
var signature: Data?
|
||||
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.type = type
|
||||
self.senderID = senderID
|
||||
@@ -31,6 +32,7 @@ struct BitchatPacket: Codable {
|
||||
self.payload = payload
|
||||
self.signature = signature
|
||||
self.ttl = ttl
|
||||
self.route = route
|
||||
}
|
||||
|
||||
// Convenience initializer for new binary format
|
||||
@@ -53,6 +55,7 @@ struct BitchatPacket: Codable {
|
||||
self.payload = payload
|
||||
self.signature = nil
|
||||
self.ttl = ttl
|
||||
self.route = nil
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
@@ -81,7 +84,8 @@ struct BitchatPacket: Codable {
|
||||
payload: payload,
|
||||
signature: nil, // Remove signature for signing
|
||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
version: version
|
||||
version: version,
|
||||
route: route
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -136,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
|
||||
|
||||
extension PeerID {
|
||||
|
||||
@@ -137,6 +137,7 @@ struct BinaryProtocol {
|
||||
static let hasRecipient: UInt8 = 0x01
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
static let isCompressed: UInt8 = 0x04
|
||||
static let hasRoute: UInt8 = 0x08
|
||||
}
|
||||
|
||||
// Encode BitchatPacket to binary format
|
||||
@@ -160,8 +161,21 @@ struct BinaryProtocol {
|
||||
}
|
||||
|
||||
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 payloadDataSize = payload.count + originalSizeFieldBytes
|
||||
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
|
||||
|
||||
if version == 1 && payloadDataSize > Int(UInt16.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.signature != nil { flags |= Flags.hasSignature }
|
||||
if isCompressed { flags |= Flags.isCompressed }
|
||||
if hasRoute { flags |= Flags.hasRoute }
|
||||
data.append(flags)
|
||||
|
||||
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 version == 2 {
|
||||
let value = UInt32(originalSize)
|
||||
@@ -321,9 +343,27 @@ struct BinaryProtocol {
|
||||
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
|
||||
if isCompressed {
|
||||
guard payloadLength >= lengthFieldBytes else { return nil }
|
||||
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
|
||||
let originalSize: Int
|
||||
if version == 2 {
|
||||
guard let rawSize = read32() else { return nil }
|
||||
@@ -332,16 +372,12 @@ struct BinaryProtocol {
|
||||
guard let rawSize = read16() else { return nil }
|
||||
originalSize = Int(rawSize)
|
||||
}
|
||||
// Guard to keep decompression bounded to sane BLE payload limits
|
||||
// Use maxFramedFileBytes to account for TLV overhead in file transfer payloads
|
||||
remainingPayloadBytes -= lengthFieldBytes
|
||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||
let compressedSize = payloadLength - lengthFieldBytes
|
||||
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
||||
let compressedSize = remainingPayloadBytes
|
||||
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)
|
||||
guard compressionRatio <= 50_000.0 else {
|
||||
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||
@@ -352,7 +388,9 @@ struct BinaryProtocol {
|
||||
decompressed.count == originalSize else { return nil }
|
||||
payload = decompressed
|
||||
} else {
|
||||
guard let rawPayload = readData(payloadLength) else { return nil }
|
||||
guard remainingPayloadBytes >= 0,
|
||||
let rawPayload = readData(remainingPayloadBytes) else { return nil }
|
||||
remainingPayloadBytes = 0
|
||||
payload = rawPayload
|
||||
}
|
||||
|
||||
@@ -372,7 +410,8 @@ struct BinaryProtocol {
|
||||
payload: payload,
|
||||
signature: signature,
|
||||
ttl: ttl,
|
||||
version: version
|
||||
version: version,
|
||||
route: route
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,4 +116,18 @@ enum ChannelID: Equatable, Codable {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ struct AnnouncementPacket {
|
||||
let nickname: String
|
||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||
let signingPublicKey: Data // Ed25519 public key for signing
|
||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case nickname = 0x01
|
||||
case noisePublicKey = 0x02
|
||||
case signingPublicKey = 0x03
|
||||
case directNeighbors = 0x04
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
@@ -35,6 +37,16 @@ struct AnnouncementPacket {
|
||||
data.append(TLVType.signingPublicKey.rawValue)
|
||||
data.append(UInt8(signingPublicKey.count))
|
||||
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
|
||||
}
|
||||
@@ -44,6 +56,7 @@ struct AnnouncementPacket {
|
||||
var nickname: String?
|
||||
var noisePublicKey: Data?
|
||||
var signingPublicKey: Data?
|
||||
var directNeighbors: [Data]?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
let typeRaw = data[offset]
|
||||
@@ -63,6 +76,17 @@ struct AnnouncementPacket {
|
||||
noisePublicKey = Data(value)
|
||||
case .signingPublicKey:
|
||||
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 {
|
||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||
@@ -74,7 +98,8 @@ struct AnnouncementPacket {
|
||||
return AnnouncementPacket(
|
||||
nickname: nickname,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey
|
||||
signingPublicKey: signingPublicKey,
|
||||
directNeighbors: directNeighbors
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ final class BLEService: NSObject {
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
return formatter
|
||||
}()
|
||||
private let meshTopology = MeshTopologyTracker()
|
||||
|
||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||
private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 }
|
||||
@@ -569,6 +570,7 @@ final class BLEService: NSObject {
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
meshTopology.reset()
|
||||
}
|
||||
|
||||
// MARK: Connectivity and peers
|
||||
@@ -726,6 +728,8 @@ final class BLEService: NSObject {
|
||||
version: 2
|
||||
)
|
||||
|
||||
self.applyRouteIfAvailable(&packet, to: peerID)
|
||||
|
||||
if let signed = self.noiseService.signPacket(packet) {
|
||||
packet = signed
|
||||
}
|
||||
@@ -745,7 +749,7 @@ final class BLEService: NSObject {
|
||||
SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session)
|
||||
do {
|
||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
var packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID.id),
|
||||
@@ -754,6 +758,7 @@ final class BLEService: NSObject {
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
applyRouteIfAvailable(&packet, to: peerID)
|
||||
broadcastPacket(packet)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to send read receipt: \(error)")
|
||||
@@ -1226,7 +1231,7 @@ final class BLEService: NSObject {
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
do {
|
||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
var packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID.id),
|
||||
@@ -1235,6 +1240,7 @@ final class BLEService: NSObject {
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
applyRouteIfAvailable(&packet, to: peerID)
|
||||
broadcastPacket(packet)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to send delivery ACK: \(error)")
|
||||
@@ -1407,10 +1413,15 @@ final class BLEService: NSObject {
|
||||
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
|
||||
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
|
||||
|
||||
let connectedPeerIDs: [Data] = collectionsQueue.sync {
|
||||
peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData }
|
||||
}
|
||||
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: myNickname,
|
||||
noisePublicKey: noisePub,
|
||||
signingPublicKey: signingPub
|
||||
signingPublicKey: signingPub,
|
||||
directNeighbors: connectedPeerIDs
|
||||
)
|
||||
|
||||
guard let payload = announcement.encode() else {
|
||||
@@ -1739,6 +1750,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
||||
peers[peerID] = info
|
||||
}
|
||||
}
|
||||
clearDirectLink(with: peerID)
|
||||
}
|
||||
|
||||
// Restart scanning with allow duplicates for faster rediscovery
|
||||
@@ -2026,6 +2038,7 @@ extension BLEService: CBPeripheralDelegate {
|
||||
peripherals[peripheralUUID] = state
|
||||
}
|
||||
peerToPeripheralUUID[senderID] = peripheralUUID
|
||||
registerDirectLink(with: senderID)
|
||||
}
|
||||
|
||||
let msgID = makeMessageID(for: packet)
|
||||
@@ -2192,6 +2205,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
|
||||
// Clean up mappings
|
||||
centralToPeerID.removeValue(forKey: centralUUID)
|
||||
clearDirectLink(with: peerID)
|
||||
|
||||
// Update UI immediately
|
||||
notifyUI { [weak self] in
|
||||
@@ -2308,7 +2322,10 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
subscribedCentrals.append(sorted[0].central)
|
||||
}
|
||||
if packet.type == MessageType.announce.rawValue {
|
||||
if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID }
|
||||
if packet.ttl == messageTTL {
|
||||
centralToPeerID[centralUUID] = senderID
|
||||
registerDirectLink(with: senderID)
|
||||
}
|
||||
// Record ingress link for last-hop suppression then process
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
@@ -2418,6 +2435,63 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
|
||||
private func routingData(for peerID: PeerID) -> Data? {
|
||||
peerID.toShort().routingData
|
||||
}
|
||||
|
||||
private func registerDirectLink(with peerID: PeerID) {
|
||||
meshTopology.recordDirectLink(between: myPeerIDData, and: routingData(for: peerID))
|
||||
}
|
||||
|
||||
private func clearDirectLink(with peerID: PeerID) {
|
||||
meshTopology.removeDirectLink(between: myPeerIDData, and: routingData(for: peerID))
|
||||
}
|
||||
|
||||
private func registerRoute(_ route: [Data]?) {
|
||||
guard let hops = route, !hops.isEmpty else { return }
|
||||
meshTopology.recordRoute(hops)
|
||||
}
|
||||
|
||||
private func computeRoute(to peerID: PeerID) -> [Data]? {
|
||||
meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID))
|
||||
}
|
||||
|
||||
private func applyRouteIfAvailable(_ packet: inout BitchatPacket, to recipient: PeerID) {
|
||||
guard let route = computeRoute(to: recipient), route.count >= 2 else { return }
|
||||
packet.route = route
|
||||
meshTopology.recordRoute(route)
|
||||
}
|
||||
|
||||
private func routingPeer(from data: Data) -> PeerID? {
|
||||
PeerID(routingData: data)
|
||||
}
|
||||
|
||||
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
|
||||
guard let route = packet.route, !route.isEmpty else { return false }
|
||||
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
|
||||
guard let selfData = myRoutingData,
|
||||
let index = route.firstIndex(of: selfData) else { return false }
|
||||
|
||||
// No further hops: respect explicit route termination
|
||||
if index == route.count - 1 {
|
||||
return true
|
||||
}
|
||||
|
||||
guard packet.ttl > 1 else { return true }
|
||||
|
||||
let nextHopData = route[index + 1]
|
||||
guard let nextPeer = routingPeer(from: nextHopData),
|
||||
isPeerConnected(nextPeer) else {
|
||||
return false
|
||||
}
|
||||
|
||||
registerDirectLink(with: nextPeer)
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl = packet.ttl - 1
|
||||
sendPacketDirected(relayPacket, to: nextPeer)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Safely fetch the current direct-link state for a peer using the BLE queue.
|
||||
private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) {
|
||||
let computeState = { () -> (Bool, Bool) in
|
||||
@@ -2451,6 +2525,7 @@ extension BLEService {
|
||||
let fingerprint = noiseService.getIdentityFingerprint()
|
||||
myPeerID = PeerID(str: fingerprint.prefix(16))
|
||||
myPeerIDData = Data(hexString: myPeerID.id) ?? Data()
|
||||
meshTopology.reset()
|
||||
}
|
||||
|
||||
private func restartGossipManager() {
|
||||
@@ -2469,7 +2544,7 @@ extension BLEService {
|
||||
}
|
||||
do {
|
||||
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
var packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID.id),
|
||||
@@ -2478,6 +2553,7 @@ extension BLEService {
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
applyRouteIfAvailable(&packet, to: peerID)
|
||||
broadcastPacket(packet)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to send verification payload: \(error)")
|
||||
@@ -2696,7 +2772,7 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
let packet = BitchatPacket(
|
||||
var packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: recipientData,
|
||||
@@ -2705,6 +2781,7 @@ extension BLEService {
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
applyRouteIfAvailable(&packet, to: recipientID)
|
||||
|
||||
broadcastPacket(packet)
|
||||
|
||||
@@ -2744,7 +2821,7 @@ extension BLEService {
|
||||
let handshakeData = try noiseService.initiateHandshake(with: peerID)
|
||||
|
||||
// Send handshake init
|
||||
let packet = BitchatPacket(
|
||||
var packet = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID.id),
|
||||
@@ -2753,6 +2830,7 @@ extension BLEService {
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
applyRouteIfAvailable(&packet, to: peerID)
|
||||
broadcastPacket(packet)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to initiate handshake: \(error)")
|
||||
@@ -2786,7 +2864,7 @@ extension BLEService {
|
||||
|
||||
let encrypted = try noiseService.encrypt(messagePayload, for: peerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
var packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID.id),
|
||||
@@ -2795,6 +2873,7 @@ extension BLEService {
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
applyRouteIfAvailable(&packet, to: peerID)
|
||||
|
||||
// We're already on messageQueue from the callback
|
||||
broadcastPacket(packet)
|
||||
@@ -2946,7 +3025,8 @@ extension BLEService {
|
||||
timestamp: packet.timestamp,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: packet.ttl
|
||||
ttl: packet.ttl,
|
||||
route: packet.route
|
||||
)
|
||||
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
@@ -3161,6 +3241,11 @@ extension BLEService {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
registerRoute(packet.route)
|
||||
if peerID != myPeerID && packet.ttl == messageTTL {
|
||||
registerDirectLink(with: peerID)
|
||||
}
|
||||
|
||||
// Deduplication (thread-safe)
|
||||
let senderID = PeerID(hexData: packet.senderID)
|
||||
@@ -3245,6 +3330,10 @@ extension BLEService {
|
||||
break
|
||||
}
|
||||
|
||||
if forwardAlongRouteIfNeeded(packet) {
|
||||
return
|
||||
}
|
||||
|
||||
// Relay if TTL > 1 and we're not the original sender
|
||||
// Relay decision and scheduling (extracted via RelayController)
|
||||
do {
|
||||
@@ -3591,7 +3680,7 @@ extension BLEService {
|
||||
do {
|
||||
if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) {
|
||||
// Send response
|
||||
let responsePacket = BitchatPacket(
|
||||
var responsePacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID.id),
|
||||
@@ -3600,6 +3689,7 @@ extension BLEService {
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
applyRouteIfAvailable(&responsePacket, to: peerID)
|
||||
// We're on messageQueue from delegate callback
|
||||
broadcastPacket(responsePacket)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -43,8 +43,6 @@ struct ContentView: View {
|
||||
@State private var showPeerList = false
|
||||
@State private var showSidebar = false
|
||||
@State private var showAppInfo = false
|
||||
@State private var showCommandSuggestions = false
|
||||
@State private var commandSuggestions: [String] = []
|
||||
@State private var showMessageActions = false
|
||||
@State private var selectedMessageSender: String?
|
||||
@State private var selectedMessageSenderID: PeerID?
|
||||
@@ -605,77 +603,12 @@ struct ContentView: View {
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
// Command suggestions
|
||||
if showCommandSuggestions && !commandSuggestions.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Define commands with aliases and syntax
|
||||
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||
(["/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)
|
||||
}
|
||||
CommandSuggestionsView(
|
||||
messageText: $messageText,
|
||||
textColor: textColor,
|
||||
backgroundColor: backgroundColor,
|
||||
secondaryTextColor: secondaryTextColor
|
||||
)
|
||||
|
||||
// Recording indicator
|
||||
if isPreparingVoiceNote || isRecordingVoiceNote {
|
||||
@@ -709,68 +642,11 @@ struct ContentView: View {
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.onChange(of: messageText) { newValue in
|
||||
// Cancel previous debounce timer
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
|
||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
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) {
|
||||
@@ -787,7 +663,7 @@ struct ContentView: View {
|
||||
.padding(.bottom, 8)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
|
||||
|
||||
private func handleOpenURL(_ url: URL) {
|
||||
guard url.scheme == "bitchat" else { return }
|
||||
switch url.host {
|
||||
|
||||
@@ -54,6 +54,86 @@ struct BinaryProtocolTests {
|
||||
#expect(decodedPacket.signature != nil)
|
||||
#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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -217,7 +217,27 @@ struct PeerIDTests {
|
||||
let short = peerID.toShort()
|
||||
#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
|
||||
|
||||
@Test func codable_emptyPrefix() throws {
|
||||
|
||||
Reference in New Issue
Block a user