mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:05:20 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc5bdf5ec | ||
|
|
fcd82c28c6 | ||
|
|
4e51c83914 | ||
|
|
d9884eaf87 | ||
|
|
a486644b7f | ||
|
|
5273f13512 |
@@ -7,14 +7,6 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
04657AB52E633C6A002D5BBE /* NostrPoW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB42E633C6A002D5BBE /* NostrPoW.swift */; };
|
|
||||||
04657AB62E633C6A002D5BBE /* NostrPoW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB42E633C6A002D5BBE /* NostrPoW.swift */; };
|
|
||||||
04657AB82E633C76002D5BBE /* PowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB72E633C76002D5BBE /* PowPolicy.swift */; };
|
|
||||||
04657AB92E633C76002D5BBE /* PowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB72E633C76002D5BBE /* PowPolicy.swift */; };
|
|
||||||
04657ABB2E633C94002D5BBE /* MiningAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */; };
|
|
||||||
04657ABC2E633C94002D5BBE /* MiningAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */; };
|
|
||||||
04657ABE2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */; };
|
|
||||||
04657ABF2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */; };
|
|
||||||
047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; };
|
047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; };
|
||||||
047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; };
|
047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; };
|
||||||
047502872E5416250083520F /* Geohash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502852E5416250083520F /* Geohash.swift */; };
|
047502872E5416250083520F /* Geohash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502852E5416250083520F /* Geohash.swift */; };
|
||||||
@@ -205,10 +197,6 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
04657AB42E633C6A002D5BBE /* NostrPoW.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoW.swift; sourceTree = "<group>"; };
|
|
||||||
04657AB72E633C76002D5BBE /* PowPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowPolicy.swift; sourceTree = "<group>"; };
|
|
||||||
04657ABA2E633C94002D5BBE /* MiningAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiningAnimation.swift; sourceTree = "<group>"; };
|
|
||||||
04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoWTests.swift; sourceTree = "<group>"; };
|
|
||||||
0475027E2E53A0FC0083520F /* FragmentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FragmentationTests.swift; sourceTree = "<group>"; };
|
0475027E2E53A0FC0083520F /* FragmentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FragmentationTests.swift; sourceTree = "<group>"; };
|
||||||
047502852E5416250083520F /* Geohash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geohash.swift; sourceTree = "<group>"; };
|
047502852E5416250083520F /* Geohash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geohash.swift; sourceTree = "<group>"; };
|
||||||
047502862E5416250083520F /* LocationChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannel.swift; sourceTree = "<group>"; };
|
047502862E5416250083520F /* LocationChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannel.swift; sourceTree = "<group>"; };
|
||||||
@@ -463,7 +451,6 @@
|
|||||||
A55126E93155456CAA8D6656 /* Views */ = {
|
A55126E93155456CAA8D6656 /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
04657ABA2E633C94002D5BBE /* MiningAnimation.swift */,
|
|
||||||
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
|
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
|
||||||
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
|
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
|
||||||
047502B32E55FED60083520F /* MeshPeerList.swift */,
|
047502B32E55FED60083520F /* MeshPeerList.swift */,
|
||||||
@@ -502,9 +489,8 @@
|
|||||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
|
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
|
||||||
04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */,
|
|
||||||
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
|
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
|
||||||
|
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
||||||
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
||||||
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
||||||
980B109CBA72BC996455C62B /* BLEServiceTests.swift */,
|
980B109CBA72BC996455C62B /* BLEServiceTests.swift */,
|
||||||
@@ -540,7 +526,6 @@
|
|||||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
04657AB72E633C76002D5BBE /* PowPolicy.swift */,
|
|
||||||
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
|
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
|
||||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
||||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||||
@@ -565,7 +550,6 @@
|
|||||||
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
|
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
04657AB42E633C6A002D5BBE /* NostrPoW.swift */,
|
|
||||||
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
|
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
|
||||||
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
|
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
|
||||||
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
||||||
@@ -808,18 +792,15 @@
|
|||||||
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
|
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
|
||||||
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */,
|
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */,
|
||||||
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */,
|
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */,
|
||||||
04657ABC2E633C94002D5BBE /* MiningAnimation.swift in Sources */,
|
|
||||||
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */,
|
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */,
|
||||||
047502892E5416250083520F /* Geohash.swift in Sources */,
|
047502892E5416250083520F /* Geohash.swift in Sources */,
|
||||||
0475028A2E5416250083520F /* LocationChannel.swift in Sources */,
|
0475028A2E5416250083520F /* LocationChannel.swift in Sources */,
|
||||||
04657AB82E633C76002D5BBE /* PowPolicy.swift in Sources */,
|
|
||||||
049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */,
|
049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */,
|
||||||
049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
|
049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
|
||||||
049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
|
049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
|
||||||
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
||||||
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
|
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
|
||||||
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
|
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
|
||||||
04657AB52E633C6A002D5BBE /* NostrPoW.swift in Sources */,
|
|
||||||
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
|
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
|
||||||
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
|
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
|
||||||
);
|
);
|
||||||
@@ -871,18 +852,15 @@
|
|||||||
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
|
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
|
||||||
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */,
|
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */,
|
||||||
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */,
|
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */,
|
||||||
04657ABB2E633C94002D5BBE /* MiningAnimation.swift in Sources */,
|
|
||||||
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */,
|
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */,
|
||||||
047502872E5416250083520F /* Geohash.swift in Sources */,
|
047502872E5416250083520F /* Geohash.swift in Sources */,
|
||||||
047502882E5416250083520F /* LocationChannel.swift in Sources */,
|
047502882E5416250083520F /* LocationChannel.swift in Sources */,
|
||||||
04657AB92E633C76002D5BBE /* PowPolicy.swift in Sources */,
|
|
||||||
049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */,
|
049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */,
|
||||||
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
|
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
|
||||||
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
|
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
|
||||||
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
||||||
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
|
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
|
||||||
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
|
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
|
||||||
04657AB62E633C6A002D5BBE /* NostrPoW.swift in Sources */,
|
|
||||||
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
|
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
|
||||||
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
|
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
|
||||||
);
|
);
|
||||||
@@ -897,7 +875,6 @@
|
|||||||
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */,
|
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */,
|
||||||
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
||||||
04657ABF2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */,
|
|
||||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||||
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
||||||
@@ -921,7 +898,6 @@
|
|||||||
686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */,
|
686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */,
|
||||||
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||||
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
||||||
04657ABE2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */,
|
|
||||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||||
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
import Security
|
|
||||||
import Darwin
|
|
||||||
|
|
||||||
/// NIP-13 Proof-of-Work utilities for Nostr events.
|
|
||||||
/// - Counts leading zero bits and mines a nonce tag to meet a target.
|
|
||||||
enum NostrPoW {
|
|
||||||
/// Table of leading zero bit counts for all 256 byte values.
|
|
||||||
private static let lzTable: [UInt8] = {
|
|
||||||
(0...255).map { v -> UInt8 in
|
|
||||||
var x = UInt8(v)
|
|
||||||
if x == 0 { return 8 }
|
|
||||||
var n: UInt8 = 0
|
|
||||||
while (x & 0x80) == 0 {
|
|
||||||
n &+= 1
|
|
||||||
x <<= 1
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// Count leading zero bits in a 32-byte hash.
|
|
||||||
static func leadingZeroBits(_ data: Data) -> Int {
|
|
||||||
var total = 0
|
|
||||||
for b in data {
|
|
||||||
let lz = Int(lzTable[Int(b)])
|
|
||||||
total += lz
|
|
||||||
if lz != 8 { break }
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute Nostr event id hash (SHA-256 over canonical serialization) for given fields.
|
|
||||||
private static func eventIDHash(pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String) -> (hex: String, data: Data) {
|
|
||||||
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
|
|
||||||
let json = try! JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
|
||||||
let digest = SHA256.hash(data: json)
|
|
||||||
let data = Data(digest)
|
|
||||||
let hex = data.map { String(format: "%02x", $0) }.joined()
|
|
||||||
return (hex, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mine a NIP-13 nonce tag that satisfies the target leading-zero bits.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - pubkey: x-only hex pubkey (64-hex chars)
|
|
||||||
/// - createdAt: seconds since epoch
|
|
||||||
/// - kind: event kind
|
|
||||||
/// - baseTags: existing tags (e.g., [ ["g", geohash], ["n", nickname], ... ])
|
|
||||||
/// - content: event content
|
|
||||||
/// - targetBits: required leading zero bits
|
|
||||||
/// - startNonce: optional starting nonce (random if nil)
|
|
||||||
/// - Returns: (nonce, idHex)
|
|
||||||
static func mine(pubkey: String,
|
|
||||||
createdAt: Int,
|
|
||||||
kind: Int,
|
|
||||||
baseTags: [[String]],
|
|
||||||
content: String,
|
|
||||||
targetBits: Int,
|
|
||||||
startNonce: UInt64? = nil) -> (nonce: UInt64, idHex: String) {
|
|
||||||
var nonce: UInt64 = startNonce ?? randomNonce()
|
|
||||||
// Build a local tags buffer once to avoid reallocating unrelated tags
|
|
||||||
var tags = baseTags
|
|
||||||
tags.append(["nonce", "0", String(targetBits)])
|
|
||||||
|
|
||||||
var iter: UInt64 = 0
|
|
||||||
while true {
|
|
||||||
var solved = false
|
|
||||||
var idHexOut = ""
|
|
||||||
// Use an autorelease pool periodically to keep memory stable on iOS
|
|
||||||
autoreleasepool {
|
|
||||||
// Update nonce tag value (second element)
|
|
||||||
tags[tags.count - 1][1] = String(nonce)
|
|
||||||
let (idHex, idData) = eventIDHash(pubkey: pubkey, createdAt: createdAt, kind: kind, tags: tags, content: content)
|
|
||||||
if leadingZeroBits(idData) >= targetBits {
|
|
||||||
solved = true
|
|
||||||
idHexOut = idHex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if solved {
|
|
||||||
return (nonce, idHexOut)
|
|
||||||
}
|
|
||||||
nonce &+= 1
|
|
||||||
iter &+= 1
|
|
||||||
if iter & 0x3FFF == 0 { /* yield every ~16k iterations */ _ = sched_yield() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func randomNonce() -> UInt64 {
|
|
||||||
var n: UInt64 = 0
|
|
||||||
withUnsafeMutableBytes(of: &n) { ptr in
|
|
||||||
_ = SecRandomCopyBytes(kSecRandomDefault, ptr.count, ptr.baseAddress!)
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,8 +44,15 @@ class NostrRelayManager: ObservableObject {
|
|||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// Message queue for reliability
|
// Message queue for reliability
|
||||||
private var messageQueue: [(event: NostrEvent, relayUrls: [String])] = []
|
// Pending sends held only for relays that are not yet connected.
|
||||||
|
private struct PendingSend {
|
||||||
|
var event: NostrEvent
|
||||||
|
var pendingRelays: Set<String>
|
||||||
|
}
|
||||||
|
private var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
// Exponential backoff configuration
|
// Exponential backoff configuration
|
||||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||||
@@ -59,6 +66,8 @@ class NostrRelayManager: ObservableObject {
|
|||||||
init() {
|
init() {
|
||||||
// Initialize with default relays
|
// Initialize with default relays
|
||||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
||||||
|
// Deterministic JSON shape for outbound requests
|
||||||
|
self.encoder.outputFormatting = .sortedKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
@@ -96,15 +105,56 @@ class NostrRelayManager: ObservableObject {
|
|||||||
let targetRelays = relayUrls ?? Self.defaultRelays
|
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||||
ensureConnections(to: targetRelays)
|
ensureConnections(to: targetRelays)
|
||||||
|
|
||||||
// Add to queue for reliability
|
// Attempt immediate send to relays with active connections; queue the rest
|
||||||
messageQueueLock.lock()
|
var stillPending = Set<String>()
|
||||||
messageQueue.append((event, targetRelays))
|
|
||||||
messageQueueLock.unlock()
|
|
||||||
|
|
||||||
// Attempt immediate send
|
|
||||||
for relayUrl in targetRelays {
|
for relayUrl in targetRelays {
|
||||||
if let connection = connections[relayUrl] {
|
if let connection = connections[relayUrl] {
|
||||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||||
|
} else {
|
||||||
|
stillPending.insert(relayUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !stillPending.isEmpty {
|
||||||
|
messageQueueLock.lock()
|
||||||
|
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
|
||||||
|
messageQueueLock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to flush any queued messages for relays that are now connected.
|
||||||
|
private func flushMessageQueue(for relayUrl: String? = nil) {
|
||||||
|
messageQueueLock.lock()
|
||||||
|
defer { messageQueueLock.unlock() }
|
||||||
|
guard !messageQueue.isEmpty else { return }
|
||||||
|
if let target = relayUrl {
|
||||||
|
// Flush only for a specific relay
|
||||||
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
|
var item = messageQueue[i]
|
||||||
|
if item.pendingRelays.contains(target), let conn = connections[target] {
|
||||||
|
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
||||||
|
item.pendingRelays.remove(target)
|
||||||
|
if item.pendingRelays.isEmpty {
|
||||||
|
messageQueue.remove(at: i)
|
||||||
|
} else {
|
||||||
|
messageQueue[i] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Flush for any relays that now have connections
|
||||||
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
|
var item = messageQueue[i]
|
||||||
|
for url in item.pendingRelays {
|
||||||
|
if let conn = connections[url] {
|
||||||
|
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
||||||
|
item.pendingRelays.remove(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.pendingRelays.isEmpty {
|
||||||
|
messageQueue.remove(at: i)
|
||||||
|
} else {
|
||||||
|
messageQueue[i] = item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,8 +174,6 @@ class NostrRelayManager: ObservableObject {
|
|||||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.outputFormatting = .sortedKeys // For consistent output
|
|
||||||
let message = try encoder.encode(req)
|
let message = try encoder.encode(req)
|
||||||
guard let messageString = String(data: message, encoding: .utf8) else {
|
guard let messageString = String(data: message, encoding: .utf8) else {
|
||||||
SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error)
|
SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error)
|
||||||
@@ -175,7 +223,7 @@ class NostrRelayManager: ObservableObject {
|
|||||||
messageHandlers.removeValue(forKey: id)
|
messageHandlers.removeValue(forKey: id)
|
||||||
|
|
||||||
let req = NostrRequest.close(id: id)
|
let req = NostrRequest.close(id: id)
|
||||||
let message = try? JSONEncoder().encode(req)
|
let message = try? encoder.encode(req)
|
||||||
|
|
||||||
guard let messageData = message,
|
guard let messageData = message,
|
||||||
let messageString = String(data: messageData, encoding: .utf8) else { return }
|
let messageString = String(data: messageData, encoding: .utf8) else { return }
|
||||||
@@ -242,13 +290,20 @@ class NostrRelayManager: ObservableObject {
|
|||||||
case .success(let message):
|
case .success(let message):
|
||||||
switch message {
|
switch message {
|
||||||
case .string(let text):
|
case .string(let text):
|
||||||
Task { @MainActor in
|
// Parse off-main to reduce UI jank, then hop back for state updates
|
||||||
self.handleMessage(text, from: relayUrl)
|
Task.detached(priority: .utility) {
|
||||||
|
guard let parsed = parseInboundMessage(text) else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .data(let data):
|
case .data(let data):
|
||||||
if let text = String(data: data, encoding: .utf8) {
|
if let text = String(data: data, encoding: .utf8) {
|
||||||
Task { @MainActor in
|
Task.detached(priority: .utility) {
|
||||||
self.handleMessage(text, from: relayUrl)
|
guard let parsed = parseInboundMessage(text) else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@unknown default:
|
@unknown default:
|
||||||
@@ -268,57 +323,31 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleMessage(_ message: String, from relayUrl: String) {
|
// Parsed inbound message type (off-main)
|
||||||
guard let data = message.data(using: .utf8) else { return }
|
// Note: declared at file scope below to avoid MainActor isolation inside this class
|
||||||
|
// and keep parsing off the main actor.
|
||||||
|
|
||||||
do {
|
// Handle parsed message on MainActor (state updates and handlers)
|
||||||
// Try to decode as an array first
|
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||||
if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
|
switch parsed {
|
||||||
array.count >= 2,
|
case .event(let subId, let event):
|
||||||
let type = array[0] as? String {
|
|
||||||
|
|
||||||
// Received message from relay
|
|
||||||
|
|
||||||
switch type {
|
|
||||||
case "EVENT":
|
|
||||||
if array.count >= 3,
|
|
||||||
let subId = array[1] as? String,
|
|
||||||
let eventDict = array[2] as? [String: Any] {
|
|
||||||
|
|
||||||
let event = try NostrEvent(from: eventDict)
|
|
||||||
|
|
||||||
// Only log non-gift-wrap events to reduce noise
|
|
||||||
if event.kind != 1059 {
|
if event.kind != 1059 {
|
||||||
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
|
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
// Update relay stats
|
|
||||||
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||||
self.relays[index].messagesReceived += 1
|
self.relays[index].messagesReceived += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call handler
|
|
||||||
if let handler = self.messageHandlers[subId] {
|
if let handler = self.messageHandlers[subId] {
|
||||||
handler(event)
|
handler(event)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.log("⚠️ No handler for subscription \(subId)",
|
SecureLogger.log("⚠️ No handler for subscription \(subId)",
|
||||||
category: SecureLogger.session, level: .warning)
|
category: SecureLogger.session, level: .warning)
|
||||||
}
|
}
|
||||||
}
|
case .eose:
|
||||||
}
|
// No-op for now
|
||||||
|
break
|
||||||
case "EOSE":
|
case .ok(let eventId, let success, let reason):
|
||||||
if array.count >= 2 {
|
|
||||||
// End of stored events
|
|
||||||
}
|
|
||||||
|
|
||||||
case "OK":
|
|
||||||
if array.count >= 3,
|
|
||||||
let eventId = array[1] as? String,
|
|
||||||
let success = array[2] as? Bool {
|
|
||||||
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
|
|
||||||
if success {
|
if success {
|
||||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||||
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
|
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
|
||||||
@@ -328,19 +357,8 @@ class NostrRelayManager: ObservableObject {
|
|||||||
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
|
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
|
||||||
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
|
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
|
||||||
}
|
}
|
||||||
}
|
case .notice:
|
||||||
|
break
|
||||||
case "NOTICE":
|
|
||||||
if array.count >= 2 {
|
|
||||||
// Server notice received
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
break // Unknown message type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.log("Failed to parse Nostr message: \(error)", category: SecureLogger.session, level: .error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,8 +366,6 @@ class NostrRelayManager: ObservableObject {
|
|||||||
let req = NostrRequest.event(event)
|
let req = NostrRequest.event(event)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.outputFormatting = .sortedKeys
|
|
||||||
let data = try encoder.encode(req)
|
let data = try encoder.encode(req)
|
||||||
let message = String(data: data, encoding: .utf8) ?? ""
|
let message = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
@@ -389,6 +405,10 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
|
// If we just connected to this relay, flush any queued sends targeting it
|
||||||
|
if isConnected {
|
||||||
|
flushMessageQueue(for: url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateConnectionStatus() {
|
private func updateConnectionStatus() {
|
||||||
@@ -494,6 +514,51 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
|
||||||
|
|
||||||
|
private enum ParsedInbound {
|
||||||
|
case event(subId: String, event: NostrEvent)
|
||||||
|
case ok(eventId: String, success: Bool, reason: String)
|
||||||
|
case eose(subscriptionId: String)
|
||||||
|
case notice(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off-main JSON parse to avoid UI jank; pure function, not actor-isolated
|
||||||
|
private func parseInboundMessage(_ message: String) -> ParsedInbound? {
|
||||||
|
guard let data = message.data(using: .utf8) else { return nil }
|
||||||
|
do {
|
||||||
|
if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
|
||||||
|
array.count >= 2,
|
||||||
|
let type = array[0] as? String {
|
||||||
|
switch type {
|
||||||
|
case "EVENT":
|
||||||
|
if array.count >= 3,
|
||||||
|
let subId = array[1] as? String,
|
||||||
|
let eventDict = array[2] as? [String: Any] {
|
||||||
|
let event = try NostrEvent(from: eventDict)
|
||||||
|
return .event(subId: subId, event: event)
|
||||||
|
}
|
||||||
|
case "EOSE":
|
||||||
|
if let subId = array[1] as? String { return .eose(subscriptionId: subId) }
|
||||||
|
case "OK":
|
||||||
|
if array.count >= 3,
|
||||||
|
let eventId = array[1] as? String,
|
||||||
|
let success = array[2] as? Bool {
|
||||||
|
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
|
||||||
|
return .ok(eventId: eventId, success: success, reason: reason)
|
||||||
|
}
|
||||||
|
case "NOTICE":
|
||||||
|
if array.count >= 2, let msg = array[1] as? String { return .notice(msg) }
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Nostr Protocol Types
|
// MARK: - Nostr Protocol Types
|
||||||
|
|
||||||
enum NostrRequest: Encodable {
|
enum NostrRequest: Encodable {
|
||||||
|
|||||||
@@ -40,23 +40,23 @@ extension Data {
|
|||||||
extension Data {
|
extension Data {
|
||||||
// MARK: Writing
|
// MARK: Writing
|
||||||
|
|
||||||
mutating func appendUInt8(_ value: UInt8) {
|
@inlinable mutating func appendUInt8(_ value: UInt8) {
|
||||||
self.append(value)
|
self.append(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func appendUInt16(_ value: UInt16) {
|
@inlinable mutating func appendUInt16(_ value: UInt16) {
|
||||||
self.append(UInt8((value >> 8) & 0xFF))
|
self.append(UInt8((value >> 8) & 0xFF))
|
||||||
self.append(UInt8(value & 0xFF))
|
self.append(UInt8(value & 0xFF))
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func appendUInt32(_ value: UInt32) {
|
@inlinable mutating func appendUInt32(_ value: UInt32) {
|
||||||
self.append(UInt8((value >> 24) & 0xFF))
|
self.append(UInt8((value >> 24) & 0xFF))
|
||||||
self.append(UInt8((value >> 16) & 0xFF))
|
self.append(UInt8((value >> 16) & 0xFF))
|
||||||
self.append(UInt8((value >> 8) & 0xFF))
|
self.append(UInt8((value >> 8) & 0xFF))
|
||||||
self.append(UInt8(value & 0xFF))
|
self.append(UInt8(value & 0xFF))
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func appendUInt64(_ value: UInt64) {
|
@inlinable mutating func appendUInt64(_ value: UInt64) {
|
||||||
for i in (0..<8).reversed() {
|
for i in (0..<8).reversed() {
|
||||||
self.append(UInt8((value >> (i * 8)) & 0xFF))
|
self.append(UInt8((value >> (i * 8)) & 0xFF))
|
||||||
}
|
}
|
||||||
@@ -113,21 +113,21 @@ extension Data {
|
|||||||
|
|
||||||
// MARK: Reading
|
// MARK: Reading
|
||||||
|
|
||||||
func readUInt8(at offset: inout Int) -> UInt8? {
|
@inlinable func readUInt8(at offset: inout Int) -> UInt8? {
|
||||||
guard offset >= 0 && offset < self.count else { return nil }
|
guard offset >= 0 && offset < self.count else { return nil }
|
||||||
let value = self[offset]
|
let value = self[offset]
|
||||||
offset += 1
|
offset += 1
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func readUInt16(at offset: inout Int) -> UInt16? {
|
@inlinable func readUInt16(at offset: inout Int) -> UInt16? {
|
||||||
guard offset + 2 <= self.count else { return nil }
|
guard offset + 2 <= self.count else { return nil }
|
||||||
let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])
|
let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])
|
||||||
offset += 2
|
offset += 2
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func readUInt32(at offset: inout Int) -> UInt32? {
|
@inlinable func readUInt32(at offset: inout Int) -> UInt32? {
|
||||||
guard offset + 4 <= self.count else { return nil }
|
guard offset + 4 <= self.count else { return nil }
|
||||||
let value = UInt32(self[offset]) << 24 |
|
let value = UInt32(self[offset]) << 24 |
|
||||||
UInt32(self[offset + 1]) << 16 |
|
UInt32(self[offset + 1]) << 16 |
|
||||||
@@ -137,7 +137,7 @@ extension Data {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func readUInt64(at offset: inout Int) -> UInt64? {
|
@inlinable func readUInt64(at offset: inout Int) -> UInt64? {
|
||||||
guard offset + 8 <= self.count else { return nil }
|
guard offset + 8 <= self.count else { return nil }
|
||||||
var value: UInt64 = 0
|
var value: UInt64 = 0
|
||||||
for i in 0..<8 {
|
for i in 0..<8 {
|
||||||
@@ -220,4 +220,3 @@ extension Data {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,11 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
|
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
||||||
|
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
||||||
|
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
||||||
|
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
||||||
|
data.reserveCapacity(estimated)
|
||||||
data.append(packet.version)
|
data.append(packet.version)
|
||||||
data.append(packet.type)
|
data.append(packet.type)
|
||||||
data.append(packet.ttl)
|
data.append(packet.ttl)
|
||||||
@@ -222,136 +227,106 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||||
// Minimum size check: header + senderID
|
// Minimum size: header + senderID
|
||||||
guard raw.count >= headerSize + senderIDSize else {
|
guard raw.count >= headerSize + senderIDSize else { return nil }
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to array for safer indexed access
|
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||||
let dataArray = Array(raw)
|
guard let base = buf.baseAddress else { return nil }
|
||||||
var offset = 0
|
var offset = 0
|
||||||
|
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
||||||
// Header parsing with bounds checks
|
// Read single byte
|
||||||
guard offset < dataArray.count else { return nil }
|
func read8() -> UInt8? {
|
||||||
let version = dataArray[offset]; offset += 1
|
guard require(1) else { return nil }
|
||||||
|
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||||
// Check if version is 1 (only supported version)
|
offset += 1
|
||||||
guard version == 1 else {
|
return v
|
||||||
return nil
|
}
|
||||||
|
// Read big-endian 16-bit
|
||||||
|
func read16() -> UInt16? {
|
||||||
|
guard require(2) else { return nil }
|
||||||
|
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
|
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
|
||||||
|
offset += 2
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
// Copy N bytes into Data
|
||||||
|
func readData(_ n: Int) -> Data? {
|
||||||
|
guard require(n) else { return nil }
|
||||||
|
let ptr = base.advanced(by: offset)
|
||||||
|
let d = Data(bytes: ptr, count: n)
|
||||||
|
offset += n
|
||||||
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
guard offset < dataArray.count else { return nil }
|
// Version
|
||||||
let type = dataArray[offset]; offset += 1
|
guard let version = read8(), version == 1 else { return nil }
|
||||||
|
guard let type = read8() else { return nil }
|
||||||
|
guard let ttl = read8() else { return nil }
|
||||||
|
|
||||||
guard offset < dataArray.count else { return nil }
|
// Timestamp 8 bytes BE
|
||||||
let ttl = dataArray[offset]; offset += 1
|
guard require(8) else { return nil }
|
||||||
|
var ts: UInt64 = 0
|
||||||
// Timestamp - need 8 bytes
|
for _ in 0..<8 {
|
||||||
guard offset + 8 <= dataArray.count else { return nil }
|
guard let b = read8() else { return nil }
|
||||||
let timestampData = Data(dataArray[offset..<offset+8])
|
ts = (ts << 8) | UInt64(b)
|
||||||
let timestamp = timestampData.reduce(0) { result, byte in
|
|
||||||
(result << 8) | UInt64(byte)
|
|
||||||
}
|
}
|
||||||
offset += 8
|
|
||||||
|
|
||||||
// Flags
|
// Flags
|
||||||
guard offset < dataArray.count else { return nil }
|
guard let flags = read8() else { return nil }
|
||||||
let flags = dataArray[offset]; offset += 1
|
|
||||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
|
|
||||||
// Payload length - need 2 bytes
|
// Payload length
|
||||||
guard offset + 2 <= dataArray.count else { return nil }
|
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
||||||
let payloadLengthData = Data(dataArray[offset..<offset+2])
|
|
||||||
let payloadLength = payloadLengthData.reduce(0) { result, byte in
|
|
||||||
(result << 8) | UInt16(byte)
|
|
||||||
}
|
|
||||||
offset += 2
|
|
||||||
|
|
||||||
// Validate payloadLength is reasonable (prevent integer overflow)
|
// SenderID
|
||||||
guard payloadLength <= 65535 else { return nil }
|
guard let senderID = readData(senderIDSize) else { return nil }
|
||||||
|
|
||||||
// SenderID - need 8 bytes
|
// Recipient
|
||||||
guard offset + senderIDSize <= dataArray.count else { return nil }
|
var recipientID: Data? = nil
|
||||||
let senderID = Data(dataArray[offset..<offset+senderIDSize])
|
|
||||||
offset += senderIDSize
|
|
||||||
|
|
||||||
// RecipientID if present
|
|
||||||
var recipientID: Data?
|
|
||||||
if hasRecipient {
|
if hasRecipient {
|
||||||
guard offset + recipientIDSize <= dataArray.count else { return nil }
|
recipientID = readData(recipientIDSize)
|
||||||
recipientID = Data(dataArray[offset..<offset+recipientIDSize])
|
if recipientID == nil { return nil }
|
||||||
offset += recipientIDSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload handling with comprehensive bounds checking
|
// Payload
|
||||||
let payload: Data
|
let payload: Data
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
// Compressed payload needs at least 2 bytes for original size
|
// Need original size (2 bytes)
|
||||||
guard Int(payloadLength) >= 2 else { return nil }
|
guard let origSize16 = read16() else { return nil }
|
||||||
|
let originalSize = Int(origSize16)
|
||||||
// Check we have enough data for the original size prefix
|
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
|
||||||
guard offset + 2 <= dataArray.count else { return nil }
|
let compSize = Int(payloadLen) - 2
|
||||||
let originalSizeData = Data(dataArray[offset..<offset+2])
|
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
|
||||||
let originalSize = Int(originalSizeData.reduce(0) { result, byte in
|
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
||||||
(result << 8) | UInt16(byte)
|
decompressed.count == originalSize else { return nil }
|
||||||
})
|
payload = decompressed
|
||||||
offset += 2
|
|
||||||
|
|
||||||
// Validate original size is reasonable
|
|
||||||
guard originalSize >= 0 && originalSize <= 1048576 else { return nil } // Max 1MB
|
|
||||||
|
|
||||||
// Check we have enough data for the compressed payload
|
|
||||||
let compressedPayloadSize = Int(payloadLength) - 2
|
|
||||||
guard compressedPayloadSize >= 0 && offset + compressedPayloadSize <= dataArray.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let compressedPayload = Data(dataArray[offset..<offset+compressedPayloadSize])
|
|
||||||
offset += compressedPayloadSize
|
|
||||||
|
|
||||||
// Decompress with error handling
|
|
||||||
guard let decompressedPayload = CompressionUtil.decompress(compressedPayload, originalSize: originalSize) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify decompressed size matches expected
|
|
||||||
guard decompressedPayload.count == originalSize else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = decompressedPayload
|
|
||||||
} else {
|
} else {
|
||||||
// Uncompressed payload
|
guard let p = readData(Int(payloadLen)) else { return nil }
|
||||||
guard Int(payloadLength) >= 0 && offset + Int(payloadLength) <= dataArray.count else {
|
payload = p
|
||||||
return nil
|
|
||||||
}
|
|
||||||
payload = Data(dataArray[offset..<offset+Int(payloadLength)])
|
|
||||||
offset += Int(payloadLength)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature if present
|
// Signature
|
||||||
var signature: Data?
|
var signature: Data? = nil
|
||||||
if hasSignature {
|
if hasSignature {
|
||||||
guard offset + signatureSize <= dataArray.count else { return nil }
|
signature = readData(signatureSize)
|
||||||
signature = Data(dataArray[offset..<offset+signatureSize])
|
if signature == nil { return nil }
|
||||||
offset += signatureSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final validation: ensure we haven't gone past the end
|
guard offset <= buf.count else { return nil }
|
||||||
guard offset <= dataArray.count else { return nil }
|
|
||||||
|
|
||||||
return BitchatPacket(
|
return BitchatPacket(
|
||||||
type: type,
|
type: type,
|
||||||
senderID: senderID,
|
senderID: senderID,
|
||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
timestamp: timestamp,
|
timestamp: ts,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
ttl: ttl
|
ttl: ttl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binary encoding for BitchatMessage
|
// Binary encoding for BitchatMessage
|
||||||
|
|||||||
@@ -409,8 +409,6 @@ class BitchatMessage: Codable {
|
|||||||
let senderPeerID: String?
|
let senderPeerID: String?
|
||||||
let mentions: [String]? // Array of mentioned nicknames
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
// Mining state for Nostr geohash PoW (UI hint: hide timestamp, animate)
|
|
||||||
var isMiningPow: Bool? // default nil/false
|
|
||||||
|
|
||||||
// Cached formatted text (not included in Codable)
|
// Cached formatted text (not included in Codable)
|
||||||
private var _cachedFormattedText: [String: AttributedString] = [:]
|
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||||
@@ -423,18 +421,13 @@ class BitchatMessage: Codable {
|
|||||||
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearCachedFormattedText() {
|
|
||||||
_cachedFormattedText.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codable implementation
|
// Codable implementation
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, sender, content, timestamp, isRelay, originalSender
|
case id, sender, content, timestamp, isRelay, originalSender
|
||||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||||
case isMiningPow
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil, isMiningPow: Bool? = nil) {
|
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
||||||
self.id = id ?? UUID().uuidString
|
self.id = id ?? UUID().uuidString
|
||||||
self.sender = sender
|
self.sender = sender
|
||||||
self.content = content
|
self.content = content
|
||||||
@@ -446,7 +439,6 @@ class BitchatMessage: Codable {
|
|||||||
self.senderPeerID = senderPeerID
|
self.senderPeerID = senderPeerID
|
||||||
self.mentions = mentions
|
self.mentions = mentions
|
||||||
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
||||||
self.isMiningPow = isMiningPow
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,8 +455,7 @@ extension BitchatMessage: Equatable {
|
|||||||
lhs.recipientNickname == rhs.recipientNickname &&
|
lhs.recipientNickname == rhs.recipientNickname &&
|
||||||
lhs.senderPeerID == rhs.senderPeerID &&
|
lhs.senderPeerID == rhs.senderPeerID &&
|
||||||
lhs.mentions == rhs.mentions &&
|
lhs.mentions == rhs.mentions &&
|
||||||
lhs.deliveryStatus == rhs.deliveryStatus &&
|
lhs.deliveryStatus == rhs.deliveryStatus
|
||||||
(lhs.isMiningPow ?? false) == (rhs.isMiningPow ?? false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ struct AnnouncementPacket {
|
|||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
var data = Data()
|
var data = Data()
|
||||||
|
// Reserve: TLVs for nickname (2 + n), noise key (2 + 32), signing key (2 + 32)
|
||||||
|
data.reserveCapacity(2 + min(nickname.count, 255) + 2 + noisePublicKey.count + 2 + signingPublicKey.count)
|
||||||
|
|
||||||
// TLV for nickname
|
// TLV for nickname
|
||||||
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
|
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
|
||||||
@@ -88,6 +90,7 @@ struct PrivateMessagePacket {
|
|||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
var data = Data()
|
var data = Data()
|
||||||
|
data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255))
|
||||||
|
|
||||||
// TLV for messageID
|
// TLV for messageID
|
||||||
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
|
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ final class BLEService: NSObject {
|
|||||||
private let messageDeduplicator = MessageDeduplicator()
|
private let messageDeduplicator = MessageDeduplicator()
|
||||||
|
|
||||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||||
private var incomingFragments: [String: [Int: Data]] = [:]
|
private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 }
|
||||||
private var fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)] = [:]
|
private var incomingFragments: [FragmentKey: [Int: Data]] = [:]
|
||||||
|
private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:]
|
||||||
// Backoff for peripherals that recently timed out connecting
|
// Backoff for peripherals that recently timed out connecting
|
||||||
private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout
|
private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ final class BLEService: NSObject {
|
|||||||
var myPeerID: String = ""
|
var myPeerID: String = ""
|
||||||
var myNickname: String = "anon"
|
var myNickname: String = "anon"
|
||||||
private let noiseService = NoiseEncryptionService()
|
private let noiseService = NoiseEncryptionService()
|
||||||
|
private var myPeerIDData: Data = Data()
|
||||||
|
|
||||||
// MARK: - Advertising Privacy
|
// MARK: - Advertising Privacy
|
||||||
// No Local Name by default for maximum privacy. No rotating alias.
|
// No Local Name by default for maximum privacy. No rotating alias.
|
||||||
@@ -223,7 +225,30 @@ final class BLEService: NSObject {
|
|||||||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||||
} else {
|
} else {
|
||||||
self.collectionsQueue.async(flags: .barrier) {
|
self.collectionsQueue.async(flags: .barrier) {
|
||||||
self.pendingPeripheralWrites[uuid, default: []].append(data)
|
var queue = self.pendingPeripheralWrites[uuid] ?? []
|
||||||
|
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
|
||||||
|
let newSize = data.count
|
||||||
|
// If single chunk exceeds cap, drop it immediately
|
||||||
|
if newSize > capBytes {
|
||||||
|
SecureLogger.log("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)",
|
||||||
|
category: SecureLogger.session, level: .warning)
|
||||||
|
} else {
|
||||||
|
// Append and trim from the front to respect cap
|
||||||
|
var total = queue.reduce(0) { $0 + $1.count }
|
||||||
|
queue.append(data)
|
||||||
|
total += newSize
|
||||||
|
if total > capBytes {
|
||||||
|
var removedBytes = 0
|
||||||
|
while total > capBytes && !queue.isEmpty {
|
||||||
|
let removed = queue.removeFirst()
|
||||||
|
removedBytes += removed.count
|
||||||
|
total -= removed.count
|
||||||
|
}
|
||||||
|
SecureLogger.log("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B",
|
||||||
|
category: SecureLogger.session, level: .warning)
|
||||||
|
}
|
||||||
|
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,6 +333,7 @@ final class BLEService: NSObject {
|
|||||||
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes → 16 hex chars)
|
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes → 16 hex chars)
|
||||||
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
|
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
|
||||||
self.myPeerID = String(fingerprint.prefix(16))
|
self.myPeerID = String(fingerprint.prefix(16))
|
||||||
|
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
|
||||||
|
|
||||||
// Set queue key for identification
|
// Set queue key for identification
|
||||||
messageQueue.setSpecific(key: messageQueueKey, value: ())
|
messageQueue.setSpecific(key: messageQueueKey, value: ())
|
||||||
@@ -374,7 +400,7 @@ final class BLEService: NSObject {
|
|||||||
maintenanceTimer = timer
|
maintenanceTimer = timer
|
||||||
|
|
||||||
// Publish initial empty state
|
// Publish initial empty state
|
||||||
publishFullPeerData()
|
requestPeerDataPublish()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setNickname(_ nickname: String) {
|
func setNickname(_ nickname: String) {
|
||||||
@@ -463,7 +489,7 @@ final class BLEService: NSObject {
|
|||||||
// Send leave message synchronously to ensure delivery
|
// Send leave message synchronously to ensure delivery
|
||||||
let leavePacket = BitchatPacket(
|
let leavePacket = BitchatPacket(
|
||||||
type: MessageType.leave.rawValue,
|
type: MessageType.leave.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: myPeerIDData,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: Data(),
|
payload: Data(),
|
||||||
@@ -591,7 +617,7 @@ final class BLEService: NSObject {
|
|||||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: myPeerIDData,
|
||||||
recipientID: Data(hexString: peerID),
|
recipientID: Data(hexString: peerID),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: encrypted,
|
payload: encrypted,
|
||||||
@@ -643,7 +669,7 @@ final class BLEService: NSObject {
|
|||||||
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: myPeerIDData,
|
||||||
recipientID: Data(hexString: peerID),
|
recipientID: Data(hexString: peerID),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: encrypted,
|
payload: encrypted,
|
||||||
@@ -800,7 +826,7 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: myPeerIDData,
|
||||||
recipientID: recipientData,
|
recipientID: recipientData,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: encrypted,
|
payload: encrypted,
|
||||||
@@ -854,7 +880,7 @@ final class BLEService: NSObject {
|
|||||||
// Send handshake init
|
// Send handshake init
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseHandshake.rawValue,
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: myPeerIDData,
|
||||||
recipientID: Data(hexString: peerID),
|
recipientID: Data(hexString: peerID),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: handshakeData,
|
payload: handshakeData,
|
||||||
@@ -904,7 +930,7 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: myPeerIDData,
|
||||||
recipientID: Data(hexString: peerID),
|
recipientID: Data(hexString: peerID),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: encrypted,
|
payload: encrypted,
|
||||||
@@ -1247,8 +1273,11 @@ final class BLEService: NSObject {
|
|||||||
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type
|
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type
|
||||||
guard packet.payload.count >= 13 else { return }
|
guard packet.payload.count >= 13 else { return }
|
||||||
|
|
||||||
let senderHex = packet.senderID.hexEncodedString()
|
// Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian
|
||||||
let fragmentID = packet.payload[0..<8].map { String(format: "%02x", $0) }.joined()
|
var senderU64: UInt64 = 0
|
||||||
|
for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) }
|
||||||
|
var fragU64: UInt64 = 0
|
||||||
|
for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) }
|
||||||
// Parse big-endian UInt16 safely without alignment assumptions
|
// Parse big-endian UInt16 safely without alignment assumptions
|
||||||
let idxHi = UInt16(packet.payload[8])
|
let idxHi = UInt16(packet.payload[8])
|
||||||
let idxLo = UInt16(packet.payload[9])
|
let idxLo = UInt16(packet.payload[9])
|
||||||
@@ -1263,7 +1292,7 @@ final class BLEService: NSObject {
|
|||||||
guard total > 0 && index >= 0 && index < total else { return }
|
guard total > 0 && index >= 0 && index < total else { return }
|
||||||
|
|
||||||
// Store fragment
|
// Store fragment
|
||||||
let key = "\(senderHex):\(fragmentID)"
|
let key = FragmentKey(sender: senderU64, id: fragU64)
|
||||||
if incomingFragments[key] == nil {
|
if incomingFragments[key] == nil {
|
||||||
// Cap in-flight assemblies to prevent memory/battery blowups
|
// Cap in-flight assemblies to prevent memory/battery blowups
|
||||||
if incomingFragments.count >= maxInFlightAssemblies {
|
if incomingFragments.count >= maxInFlightAssemblies {
|
||||||
@@ -1436,6 +1465,20 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Suppress announce logs to reduce noise
|
// Suppress announce logs to reduce noise
|
||||||
|
|
||||||
|
// Precompute signature verification outside barrier to reduce contention
|
||||||
|
let existingPeerForVerify = collectionsQueue.sync { peers[peerID] }
|
||||||
|
var verifiedAnnounce = false
|
||||||
|
if packet.signature != nil {
|
||||||
|
verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
|
||||||
|
if !verifiedAnnounce {
|
||||||
|
SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey {
|
||||||
|
SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning)
|
||||||
|
verifiedAnnounce = false
|
||||||
|
}
|
||||||
|
|
||||||
// Track if this is a new or reconnected peer
|
// Track if this is a new or reconnected peer
|
||||||
var isNewPeer = false
|
var isNewPeer = false
|
||||||
var isReconnectedPeer = false
|
var isReconnectedPeer = false
|
||||||
@@ -1460,21 +1503,8 @@ final class BLEService: NSObject {
|
|||||||
isNewPeer = (existingPeer == nil)
|
isNewPeer = (existingPeer == nil)
|
||||||
isReconnectedPeer = wasDisconnected
|
isReconnectedPeer = wasDisconnected
|
||||||
|
|
||||||
// Verify packet signature using the announced signing public key
|
// Use precomputed verification result
|
||||||
var verified = false
|
let verified = verifiedAnnounce
|
||||||
if packet.signature != nil {
|
|
||||||
// Verify that the packet was signed by the signing private key corresponding to the announced signing public key
|
|
||||||
verified = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
|
|
||||||
if !verified {
|
|
||||||
SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If existing peer has a different noise public key, do not consider this verified
|
|
||||||
if let existing = existingPeer, let existingKey = existing.noisePublicKey, existingKey != announcement.noisePublicKey {
|
|
||||||
SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning)
|
|
||||||
verified = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require verified announce; ignore otherwise (no backward compatibility)
|
// Require verified announce; ignore otherwise (no backward compatibility)
|
||||||
if !verified {
|
if !verified {
|
||||||
@@ -1565,7 +1595,7 @@ final class BLEService: NSObject {
|
|||||||
self.delegate?.didConnectToPeer(peerID)
|
self.delegate?.didConnectToPeer(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.publishFullPeerData()
|
self.requestPeerDataPublish()
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1666,7 +1696,7 @@ final class BLEService: NSObject {
|
|||||||
// Send response
|
// Send response
|
||||||
let responsePacket = BitchatPacket(
|
let responsePacket = BitchatPacket(
|
||||||
type: MessageType.noiseHandshake.rawValue,
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: myPeerIDData,
|
||||||
recipientID: Data(hexString: peerID),
|
recipientID: Data(hexString: peerID),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: response,
|
payload: response,
|
||||||
@@ -1822,7 +1852,7 @@ final class BLEService: NSObject {
|
|||||||
// Create packet with signature using the noise private key
|
// Create packet with signature using the noise private key
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.announce.rawValue,
|
type: MessageType.announce.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: myPeerIDData,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -1856,7 +1886,7 @@ final class BLEService: NSObject {
|
|||||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: myPeerIDData,
|
||||||
recipientID: Data(hexString: peerID),
|
recipientID: Data(hexString: peerID),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: encrypted,
|
payload: encrypted,
|
||||||
@@ -1893,7 +1923,7 @@ final class BLEService: NSObject {
|
|||||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: myPeerIDData,
|
||||||
recipientID: Data(hexString: peerID),
|
recipientID: Data(hexString: peerID),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: encrypted,
|
payload: encrypted,
|
||||||
@@ -1960,6 +1990,28 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounced publish to coalesce rapid changes
|
||||||
|
private var lastPeerPublishAt: Date = .distantPast
|
||||||
|
private var peerPublishPending: Bool = false
|
||||||
|
private let peerPublishMinInterval: TimeInterval = 0.1
|
||||||
|
private func requestPeerDataPublish() {
|
||||||
|
let now = Date()
|
||||||
|
let elapsed = now.timeIntervalSince(lastPeerPublishAt)
|
||||||
|
if elapsed >= peerPublishMinInterval {
|
||||||
|
lastPeerPublishAt = now
|
||||||
|
publishFullPeerData()
|
||||||
|
} else if !peerPublishPending {
|
||||||
|
peerPublishPending = true
|
||||||
|
let delay = peerPublishMinInterval - elapsed
|
||||||
|
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.lastPeerPublishAt = Date()
|
||||||
|
self.peerPublishPending = false
|
||||||
|
self.publishFullPeerData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Consolidated Maintenance
|
// MARK: - Consolidated Maintenance
|
||||||
|
|
||||||
private func performMaintenance() {
|
private func performMaintenance() {
|
||||||
@@ -2072,7 +2124,7 @@ final class BLEService: NSObject {
|
|||||||
self.delegate?.didDisconnectFromPeer(peerID)
|
self.delegate?.didDisconnectFromPeer(peerID)
|
||||||
}
|
}
|
||||||
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
|
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
|
||||||
self.publishFullPeerData()
|
self.requestPeerDataPublish()
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2454,7 +2506,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
|||||||
if let peerID = peerID {
|
if let peerID = peerID {
|
||||||
self.notifyPeerDisconnectedDebounced(peerID)
|
self.notifyPeerDisconnectedDebounced(peerID)
|
||||||
}
|
}
|
||||||
self.publishFullPeerData()
|
self.requestPeerDataPublish()
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2855,7 +2907,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
|
|
||||||
self.notifyPeerDisconnectedDebounced(peerID)
|
self.notifyPeerDisconnectedDebounced(peerID)
|
||||||
// Publish snapshots so UnifiedPeerService can refresh icons promptly
|
// Publish snapshots so UnifiedPeerService can refresh icons promptly
|
||||||
self.publishFullPeerData()
|
self.requestPeerDataPublish()
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,11 +89,20 @@ final class MessageRouter {
|
|||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
private func canSendViaNostr(peerID: String) -> Bool {
|
private func canSendViaNostr(peerID: String) -> Bool {
|
||||||
guard let noiseKey = Data(hexString: peerID) else { return false }
|
// Two forms are supported:
|
||||||
|
// - 64-hex Noise public key (32 bytes)
|
||||||
|
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||||
|
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
fav.peerNostrPublicKey != nil {
|
fav.peerNostrPublicKey != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
} else if peerID.count == 16 {
|
||||||
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
|
fav.peerNostrPublicKey != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +110,7 @@ final class MessageRouter {
|
|||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
|
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
for (content, nickname, messageID) in queued {
|
for (content, nickname, messageID) in queued {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
@@ -112,14 +122,19 @@ final class MessageRouter {
|
|||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
continue
|
// Keep unsent items queued
|
||||||
|
remaining.append((content, nickname, messageID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Remove all flushed items (remaining ones, if any, will be re-queued on next call)
|
// Persist only items we could not send
|
||||||
outbox[peerID]?.removeAll()
|
if remaining.isEmpty {
|
||||||
|
outbox.removeValue(forKey: peerID)
|
||||||
|
} else {
|
||||||
|
outbox[peerID] = remaining
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushAllOutbox() {
|
func flushAllOutbox() {
|
||||||
for key in outbox.keys { flushOutbox(for: key) }
|
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Simple default policy for NIP-13 difficulty on geohash chats.
|
|
||||||
/// Progressive by geohash precision; adjustable later by live activity.
|
|
||||||
enum PowPolicy {
|
|
||||||
/// Determine target bits for a given geohash string.
|
|
||||||
/// Clamped to a sane range to keep UX responsive on phones.
|
|
||||||
static func requiredBits(forGeohash geohash: String) -> Int {
|
|
||||||
let precision = geohash.count
|
|
||||||
// Start at 13 and go down with higher precision (smaller areas)
|
|
||||||
switch precision {
|
|
||||||
case ...5: return 16
|
|
||||||
case 6: return 15
|
|
||||||
default: return 14
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
private var peerIndex: [String: BitchatPeer] = [:]
|
private var peerIndex: [String: BitchatPeer] = [:]
|
||||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
||||||
private let meshService: Transport
|
private let meshService: Transport
|
||||||
|
weak var messageRouter: MessageRouter?
|
||||||
private let favoritesService = FavoritesPersistenceService.shared
|
private let favoritesService = FavoritesPersistenceService.shared
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
@@ -330,8 +331,13 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
|
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Send favorite notification to the peer
|
// Send favorite notification to the peer via router (mesh or Nostr)
|
||||||
|
if let router = messageRouter {
|
||||||
|
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||||
|
} else {
|
||||||
|
// Fallback to mesh-only if router not yet wired
|
||||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||||
|
}
|
||||||
|
|
||||||
// Force update of peers to reflect the change
|
// Force update of peers to reflect the change
|
||||||
updatePeers()
|
updatePeers()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ final class MessageDeduplicator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var entries: [Entry] = []
|
private var entries: [Entry] = []
|
||||||
|
private var head: Int = 0
|
||||||
private var lookup = Set<String>()
|
private var lookup = Set<String>()
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
|
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
|
||||||
@@ -28,10 +29,18 @@ final class MessageDeduplicator {
|
|||||||
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
||||||
lookup.insert(messageID)
|
lookup.insert(messageID)
|
||||||
|
|
||||||
if entries.count > maxCount {
|
// Soft-cap and advance head by a chunk to avoid O(n) shifting
|
||||||
let toRemove = entries.prefix(100)
|
if (entries.count - head) > maxCount {
|
||||||
toRemove.forEach { lookup.remove($0.messageID) }
|
let removeCount = min(100, entries.count - head)
|
||||||
entries.removeFirst(100)
|
for i in head..<(head + removeCount) {
|
||||||
|
lookup.remove(entries[i].messageID)
|
||||||
|
}
|
||||||
|
head += removeCount
|
||||||
|
// Periodically compact to reclaim memory
|
||||||
|
if head > entries.count / 2 {
|
||||||
|
entries.removeFirst(head)
|
||||||
|
head = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
@@ -61,6 +70,7 @@ final class MessageDeduplicator {
|
|||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
|
|
||||||
entries.removeAll()
|
entries.removeAll()
|
||||||
|
head = 0
|
||||||
lookup.removeAll()
|
lookup.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +88,13 @@ final class MessageDeduplicator {
|
|||||||
|
|
||||||
private func cleanupOldEntries() {
|
private func cleanupOldEntries() {
|
||||||
let cutoff = Date().addingTimeInterval(-maxAge)
|
let cutoff = Date().addingTimeInterval(-maxAge)
|
||||||
while let first = entries.first, first.timestamp < cutoff {
|
while head < entries.count, entries[head].timestamp < cutoff {
|
||||||
lookup.remove(first.messageID)
|
lookup.remove(entries[head].messageID)
|
||||||
entries.removeFirst()
|
head += 1
|
||||||
|
}
|
||||||
|
if head > 0 && head > entries.count / 2 {
|
||||||
|
entries.removeFirst(head)
|
||||||
|
head = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,11 +140,11 @@ class SecureLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Log general messages with automatic sensitive data filtering
|
/// Log general messages with automatic sensitive data filtering
|
||||||
static func log(_ message: String, category: OSLog = noise, level: LogLevel = .debug,
|
static func log(_ message: @autoclosure () -> String, category: OSLog = noise, level: LogLevel = .debug,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
guard shouldLog(level) else { return }
|
guard shouldLog(level) else { return }
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = sanitize("\(location) \(message)")
|
let sanitized = sanitize("\(location) \(message())")
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||||
@@ -157,10 +157,10 @@ class SecureLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Log errors with context
|
/// Log errors with context
|
||||||
static func logError(_ error: Error, context: String, category: OSLog = noise,
|
static func logError(_ error: Error, context: @autoclosure () -> String, category: OSLog = noise,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = sanitize(context)
|
let sanitized = sanitize(context())
|
||||||
let errorDesc = sanitize(error.localizedDescription)
|
let errorDesc = sanitize(error.localizedDescription)
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|||||||
@@ -492,6 +492,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
||||||
// Route receipts from PrivateChatManager through MessageRouter
|
// Route receipts from PrivateChatManager through MessageRouter
|
||||||
self.privateChatManager.messageRouter = self.messageRouter
|
self.privateChatManager.messageRouter = self.messageRouter
|
||||||
|
// Allow UnifiedPeerService to route favorite notifications via mesh/Nostr
|
||||||
|
self.unifiedPeerService.messageRouter = self.messageRouter
|
||||||
self.autocompleteService = AutocompleteService()
|
self.autocompleteService = AutocompleteService()
|
||||||
|
|
||||||
// Wire up dependencies
|
// Wire up dependencies
|
||||||
@@ -796,17 +798,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
if self.processedNostrEvents.contains(event.id) { return }
|
if self.processedNostrEvents.contains(event.id) { return }
|
||||||
self.processedNostrEvents.insert(event.id)
|
self.processedNostrEvents.insert(event.id)
|
||||||
// PoW filter: require local difficulty for this geohash
|
|
||||||
let ghFromTag: String? = event.tags.first(where: { $0.first?.lowercased() == "g" && $0.count >= 2 })?.dropFirst().first
|
|
||||||
let ghValue = ghFromTag ?? ch.geohash
|
|
||||||
if let idData = Data(hexString: event.id) {
|
|
||||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
|
||||||
let required = PowPolicy.requiredBits(forGeohash: ghValue)
|
|
||||||
if powBits < required {
|
|
||||||
// Drop low PoW event
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let gh = self.currentGeohash,
|
if let gh = self.currentGeohash,
|
||||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
||||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||||
@@ -824,11 +815,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||||
self.nostrKeyMapping[key8] = event.pubkey
|
self.nostrKeyMapping[key8] = event.pubkey
|
||||||
|
|
||||||
// Update participants last-seen for this pubkey with observed PoW bits
|
// Update participants last-seen for this pubkey
|
||||||
if let idData = Data(hexString: event.id) {
|
self.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
|
||||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: ch.geohash, powBits: powBits)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
||||||
let hasTeleportTag = event.tags.contains(where: { tag in
|
let hasTeleportTag = event.tags.contains(where: { tag in
|
||||||
@@ -1334,11 +1322,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: localSenderPeerID,
|
senderPeerID: localSenderPeerID,
|
||||||
mentions: mentions.isEmpty ? nil : mentions,
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
deliveryStatus: nil,
|
|
||||||
isMiningPow: {
|
|
||||||
if case .location(_) = activeChannel { return true } else { return nil }
|
|
||||||
}()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add to main messages immediately for user feedback
|
// Add to main messages immediately for user feedback
|
||||||
@@ -1371,42 +1355,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if case .location(let ch) = activeChannel {
|
if case .location(let ch) = activeChannel {
|
||||||
// Send to geohash channel via Nostr ephemeral with PoW
|
// Send to geohash channel via Nostr ephemeral
|
||||||
let msgID = message.id
|
Task { @MainActor in
|
||||||
let contentToSend = trimmed
|
|
||||||
Task {@MainActor in
|
|
||||||
do {
|
do {
|
||||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||||
let createdAt = Int(message.timestamp.timeIntervalSince1970)
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
var baseTags = [["g", ch.geohash]]
|
content: trimmed,
|
||||||
if !self.nickname.isEmpty { baseTags.append(["n", self.nickname]) }
|
geohash: ch.geohash,
|
||||||
if LocationChannelManager.shared.teleported { baseTags.append(["t", "teleport"]) }
|
senderIdentity: identity,
|
||||||
let bits = PowPolicy.requiredBits(forGeohash: ch.geohash)
|
nickname: self.nickname,
|
||||||
// Mine off the main thread
|
teleported: LocationChannelManager.shared.teleported
|
||||||
let (nonce, _) = await withCheckedContinuation { (cont: CheckedContinuation<(UInt64, String), Never>) in
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
|
||||||
let res = NostrPoW.mine(pubkey: identity.publicKeyHex,
|
|
||||||
createdAt: createdAt,
|
|
||||||
kind: NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
|
||||||
baseTags: baseTags,
|
|
||||||
content: contentToSend,
|
|
||||||
targetBits: bits)
|
|
||||||
cont.resume(returning: (res.nonce, res.idHex))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Build final event with nonce tag and sign
|
|
||||||
var tags = baseTags
|
|
||||||
tags.append(["nonce", String(nonce), String(bits)])
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: identity.publicKeyHex,
|
|
||||||
createdAt: message.timestamp,
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: tags,
|
|
||||||
content: contentToSend
|
|
||||||
)
|
)
|
||||||
let schnorrKey = try identity.schnorrSigningKey()
|
|
||||||
event = try event.sign(with: schnorrKey)
|
|
||||||
|
|
||||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||||
toGeohash: ch.geohash,
|
toGeohash: ch.geohash,
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
count: TransportConfig.nostrGeoRelayCount
|
||||||
@@ -1416,22 +1375,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
} else {
|
} else {
|
||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
}
|
}
|
||||||
// Track ourselves as active participant with PoW record
|
// Track ourselves as active participant
|
||||||
if let idData = Data(hexString: event.id) {
|
|
||||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
|
||||||
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex, geohash: ch.geohash, powBits: powBits)
|
|
||||||
} else {
|
|
||||||
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
}
|
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
|
||||||
SecureLogger.log("Geo: sent geo event id=\(event.id.prefix(12))… bits=\(bits) nonce=\(String(nonce))", category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
// Reveal timestamp / stop animation
|
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
||||||
if let idx = self.messages.firstIndex(where: { $0.id == msgID }) {
|
// Only when not in our regional set (and regional list is known)
|
||||||
self.messages[idx].isMiningPow = false
|
|
||||||
self.messages[idx].clearCachedFormattedText()
|
|
||||||
// Trigger UI refresh so timestamp appears
|
|
||||||
self.objectWillChange.send()
|
|
||||||
}
|
|
||||||
// If teleported and outside regional set, mark self teleported
|
|
||||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||||
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
||||||
@@ -1443,11 +1392,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("❌ Failed to send geohash message: \(error)", category: SecureLogger.session, level: .error)
|
SecureLogger.log("❌ Failed to send geohash message: \(error)", category: SecureLogger.session, level: .error)
|
||||||
self.addSystemMessage("failed to send to location channel")
|
self.addSystemMessage("failed to send to location channel")
|
||||||
if let idx = self.messages.firstIndex(where: { $0.id == msgID }) {
|
|
||||||
self.messages[idx].isMiningPow = false
|
|
||||||
self.messages[idx].clearCachedFormattedText()
|
|
||||||
self.objectWillChange.send()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1549,16 +1493,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Deduplicate
|
// Deduplicate
|
||||||
if self.processedNostrEvents.contains(event.id) { return }
|
if self.processedNostrEvents.contains(event.id) { return }
|
||||||
self.recordProcessedEvent(event.id)
|
self.recordProcessedEvent(event.id)
|
||||||
// PoW filter for geohash events
|
|
||||||
let ghFromTag: String? = event.tags.first(where: { $0.first?.lowercased() == "g" && $0.count >= 2 })?.dropFirst().first
|
|
||||||
let ghValue = ghFromTag ?? ch.geohash
|
|
||||||
if let idData = Data(hexString: event.id) {
|
|
||||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
|
||||||
let required = PowPolicy.requiredBits(forGeohash: ghValue)
|
|
||||||
if powBits < required {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Log incoming tags for diagnostics
|
// Log incoming tags for diagnostics
|
||||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||||
SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)",
|
SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)",
|
||||||
@@ -1605,11 +1539,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.nostrKeyMapping[key16] = event.pubkey
|
self.nostrKeyMapping[key16] = event.pubkey
|
||||||
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||||
self.nostrKeyMapping[key8] = event.pubkey
|
self.nostrKeyMapping[key8] = event.pubkey
|
||||||
// Update participants last-seen for this pubkey with observed PoW bits
|
// Update participants last-seen for this pubkey
|
||||||
if let idData = Data(hexString: event.id) {
|
self.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
|
||||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: ch.geohash, powBits: powBits)
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderName = self.displayNameForNostrPubkey(event.pubkey)
|
let senderName = self.displayNameForNostrPubkey(event.pubkey)
|
||||||
let content = event.content
|
let content = event.content
|
||||||
@@ -1794,24 +1725,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track last observed PoW bits per participant per geohash
|
|
||||||
private var geoParticipantPowBits: [String: [String: Int]] = [:] // geohash -> (pubkeyLower -> bits)
|
|
||||||
|
|
||||||
private func recordGeoParticipant(pubkeyHex: String, geohash: String, powBits: Int?) {
|
|
||||||
let key = pubkeyHex.lowercased()
|
|
||||||
var map = geoParticipants[geohash] ?? [:]
|
|
||||||
map[key] = Date()
|
|
||||||
geoParticipants[geohash] = map
|
|
||||||
if let bits = powBits {
|
|
||||||
var powMap = geoParticipantPowBits[geohash] ?? [:]
|
|
||||||
powMap[key] = bits
|
|
||||||
geoParticipantPowBits[geohash] = powMap
|
|
||||||
}
|
|
||||||
if currentGeohash == geohash {
|
|
||||||
refreshGeohashPeople()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshGeohashPeople() {
|
private func refreshGeohashPeople() {
|
||||||
guard let gh = currentGeohash else { geohashPeople = []; return }
|
guard let gh = currentGeohash else { geohashPeople = []; return }
|
||||||
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||||
@@ -1820,17 +1733,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
map = map.filter { $0.value >= cutoff }
|
map = map.filter { $0.value >= cutoff }
|
||||||
// Remove blocked Nostr pubkeys
|
// Remove blocked Nostr pubkeys
|
||||||
map = map.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
map = map.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||||
// Enforce PoW threshold for peer list as well
|
|
||||||
let required = PowPolicy.requiredBits(forGeohash: gh)
|
|
||||||
let myHex: String? = (try? NostrIdentityBridge.deriveIdentity(forGeohash: gh))?.publicKeyHex.lowercased()
|
|
||||||
let powMap = geoParticipantPowBits[gh] ?? [:]
|
|
||||||
map = map.filter { (pub, _) in
|
|
||||||
if let bits = powMap[pub] { return bits >= required }
|
|
||||||
// Always allow self even if no PoW record yet
|
|
||||||
if let me = myHex, pub == me { return true }
|
|
||||||
// Otherwise, if no PoW info, hide until we see an acceptable event
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
geoParticipants[gh] = map
|
geoParticipants[gh] = map
|
||||||
// Build display list
|
// Build display list
|
||||||
let people = map
|
let people = map
|
||||||
@@ -1861,17 +1763,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
func visibleGeohashPeople() -> [GeoPerson] {
|
func visibleGeohashPeople() -> [GeoPerson] {
|
||||||
guard let gh = currentGeohash else { return [] }
|
guard let gh = currentGeohash else { return [] }
|
||||||
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||||
let required = PowPolicy.requiredBits(forGeohash: gh)
|
|
||||||
let myHex: String? = (try? NostrIdentityBridge.deriveIdentity(forGeohash: gh))?.publicKeyHex.lowercased()
|
|
||||||
let powMap = geoParticipantPowBits[gh] ?? [:]
|
|
||||||
let map = (geoParticipants[gh] ?? [:])
|
let map = (geoParticipants[gh] ?? [:])
|
||||||
.filter { $0.value >= cutoff }
|
.filter { $0.value >= cutoff }
|
||||||
.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||||
.filter { (pub, _) in
|
|
||||||
if let bits = powMap[pub] { return bits >= required }
|
|
||||||
if let me = myHex, pub == me { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
let people = map
|
let people = map
|
||||||
.map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) }
|
.map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) }
|
||||||
.sorted { $0.lastSeen > $1.lastSeen }
|
.sorted { $0.lastSeen > $1.lastSeen }
|
||||||
@@ -1973,12 +1867,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
// Enforce PoW for sampled geohashes too
|
|
||||||
if let idData = Data(hexString: event.id) {
|
|
||||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
|
||||||
let required = PowPolicy.requiredBits(forGeohash: gh)
|
|
||||||
if powBits < required { return }
|
|
||||||
}
|
|
||||||
// Compute current participant count (5-minute window) BEFORE updating with this event
|
// Compute current participant count (5-minute window) BEFORE updating with this event
|
||||||
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||||
let existingCount: Int = {
|
let existingCount: Int = {
|
||||||
@@ -1986,12 +1874,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return map.values.filter { $0 >= cutoff }.count
|
return map.values.filter { $0 >= cutoff }.count
|
||||||
}()
|
}()
|
||||||
// Update participants for this specific geohash
|
// Update participants for this specific geohash
|
||||||
if let idData = Data(hexString: event.id) {
|
|
||||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
|
||||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh, powBits: powBits)
|
|
||||||
} else {
|
|
||||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||||
}
|
|
||||||
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
||||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !content.isEmpty else { return }
|
guard !content.isEmpty else { return }
|
||||||
@@ -2786,7 +2669,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
meshService.sendMessage(screenshotMessage, mentions: [])
|
meshService.sendMessage(screenshotMessage, mentions: [])
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await mineAndSendGeohashEvent(content: screenshotMessage, geohash: ch.geohash, nickname: self.nickname, teleported: LocationChannelManager.shared.teleported, createdAt: Date())
|
do {
|
||||||
|
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||||
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
|
content: screenshotMessage,
|
||||||
|
geohash: ch.geohash,
|
||||||
|
senderIdentity: identity,
|
||||||
|
nickname: self.nickname,
|
||||||
|
teleported: LocationChannelManager.shared.teleported
|
||||||
|
)
|
||||||
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning)
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
|
// Track ourselves as active participant
|
||||||
|
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.log("❌ Failed to send geohash screenshot message: \(error)", category: SecureLogger.session, level: .error)
|
||||||
|
self.addSystemMessage("failed to send to location channel")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3237,59 +3140,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return processedContent
|
return processedContent
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers for mining UI
|
|
||||||
@MainActor
|
|
||||||
func isSelfMessage(_ message: BitchatMessage) -> Bool {
|
|
||||||
let isSelf: Bool = {
|
|
||||||
if let spid = message.senderPeerID {
|
|
||||||
if spid == meshService.myPeerID { return true }
|
|
||||||
if case .location(let ch) = activeChannel,
|
|
||||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
|
||||||
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
|
||||||
let bare: String = spid.hasPrefix("nostr:") ? String(spid.dropFirst(6)) : String(spid.dropFirst(6))
|
|
||||||
return bare.lowercased().hasPrefix(id.publicKeyHex.lowercased().prefix(TransportConfig.nostrShortKeyDisplayLength))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return spid == meshService.myPeerID
|
|
||||||
}
|
|
||||||
if message.sender == nickname { return true }
|
|
||||||
if message.sender.hasPrefix(nickname + "#") { return true }
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
return isSelf
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func baseColorForMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> Color {
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
return isSelfMessage(message) ? .orange : peerColor(for: message, isDark: isDark)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func formatSenderPrefix(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
|
||||||
let isSelf = isSelfMessage(message)
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
|
|
||||||
let (baseName, suffix) = splitSuffix(from: message.sender)
|
|
||||||
var senderStyle = AttributeContainer()
|
|
||||||
senderStyle.foregroundColor = baseColor
|
|
||||||
let fontWeight: Font.Weight = isSelf ? .bold : .medium
|
|
||||||
senderStyle.font = .system(size: 14, weight: fontWeight, design: .monospaced)
|
|
||||||
if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
|
|
||||||
senderStyle.link = url
|
|
||||||
}
|
|
||||||
var prefix = AttributedString()
|
|
||||||
prefix.append(AttributedString("<@").mergingAttributes(senderStyle))
|
|
||||||
prefix.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
|
||||||
if !suffix.isEmpty {
|
|
||||||
var suffixStyle = senderStyle
|
|
||||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
|
||||||
prefix.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
|
||||||
}
|
|
||||||
prefix.append(AttributedString("> ").mergingAttributes(senderStyle))
|
|
||||||
return prefix
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||||
// Determine if this message was sent by self (mesh, geo, or DM)
|
// Determine if this message was sent by self (mesh, geo, or DM)
|
||||||
@@ -3595,15 +3445,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add timestamp at the end (hide while PoW mining)
|
// Add timestamp at the end (smaller, light grey)
|
||||||
let hideTimestamp = (message.isMiningPow ?? false)
|
|
||||||
if !hideTimestamp {
|
|
||||||
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
|
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
|
||||||
var timestampStyle = AttributeContainer()
|
var timestampStyle = AttributeContainer()
|
||||||
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||||
timestampStyle.font = .system(size: 10, design: .monospaced)
|
timestampStyle.font = .system(size: 10, design: .monospaced)
|
||||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// System message
|
// System message
|
||||||
var contentStyle = AttributeContainer()
|
var contentStyle = AttributeContainer()
|
||||||
@@ -5038,7 +4885,24 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
func sendPublicRaw(_ content: String) {
|
func sendPublicRaw(_ content: String) {
|
||||||
if case .location(let ch) = activeChannel {
|
if case .location(let ch) = activeChannel {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await mineAndSendGeohashEvent(content: content, geohash: ch.geohash, nickname: self.nickname, teleported: LocationChannelManager.shared.teleported, createdAt: Date())
|
do {
|
||||||
|
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||||
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
|
content: content,
|
||||||
|
geohash: ch.geohash,
|
||||||
|
senderIdentity: identity,
|
||||||
|
nickname: self.nickname,
|
||||||
|
teleported: LocationChannelManager.shared.teleported
|
||||||
|
)
|
||||||
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -5046,49 +4910,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
meshService.sendMessage(content, mentions: [])
|
meshService.sendMessage(content, mentions: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Geohash PoW helper for system/raw messages
|
|
||||||
@MainActor
|
|
||||||
private func mineAndSendGeohashEvent(content: String, geohash: String, nickname: String, teleported: Bool, createdAt: Date) async {
|
|
||||||
do {
|
|
||||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
|
||||||
let createdAtSecs = Int(createdAt.timeIntervalSince1970)
|
|
||||||
var baseTags = [["g", geohash]]
|
|
||||||
if !nickname.isEmpty { baseTags.append(["n", nickname]) }
|
|
||||||
if teleported { baseTags.append(["t", "teleport"]) }
|
|
||||||
let bits = PowPolicy.requiredBits(forGeohash: geohash)
|
|
||||||
let (nonce, _) = await withCheckedContinuation { (cont: CheckedContinuation<(UInt64, String), Never>) in
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
|
||||||
let res = NostrPoW.mine(pubkey: identity.publicKeyHex,
|
|
||||||
createdAt: createdAtSecs,
|
|
||||||
kind: NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
|
||||||
baseTags: baseTags,
|
|
||||||
content: content,
|
|
||||||
targetBits: bits)
|
|
||||||
cont.resume(returning: (res.nonce, res.idHex))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var tags = baseTags
|
|
||||||
tags.append(["nonce", String(nonce), String(bits)])
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: identity.publicKeyHex,
|
|
||||||
createdAt: createdAt,
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: tags,
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
let key = try identity.schnorrSigningKey()
|
|
||||||
event = try event.sign(with: key)
|
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
|
||||||
if relays.isEmpty {
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
} else {
|
|
||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.log("❌ GeoPoW: failed to send event: \(error)", category: SecureLogger.session, level: .error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -303,21 +303,10 @@ struct ContentView: View {
|
|||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
let formatted = viewModel.formatMessageAsText(message, colorScheme: colorScheme)
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
let prefixAttr = viewModel.formatSenderPrefix(message, colorScheme: colorScheme)
|
|
||||||
let baseColor = viewModel.baseColorForMessage(message, colorScheme: colorScheme)
|
|
||||||
let isSelfMsg = viewModel.isSelfMessage(message)
|
|
||||||
if (message.isMiningPow ?? false) {
|
|
||||||
// Single Text combines prefix + animated body, so wrapping matches final layout
|
|
||||||
CombinedMiningText(prefix: prefixAttr, original: message.content, color: baseColor, isSelf: isSelfMsg)
|
|
||||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
} else {
|
|
||||||
Text(formatted)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
|
||||||
|
|
||||||
// Delivery status indicator for private messages
|
// Delivery status indicator for private messages
|
||||||
if message.isPrivate && message.sender == viewModel.nickname,
|
if message.isPrivate && message.sender == viewModel.nickname,
|
||||||
@@ -1398,53 +1387,6 @@ struct ContentView: View {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mining UI helpers (local to ensure inclusion in target)
|
|
||||||
fileprivate struct CombinedMiningText: View {
|
|
||||||
let prefix: AttributedString
|
|
||||||
let original: String
|
|
||||||
let color: Color
|
|
||||||
let isSelf: Bool
|
|
||||||
let fontSize: CGFloat = 14
|
|
||||||
let interval: TimeInterval = 0.06
|
|
||||||
@State private var display: String = ""
|
|
||||||
@State private var timer: Timer? = nil
|
|
||||||
|
|
||||||
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(combinedAttributed)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.onAppear {
|
|
||||||
display = scramble(original)
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
|
|
||||||
display = scramble(original)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDisappear { timer?.invalidate(); timer = nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var combinedAttributed: AttributedString {
|
|
||||||
var res = AttributedString()
|
|
||||||
res.append(prefix)
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = color
|
|
||||||
style.font = .system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced)
|
|
||||||
res.append(AttributedString(display).mergingAttributes(style))
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scramble(_ s: String) -> String {
|
|
||||||
var out = String()
|
|
||||||
out.reserveCapacity(s.count)
|
|
||||||
for ch in s {
|
|
||||||
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
|
|
||||||
out.append(charset.randomElement() ?? ch)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Views
|
// MARK: - Helper Views
|
||||||
|
|
||||||
// Rounded payment chip button
|
// Rounded payment chip button
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Mining body animation: scramble characters (matrix-like) until PoW completes.
|
|
||||||
struct MiningScrambleText: View {
|
|
||||||
let original: String
|
|
||||||
let color: Color
|
|
||||||
let isSelf: Bool
|
|
||||||
let fontSize: CGFloat = 14
|
|
||||||
let interval: TimeInterval = 0.06
|
|
||||||
@State private var display: String = ""
|
|
||||||
@State private var timer: Timer? = nil
|
|
||||||
|
|
||||||
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(display)
|
|
||||||
.font(.system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced))
|
|
||||||
.foregroundColor(color)
|
|
||||||
.onAppear {
|
|
||||||
display = scramble(original)
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
|
|
||||||
display = scramble(original)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
timer?.invalidate(); timer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Combined prefix+body single-Text variant to ensure identical wrapping
|
|
||||||
struct MiningCombinedText: View {
|
|
||||||
let prefix: AttributedString
|
|
||||||
let original: String
|
|
||||||
let color: Color
|
|
||||||
let isSelf: Bool
|
|
||||||
let fontSize: CGFloat = 14
|
|
||||||
let interval: TimeInterval = 0.06
|
|
||||||
@State private var display: String = ""
|
|
||||||
@State private var timer: Timer? = nil
|
|
||||||
|
|
||||||
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(combinedAttributed)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.onAppear {
|
|
||||||
display = scramble(original)
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
|
|
||||||
display = scramble(original)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDisappear { timer?.invalidate(); timer = nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var combinedAttributed: AttributedString {
|
|
||||||
var res = AttributedString()
|
|
||||||
res.append(prefix)
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = color
|
|
||||||
style.font = .system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced)
|
|
||||||
res.append(AttributedString(display).mergingAttributes(style))
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scramble(_ s: String) -> String {
|
|
||||||
var out = String()
|
|
||||||
out.reserveCapacity(s.count)
|
|
||||||
for ch in s {
|
|
||||||
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
|
|
||||||
out.append(charset.randomElement() ?? ch)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scramble(_ s: String) -> String {
|
|
||||||
var out = String()
|
|
||||||
out.reserveCapacity(s.count)
|
|
||||||
for ch in s {
|
|
||||||
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
|
|
||||||
out.append(charset.randomElement() ?? ch)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
import CryptoKit
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
final class NostrPoWTests: XCTestCase {
|
|
||||||
func testLeadingZeroBits() {
|
|
||||||
// 0x00 -> 8, 0x00 -> 8, 0xF0 -> 0 leading zeros
|
|
||||||
let data = Data([0x00, 0x00, 0xF0]) + Data(repeating: 0x00, count: 29)
|
|
||||||
let lz = NostrPoW.leadingZeroBits(data)
|
|
||||||
XCTAssertEqual(lz, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMineLowDifficulty() {
|
|
||||||
let pubkey = String(repeating: "a", count: 64)
|
|
||||||
let createdAt = Int(Date().timeIntervalSince1970)
|
|
||||||
let kind = 20000
|
|
||||||
let tags = [["g", "u4pruydqqvj"]]
|
|
||||||
let content = "hello"
|
|
||||||
let targetBits = 8 // keep it very low for test speed
|
|
||||||
let (nonce, idHex) = NostrPoW.mine(pubkey: pubkey, createdAt: createdAt, kind: kind, baseTags: tags, content: content, targetBits: targetBits)
|
|
||||||
XCTAssertGreaterThan(nonce, 0)
|
|
||||||
// Verify difficulty
|
|
||||||
let powTags = tags + [["nonce", String(nonce), String(targetBits)]]
|
|
||||||
// Recompute ID
|
|
||||||
let serialized: [Any] = [0, pubkey, createdAt, kind, powTags, content]
|
|
||||||
let json = try! JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
|
||||||
let digest = CryptoKit.SHA256.hash(data: json)
|
|
||||||
let idData = Data(digest)
|
|
||||||
XCTAssertEqual(idHex.count, 64)
|
|
||||||
XCTAssertGreaterThanOrEqual(NostrPoW.leadingZeroBits(idData), targetBits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user