mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 00:45:20 +00:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53826d5145 | ||
|
|
f58955f194 | ||
|
|
537737f521 | ||
|
|
b58c71f777 | ||
|
|
b4e2746be6 | ||
|
|
91040f7ed4 | ||
|
|
588d8fef0d | ||
|
|
8cd5a09a86 | ||
|
|
121e1d246a | ||
|
|
e5028e5e86 | ||
|
|
6681ca11c3 | ||
|
|
81b5dd15c1 | ||
|
|
098f223906 | ||
|
|
d31dd8300f | ||
|
|
85b627945d | ||
|
|
035ad175a7 | ||
|
|
b995a3fe4f | ||
|
|
fb26db3bf0 | ||
|
|
de4bf0a471 | ||
|
|
f493b50163 | ||
|
|
4aa12c08e7 | ||
|
|
c2a0c86542 | ||
|
|
073e22e126 | ||
|
|
6533293f75 | ||
|
|
757acef8d1 | ||
|
|
db52c9463b | ||
|
|
b179d99cf8 | ||
|
|
73d0867c18 | ||
|
|
97f822b88d | ||
|
|
3f91d6510b | ||
|
|
2bb55cbe1a | ||
|
|
7fb93eb522 | ||
|
|
cb8b34f8ea | ||
|
|
b872113a4b | ||
|
|
de3795289d | ||
|
|
bd37cc69a0 | ||
|
|
788e21c4ea | ||
|
|
c179e34c43 | ||
|
|
aa8b257e68 | ||
|
|
7b4aeb506e | ||
|
|
5d5ed94952 | ||
|
|
4945688eca | ||
|
|
22bd975059 | ||
|
|
d28b58ecb2 | ||
|
|
e17163b3da | ||
|
|
9346e62971 | ||
|
|
a89fd153ee | ||
|
|
25bc737919 | ||
|
|
8218c12f69 | ||
|
|
60c2263a46 | ||
|
|
e2fcb44982 | ||
|
|
ebbb7b356f | ||
|
|
2d0f55ae84 | ||
|
|
51e8e4e51a | ||
|
|
fb251a3fa8 | ||
|
|
4052ba581a | ||
|
|
235fefe4ab | ||
|
|
8389961269 | ||
|
|
a244c4084f | ||
|
|
ed320fb0ad | ||
|
|
2bdc1535c7 | ||
|
|
60a375469a | ||
|
|
640567b7e4 | ||
|
|
f58bcaf615 | ||
|
|
6e19995de2 | ||
|
|
78d72f5814 | ||
|
|
f145d13992 | ||
|
|
f607413caf | ||
|
|
47db836a22 | ||
|
|
aa7a5efe6a | ||
|
|
c8d196f106 | ||
|
|
2cd90ff813 | ||
|
|
5267489fa2 | ||
|
|
790dcda8e5 |
@@ -14,11 +14,8 @@ default:
|
|||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
check:
|
check:
|
||||||
@echo "Checking prerequisites..."
|
@echo "Checking prerequisites..."
|
||||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
||||||
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
|
||||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
|
||||||
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
|
||||||
@echo "✅ All prerequisites met"
|
@echo "✅ All prerequisites met"
|
||||||
|
|
||||||
# Backup original files
|
# Backup original files
|
||||||
|
|||||||
@@ -33,32 +33,13 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||||
guard !entries.isEmpty, count > 0 else { return [] }
|
guard !entries.isEmpty else { return [] }
|
||||||
|
let sorted = entries
|
||||||
if entries.count <= count {
|
.sorted { a, b in
|
||||||
return entries
|
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||||
.sorted { a, b in
|
|
||||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
|
||||||
}
|
|
||||||
.map { "wss://\($0.host)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
var best: [(entry: Entry, distance: Double)] = []
|
|
||||||
best.reserveCapacity(count)
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
|
||||||
if best.count < count {
|
|
||||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
|
||||||
best.insert((entry, distance), at: idx)
|
|
||||||
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
|
||||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
|
||||||
best.insert((entry, distance), at: idx)
|
|
||||||
best.removeLast()
|
|
||||||
}
|
}
|
||||||
}
|
.prefix(count)
|
||||||
|
return sorted.map { "wss://\($0.host)" }
|
||||||
return best.map { "wss://\($0.entry.host)" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
|
|||||||
@@ -1142,19 +1142,9 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Check cumulative size before storing this fragment
|
// Check cumulative size before storing this fragment
|
||||||
let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0
|
let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0
|
||||||
let assemblyLimit: Int = {
|
guard currentSize + fragmentData.count <= FileTransferLimits.maxPayloadBytes else {
|
||||||
if originalType == MessageType.fileTransfer.rawValue {
|
|
||||||
// Allow headroom for TLV metadata and binary framing overhead.
|
|
||||||
return FileTransferLimits.maxFramedFileBytes
|
|
||||||
}
|
|
||||||
return FileTransferLimits.maxPayloadBytes
|
|
||||||
}()
|
|
||||||
guard currentSize + fragmentData.count <= assemblyLimit else {
|
|
||||||
// Exceeds size limit - evict this assembly
|
// Exceeds size limit - evict this assembly
|
||||||
SecureLogger.warning(
|
SecureLogger.warning("🚫 Fragment assembly exceeds size limit (\(currentSize + fragmentData.count) bytes), evicting", category: .security)
|
||||||
"🚫 Fragment assembly exceeds size limit (\(currentSize + fragmentData.count) bytes > \(assemblyLimit)), evicting",
|
|
||||||
category: .security
|
|
||||||
)
|
|
||||||
incomingFragments.removeValue(forKey: key)
|
incomingFragments.removeValue(forKey: key)
|
||||||
fragmentMetadata.removeValue(forKey: key)
|
fragmentMetadata.removeValue(forKey: key)
|
||||||
shouldReassemble = false
|
shouldReassemble = false
|
||||||
@@ -3814,20 +3804,18 @@ extension BLEService {
|
|||||||
|
|
||||||
var accepted = false
|
var accepted = false
|
||||||
var senderNickname: String = ""
|
var senderNickname: String = ""
|
||||||
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
|
|
||||||
let peersSnapshot = collectionsQueue.sync { peers }
|
|
||||||
|
|
||||||
// If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately
|
// If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately
|
||||||
if peerID == myPeerID {
|
if peerID == myPeerID {
|
||||||
accepted = true
|
accepted = true
|
||||||
senderNickname = myNickname
|
senderNickname = myNickname
|
||||||
}
|
}
|
||||||
else if let info = peersSnapshot[peerID], info.isVerifiedNickname {
|
else if let info = peers[peerID], info.isVerifiedNickname {
|
||||||
// Known verified peer path
|
// Known verified peer path
|
||||||
accepted = true
|
accepted = true
|
||||||
senderNickname = info.nickname
|
senderNickname = info.nickname
|
||||||
// Handle nickname collisions
|
// Handle nickname collisions
|
||||||
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
|
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
|
||||||
if hasCollision {
|
if hasCollision {
|
||||||
senderNickname += "#" + String(peerID.id.prefix(4))
|
senderNickname += "#" + String(peerID.id.prefix(4))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,11 +65,14 @@ final class CommandProcessor {
|
|||||||
case "/unfav":
|
case "/unfav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: false)
|
return handleFavorite(args, add: false)
|
||||||
|
//
|
||||||
|
case "/help", "/h":
|
||||||
|
return .error(message: "unknown command: \(cmd)")
|
||||||
default:
|
default:
|
||||||
return .error(message: "unknown command: \(cmd)")
|
return .error(message: "unknown command: \(cmd)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Command Handlers
|
// MARK: - Command Handlers
|
||||||
|
|
||||||
private func handleMessage(_ args: String) -> CommandResult {
|
private func handleMessage(_ args: String) -> CommandResult {
|
||||||
@@ -308,4 +311,19 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleHelp() -> CommandResult {
|
||||||
|
let helpText = """
|
||||||
|
commands:
|
||||||
|
/msg @name - start private chat
|
||||||
|
/who - list who's online
|
||||||
|
/clear - clear messages
|
||||||
|
/hug @name - send a hug
|
||||||
|
/slap @name - slap with a trout
|
||||||
|
/fav @name - add to favorites
|
||||||
|
/unfav @name - remove from favorites
|
||||||
|
/block @name - block
|
||||||
|
/unblock @name - unblock
|
||||||
|
"""
|
||||||
|
return .success(message: helpText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,34 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||||
|
|
||||||
|
private func isSandboxed() -> Bool {
|
||||||
|
#if os(macOS)
|
||||||
|
// More robust sandbox detection using multiple methods
|
||||||
|
|
||||||
|
// Method 1: Check environment variable (can be spoofed)
|
||||||
|
let environment = ProcessInfo.processInfo.environment
|
||||||
|
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
|
||||||
|
|
||||||
|
// Method 2: Check if we can access a path outside sandbox
|
||||||
|
let homeDir = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
|
||||||
|
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
|
||||||
|
if canWriteOutsideSandbox {
|
||||||
|
try? FileManager.default.removeItem(at: testPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Check container path
|
||||||
|
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
|
||||||
|
let hasContainerPath = containerPath.contains("/Containers/")
|
||||||
|
|
||||||
|
// If any method indicates sandbox, we consider it sandboxed
|
||||||
|
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
|
||||||
|
#else
|
||||||
|
// iOS is always sandboxed
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Identity Keys
|
// MARK: - Identity Keys
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
|
|||||||
@@ -8,16 +8,6 @@ enum FileTransferLimits {
|
|||||||
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
|
||||||
static let maxFramedFileBytes: Int = {
|
|
||||||
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
|
||||||
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
|
|
||||||
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
|
|
||||||
+ BinaryProtocol.senderIDSize
|
|
||||||
+ BinaryProtocol.recipientIDSize
|
|
||||||
+ BinaryProtocol.signatureSize
|
|
||||||
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
|
||||||
}()
|
|
||||||
|
|
||||||
static func isValidPayload(_ size: Int) -> Bool {
|
static func isValidPayload(_ size: Int) -> Bool {
|
||||||
size <= maxPayloadBytes
|
size <= maxPayloadBytes
|
||||||
|
|||||||
@@ -198,37 +198,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Persistent recent content map (LRU) to speed near-duplicate checks
|
// Persistent recent content map (LRU) to speed near-duplicate checks
|
||||||
private var contentLRUMap: [String: Date] = [:]
|
private var contentLRUMap: [String: Date] = [:]
|
||||||
private var contentLRUOrder: [String] = []
|
private var contentLRUOrder: [String] = []
|
||||||
private var contentLRUHead = 0
|
|
||||||
private let contentLRUCap = TransportConfig.contentLRUCap
|
private let contentLRUCap = TransportConfig.contentLRUCap
|
||||||
private func recordContentKey(_ key: String, timestamp: Date) {
|
private func recordContentKey(_ key: String, timestamp: Date) {
|
||||||
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
|
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
|
||||||
contentLRUMap[key] = timestamp
|
contentLRUMap[key] = timestamp
|
||||||
trimContentLRUIfNeeded()
|
if contentLRUOrder.count > contentLRUCap {
|
||||||
}
|
let overflow = contentLRUOrder.count - contentLRUCap
|
||||||
|
for _ in 0..<overflow {
|
||||||
private func trimContentLRUIfNeeded() {
|
if let victim = contentLRUOrder.first {
|
||||||
let activeCount = contentLRUOrder.count - contentLRUHead
|
contentLRUOrder.removeFirst()
|
||||||
guard activeCount > contentLRUCap else { return }
|
contentLRUMap.removeValue(forKey: victim)
|
||||||
|
}
|
||||||
let overflow = activeCount - contentLRUCap
|
}
|
||||||
for _ in 0..<overflow {
|
|
||||||
guard let victim = popOldestContentKey() else { break }
|
|
||||||
contentLRUMap.removeValue(forKey: victim)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func popOldestContentKey() -> String? {
|
|
||||||
guard contentLRUHead < contentLRUOrder.count else { return nil }
|
|
||||||
let victim = contentLRUOrder[contentLRUHead]
|
|
||||||
contentLRUHead += 1
|
|
||||||
|
|
||||||
// Periodically compact the backing storage to avoid unbounded growth.
|
|
||||||
if contentLRUHead >= 32 && contentLRUHead * 2 >= contentLRUOrder.count {
|
|
||||||
contentLRUOrder.removeFirst(contentLRUHead)
|
|
||||||
contentLRUHead = 0
|
|
||||||
}
|
|
||||||
return victim
|
|
||||||
}
|
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
|
|
||||||
@Published var messages: [BitchatMessage] = []
|
@Published var messages: [BitchatMessage] = []
|
||||||
@@ -366,7 +349,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// PeerManager replaced by UnifiedPeerService
|
// PeerManager replaced by UnifiedPeerService
|
||||||
private var processedNostrEvents = Set<String>() // Simple deduplication
|
private var processedNostrEvents = Set<String>() // Simple deduplication
|
||||||
private var processedNostrEventOrder: [String] = []
|
private var processedNostrEventOrder: [String] = []
|
||||||
private var processedNostrEventHead = 0
|
|
||||||
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
|
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
@@ -2193,35 +2175,30 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return "anon#\(suffix)"
|
return "anon#\(suffix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: display name for current active channel (for notifications)
|
||||||
|
private func activeChannelDisplayName() -> String {
|
||||||
|
switch activeChannel {
|
||||||
|
case .mesh:
|
||||||
|
return "#mesh"
|
||||||
|
case .location(let ch):
|
||||||
|
return "#\(ch.geohash)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dedup helper with small memory cap
|
// Dedup helper with small memory cap
|
||||||
private func recordProcessedEvent(_ id: String) {
|
private func recordProcessedEvent(_ id: String) {
|
||||||
processedNostrEvents.insert(id)
|
processedNostrEvents.insert(id)
|
||||||
processedNostrEventOrder.append(id)
|
processedNostrEventOrder.append(id)
|
||||||
trimProcessedNostrEventsIfNeeded()
|
if processedNostrEventOrder.count > maxProcessedNostrEvents {
|
||||||
}
|
let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents
|
||||||
|
for _ in 0..<overflow {
|
||||||
private func trimProcessedNostrEventsIfNeeded() {
|
if let old = processedNostrEventOrder.first {
|
||||||
let activeCount = processedNostrEventOrder.count - processedNostrEventHead
|
processedNostrEventOrder.removeFirst()
|
||||||
guard activeCount > maxProcessedNostrEvents else { return }
|
processedNostrEvents.remove(old)
|
||||||
|
}
|
||||||
let overflow = activeCount - maxProcessedNostrEvents
|
}
|
||||||
for _ in 0..<overflow {
|
|
||||||
guard let old = popOldestProcessedEvent() else { break }
|
|
||||||
processedNostrEvents.remove(old)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func popOldestProcessedEvent() -> String? {
|
|
||||||
guard processedNostrEventHead < processedNostrEventOrder.count else { return nil }
|
|
||||||
let value = processedNostrEventOrder[processedNostrEventHead]
|
|
||||||
processedNostrEventHead += 1
|
|
||||||
|
|
||||||
if processedNostrEventHead >= 32 && processedNostrEventHead * 2 >= processedNostrEventOrder.count {
|
|
||||||
processedNostrEventOrder.removeFirst(processedNostrEventHead)
|
|
||||||
processedNostrEventHead = 0
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends an encrypted private message to a specific peer.
|
/// Sends an encrypted private message to a specific peer.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -3820,6 +3797,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if let spid = message.senderPeerID {
|
if let spid = message.senderPeerID {
|
||||||
// In geohash channels, compare against our per-geohash nostr short ID
|
// In geohash channels, compare against our per-geohash nostr short ID
|
||||||
if case .location(let ch) = activeChannel, spid.isGeoChat {
|
if case .location(let ch) = activeChannel, spid.isGeoChat {
|
||||||
|
// Use cached identity to avoid crypto during rendering
|
||||||
let myGeo: NostrIdentity? = {
|
let myGeo: NostrIdentity? = {
|
||||||
if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash {
|
if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash {
|
||||||
return cached.identity
|
return cached.identity
|
||||||
@@ -3831,7 +3809,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
if let myGeo {
|
if let myGeo = myGeo {
|
||||||
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5434,10 +5412,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
private func parseMentions(from content: String) -> [String] {
|
private func parseMentions(from content: String) -> [String] {
|
||||||
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
|
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
|
||||||
let regex = Regexes.mention
|
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
||||||
|
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||||
let nsContent = content as NSString
|
let nsContent = content as NSString
|
||||||
let nsLen = nsContent.length
|
let nsLen = nsContent.length
|
||||||
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||||
|
|
||||||
var mentions: [String] = []
|
var mentions: [String] = []
|
||||||
let peerNicknames = meshService.getPeerNicknames()
|
let peerNicknames = meshService.getPeerNicknames()
|
||||||
@@ -5948,6 +5927,61 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleNostrAcknowledgment(content: String, from senderPubkey: String) {
|
||||||
|
// Parse ACK format: "ACK:TYPE:MESSAGE_ID"
|
||||||
|
let parts = content.split(separator: ":", maxSplits: 2)
|
||||||
|
guard parts.count >= 3 else {
|
||||||
|
SecureLogger.warning("⚠️ Invalid ACK format: \(content)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ackType = String(parts[1])
|
||||||
|
let messageId = String(parts[2])
|
||||||
|
|
||||||
|
// Check if we've already processed this ACK
|
||||||
|
let ackKey = "\(messageId):\(ackType):\(senderPubkey)"
|
||||||
|
if processedNostrAcks.contains(ackKey) {
|
||||||
|
// Skip duplicate ACK
|
||||||
|
return
|
||||||
|
}
|
||||||
|
processedNostrAcks.insert(ackKey)
|
||||||
|
|
||||||
|
SecureLogger.debug("📨 Received \(ackType) ACK for message \(messageId.prefix(16))... from \(senderPubkey.prefix(16))...", category: .session)
|
||||||
|
|
||||||
|
// Verify the sender has a valid Noise key
|
||||||
|
guard findNoiseKey(for: senderPubkey) != nil else {
|
||||||
|
// Cannot find Noise key for ACK sender
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and update the message status in ALL private chats (both stable and ephemeral)
|
||||||
|
var messageFound = false
|
||||||
|
for (chatPeerID, messages) in privateChats {
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||||
|
// Update delivery status based on ACK type
|
||||||
|
switch ackType {
|
||||||
|
case "DELIVERED":
|
||||||
|
privateChats[chatPeerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
|
||||||
|
case "READ":
|
||||||
|
privateChats[chatPeerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
|
||||||
|
default:
|
||||||
|
SecureLogger.warning("⚠️ Unknown ACK type: \(ackType)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
messageFound = true
|
||||||
|
SecureLogger.info("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.id.prefix(16))...", category: .session)
|
||||||
|
// Don't break - continue to update in all chats where this message exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if messageFound {
|
||||||
|
objectWillChange.send()
|
||||||
|
} else {
|
||||||
|
SecureLogger.warning("⚠️ Could not find message \(messageId) to update status from ACK", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Base64URL utils
|
// MARK: - Base64URL utils
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+")
|
var str = s.replacingOccurrences(of: "-", with: "+")
|
||||||
@@ -6006,6 +6040,111 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleNostrMessageFromUnknownSender(
|
||||||
|
messageId: String,
|
||||||
|
content: String,
|
||||||
|
senderPubkey: String,
|
||||||
|
senderNickname: String? = nil,
|
||||||
|
timestamp: Date
|
||||||
|
) {
|
||||||
|
// Check if we already have this message in local storage
|
||||||
|
for (_, messages) in privateChats {
|
||||||
|
if messages.contains(where: { $0.id == messageId }) {
|
||||||
|
return // Skipping duplicate message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've read this message before (in a previous session)
|
||||||
|
let wasReadBefore = sentReadReceipts.contains(messageId)
|
||||||
|
|
||||||
|
// Try to find sender by checking all known peers for nickname matches
|
||||||
|
// This is a fallback when we receive Nostr messages from someone not in favorites
|
||||||
|
|
||||||
|
// For now, create a temporary peer ID based on Nostr pubkey
|
||||||
|
// This allows the message to be displayed even without Noise key mapping
|
||||||
|
let tempPeerID = PeerID(nostr_: senderPubkey)
|
||||||
|
|
||||||
|
// Check if we're viewing this unknown sender's chat
|
||||||
|
let isViewingThisChat = selectedPrivateChatPeer == tempPeerID
|
||||||
|
|
||||||
|
// Check if message is recent (less than 30 seconds old)
|
||||||
|
let messageAgeSeconds = Date().timeIntervalSince(timestamp)
|
||||||
|
let isRecentMessage = messageAgeSeconds < 30
|
||||||
|
|
||||||
|
// Determine if we should mark as unread BEFORE adding to chats
|
||||||
|
// During startup phase, only block OLD messages from being marked as unread
|
||||||
|
// Recent messages should always be marked as unread if not previously read
|
||||||
|
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase)
|
||||||
|
|
||||||
|
// Use provided nickname or try to extract from previous messages
|
||||||
|
var finalSenderNickname = senderNickname ?? "Unknown"
|
||||||
|
|
||||||
|
// If no nickname provided, check if we have any previous messages from this Nostr key
|
||||||
|
if senderNickname == nil {
|
||||||
|
for (_, messages) in privateChats {
|
||||||
|
if let previousMessage = messages.first(where: {
|
||||||
|
$0.senderPeerID == tempPeerID
|
||||||
|
}) {
|
||||||
|
finalSenderNickname = previousMessage.sender
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the message
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: messageId,
|
||||||
|
sender: finalSenderNickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: nickname,
|
||||||
|
senderPeerID: tempPeerID,
|
||||||
|
mentions: nil,
|
||||||
|
deliveryStatus: .delivered(to: nickname, at: Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store in private chats
|
||||||
|
if privateChats[tempPeerID] == nil {
|
||||||
|
privateChats[tempPeerID] = []
|
||||||
|
}
|
||||||
|
privateChats[tempPeerID]?.append(message)
|
||||||
|
|
||||||
|
// For unknown senders (no Noise key), skip sending Nostr ACKs
|
||||||
|
|
||||||
|
// Handle based on read status
|
||||||
|
if wasReadBefore {
|
||||||
|
// Message was read in a previous session - don't mark as unread or notify
|
||||||
|
// Not marking previously-read message as unread
|
||||||
|
} else if isViewingThisChat {
|
||||||
|
// Viewing this chat - mark as read
|
||||||
|
// No read ACKs for unknown senders
|
||||||
|
} else {
|
||||||
|
// Not viewing and not previously read
|
||||||
|
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
|
||||||
|
if shouldMarkAsUnread {
|
||||||
|
unreadPrivateMessages.insert(tempPeerID)
|
||||||
|
|
||||||
|
// Only notify if it's a recent message
|
||||||
|
if isRecentMessage {
|
||||||
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
|
from: finalSenderNickname,
|
||||||
|
message: content,
|
||||||
|
peerID: tempPeerID.id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Not notifying for old message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not notifying for old message
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.info("📬 Stored Nostr message from unknown sender \(finalSenderNickname) in temporary peer \(tempPeerID)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func findNoiseKey(for nostrPubkey: String) -> Data? {
|
private func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||||
// Convert hex to npub if needed for comparison
|
// Convert hex to npub if needed for comparison
|
||||||
|
|||||||
@@ -322,8 +322,8 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
|
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
|
||||||
let messages: [BitchatMessage] = {
|
let messages: [BitchatMessage] = {
|
||||||
if let peerID = PeerID(str: privatePeer) {
|
if let privatePeer {
|
||||||
return viewModel.getPrivateChatMessages(for: peerID)
|
return viewModel.getPrivateChatMessages(for: PeerID(str: privatePeer))
|
||||||
}
|
}
|
||||||
return viewModel.messages
|
return viewModel.messages
|
||||||
}()
|
}()
|
||||||
@@ -765,55 +765,26 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleOpenURL(_ url: URL) {
|
private func handleOpenURL(_ url: URL) {
|
||||||
guard url.scheme == "bitchat" else { return }
|
guard url.scheme == "bitchat", url.host == "user" else { return }
|
||||||
switch url.host {
|
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
case "user":
|
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
selectedMessageSenderID = peerID.id
|
||||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
|
||||||
selectedMessageSenderID = peerID.id
|
|
||||||
|
|
||||||
if peerID.isGeoDM || peerID.isGeoChat {
|
if peerID.isGeoDM || peerID.isGeoChat {
|
||||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||||
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
} else {
|
||||||
|
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
||||||
selectedMessageSender = name
|
selectedMessageSender = name
|
||||||
} else {
|
} else {
|
||||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
|
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
|
||||||
selectedMessageSender = nil
|
selectedMessageSender = nil
|
||||||
selectedMessageSenderID = nil
|
selectedMessageSenderID = nil
|
||||||
} else {
|
} else {
|
||||||
showMessageActions = true
|
showMessageActions = true
|
||||||
}
|
|
||||||
|
|
||||||
case "geohash":
|
|
||||||
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
|
||||||
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
|
||||||
|
|
||||||
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
|
||||||
switch len {
|
|
||||||
case 0...2: return .region
|
|
||||||
case 3...4: return .province
|
|
||||||
case 5: return .city
|
|
||||||
case 6: return .neighborhood
|
|
||||||
case 7: return .block
|
|
||||||
default: return .block
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let level = levelForLength(gh.count)
|
|
||||||
let channel = GeohashChannel(level: level, geohash: gh)
|
|
||||||
|
|
||||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
|
||||||
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
|
||||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
|
||||||
}
|
|
||||||
LocationChannelManager.shared.select(ChannelID.location(channel))
|
|
||||||
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,8 +792,8 @@ struct ContentView: View {
|
|||||||
privatePeer: String?,
|
privatePeer: String?,
|
||||||
isAtBottom: Binding<Bool>) {
|
isAtBottom: Binding<Bool>) {
|
||||||
let targetID: String? = {
|
let targetID: String? = {
|
||||||
if let peer = PeerID(str: privatePeer),
|
if let peer = privatePeer,
|
||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
let last = viewModel.getPrivateChatMessages(for: PeerID(str: peer)).suffix(300).last?.id {
|
||||||
return "dm:\(peer)|\(last)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
@@ -831,41 +802,17 @@ struct ContentView: View {
|
|||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if let last = viewModel.messages.suffix(300).last?.id {
|
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
|
||||||
return "\(contextKey)|\(last)"
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
isAtBottom.wrappedValue = true
|
isAtBottom.wrappedValue = true
|
||||||
|
guard let target = targetID else { return }
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let targetID {
|
proxy.scrollTo(target, anchor: .bottom)
|
||||||
proxy.scrollTo(targetID, anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
proxy.scrollTo(target, anchor: .bottom)
|
||||||
let secondTarget: String? = {
|
|
||||||
if let peer = PeerID(str: privatePeer),
|
|
||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
|
||||||
return "dm:\(peer)|\(last)"
|
|
||||||
}
|
|
||||||
let contextKey: String = {
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh: return "mesh"
|
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if let last = viewModel.messages.suffix(300).last?.id {
|
|
||||||
return "\(contextKey)|\(last)"
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}()
|
|
||||||
|
|
||||||
if let secondTarget {
|
|
||||||
proxy.scrollTo(secondTarget, anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|||||||
@@ -92,62 +92,6 @@ struct FragmentationTests {
|
|||||||
#expect(capture.publicMessages.count == 1)
|
#expect(capture.publicMessages.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Max-sized file transfer survives reassembly")
|
|
||||||
func maxSizedFileTransferSurvivesReassembly() async throws {
|
|
||||||
let ble = BLEService(
|
|
||||||
keychain: mockKeychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: mockIdentityManager
|
|
||||||
)
|
|
||||||
let capture = CaptureDelegate()
|
|
||||||
ble.delegate = capture
|
|
||||||
|
|
||||||
let remoteID = PeerID(str: "CAFEBABECAFEBABE")
|
|
||||||
let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes)
|
|
||||||
let filePacket = BitchatFilePacket(
|
|
||||||
fileName: "limit.bin",
|
|
||||||
fileSize: UInt64(fileContent.count),
|
|
||||||
mimeType: "application/octet-stream",
|
|
||||||
content: fileContent
|
|
||||||
)
|
|
||||||
let encoded = try #require(filePacket.encode(), "File packet encoding failed")
|
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
|
||||||
type: MessageType.fileTransfer.rawValue,
|
|
||||||
senderID: Data(hexString: remoteID.id) ?? Data(),
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
||||||
payload: encoded,
|
|
||||||
signature: nil,
|
|
||||||
ttl: 7,
|
|
||||||
version: 2
|
|
||||||
)
|
|
||||||
|
|
||||||
let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false)
|
|
||||||
#expect(!fragments.isEmpty)
|
|
||||||
|
|
||||||
for (i, fragment) in fragments.enumerated() {
|
|
||||||
let delay = 5 * Double(i) * 0.001
|
|
||||||
Task {
|
|
||||||
try await sleep(delay)
|
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try await sleep(1.0)
|
|
||||||
|
|
||||||
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
|
||||||
#expect(message.content.hasPrefix("[file]"))
|
|
||||||
|
|
||||||
if let fileName = message.content.split(separator: " ").last {
|
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
let filesRoot = base.appendingPathComponent("files", isDirectory: true)
|
|
||||||
let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true)
|
|
||||||
let url = incoming.appendingPathComponent(String(fileName))
|
|
||||||
try? FileManager.default.removeItem(at: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Invalid fragment header is ignored")
|
@Test("Invalid fragment header is ignored")
|
||||||
func invalidFragmentHeaderIsIgnored() async throws {
|
func invalidFragmentHeaderIsIgnored() async throws {
|
||||||
@@ -198,10 +142,7 @@ struct FragmentationTests {
|
|||||||
extension FragmentationTests {
|
extension FragmentationTests {
|
||||||
private final class CaptureDelegate: BitchatDelegate {
|
private final class CaptureDelegate: BitchatDelegate {
|
||||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
||||||
var receivedMessages: [BitchatMessage] = []
|
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {
|
|
||||||
receivedMessages.append(message)
|
|
||||||
}
|
|
||||||
func didConnectToPeer(_ peerID: PeerID) {}
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
@@ -232,8 +173,8 @@ extension FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper: fragment a packet using the same header format BLEService expects
|
// Helper: fragment a packet using the same header format BLEService expects
|
||||||
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] {
|
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil) -> [BitchatPacket] {
|
||||||
guard let fullData = packet.toBinaryData(padding: pad) else { return [] }
|
let fullData = packet.toBinaryData() ?? Data()
|
||||||
let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
||||||
let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in
|
let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in
|
||||||
Data(fullData[off..<min(off + fragmentSize, fullData.count)])
|
Data(fullData[off..<min(off + fragmentSize, fullData.count)])
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ struct NotificationStreamAssemblerTests {
|
|||||||
#expect(decoded.timestamp == packet.timestamp)
|
#expect(decoded.timestamp == packet.timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAssemblesCompressedLargeFrame() throws {
|
@Test func assemblesCompressedLargeFrame() throws {
|
||||||
var assembler = NotificationStreamAssembler()
|
var assembler = NotificationStreamAssembler()
|
||||||
|
|
||||||
// Keep the fixture below FileTransferLimits.maxPayloadBytes so encoding succeeds while still exercising compression.
|
// Keep the fixture below FileTransferLimits.maxPayloadBytes so encoding succeeds while still exercising compression.
|
||||||
@@ -108,7 +108,6 @@ struct NotificationStreamAssemblerTests {
|
|||||||
content: largeContent
|
content: largeContent
|
||||||
)
|
)
|
||||||
let tlvPayload = try #require(filePacket.encode(), "Failed to encode file packet")
|
let tlvPayload = try #require(filePacket.encode(), "Failed to encode file packet")
|
||||||
|
|
||||||
let senderID = Data(repeating: 0xAA, count: BinaryProtocol.senderIDSize)
|
let senderID = Data(repeating: 0xAA, count: BinaryProtocol.senderIDSize)
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.fileTransfer.rawValue,
|
type: MessageType.fileTransfer.rawValue,
|
||||||
@@ -125,7 +124,7 @@ struct NotificationStreamAssemblerTests {
|
|||||||
|
|
||||||
#expect(BinaryProtocol.Offsets.flags < frame.count)
|
#expect(BinaryProtocol.Offsets.flags < frame.count)
|
||||||
let flags = frame[frame.startIndex + BinaryProtocol.Offsets.flags]
|
let flags = frame[frame.startIndex + BinaryProtocol.Offsets.flags]
|
||||||
#expect((flags & BinaryProtocol.Flags.isCompressed) != 0, "Frame should be compressed for large payloads")
|
#expect(flags & BinaryProtocol.Flags.isCompressed != 0, "Frame should be compressed for large payloads")
|
||||||
|
|
||||||
let splitIndex = min(4096, frame.count / 2)
|
let splitIndex = min(4096, frame.count / 2)
|
||||||
var result = assembler.append(frame.prefix(splitIndex))
|
var result = assembler.append(frame.prefix(splitIndex))
|
||||||
@@ -134,7 +133,7 @@ struct NotificationStreamAssemblerTests {
|
|||||||
result = assembler.append(frame.suffix(from: splitIndex))
|
result = assembler.append(frame.suffix(from: splitIndex))
|
||||||
#expect(result.frames.count == 1)
|
#expect(result.frames.count == 1)
|
||||||
#expect(result.droppedPrefixes.isEmpty)
|
#expect(result.droppedPrefixes.isEmpty)
|
||||||
#expect(result.reset == false)
|
#expect(!result.reset)
|
||||||
|
|
||||||
let assembled = try #require(result.frames.first, "Missing assembled frame")
|
let assembled = try #require(result.frames.first, "Missing assembled frame")
|
||||||
#expect(assembled.count == frame.count)
|
#expect(assembled.count == frame.count)
|
||||||
|
|||||||
@@ -68,9 +68,10 @@ struct BinaryProtocolTests {
|
|||||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
|
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
|
||||||
|
|
||||||
// The encoded size should be smaller than uncompressed due to compression
|
// The encoded size should be smaller than uncompressed due to compression
|
||||||
let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), "Invalid packet version")
|
let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), "Invalid version")
|
||||||
|
|
||||||
let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
||||||
#expect(encodedData.count < uncompressedSize, "Compressed packet should be smaller than uncompressed form")
|
#expect(encodedData.count < uncompressedSize)
|
||||||
|
|
||||||
// Decode and verify
|
// Decode and verify
|
||||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet")
|
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet")
|
||||||
|
|||||||
@@ -6,13 +6,11 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
#if canImport(os.log)
|
|
||||||
import os.log
|
import os.log
|
||||||
#endif
|
|
||||||
|
|
||||||
public extension OSLog {
|
public extension OSLog {
|
||||||
private static let subsystem = "chat.bitchat"
|
private static let subsystem = "chat.bitchat"
|
||||||
|
|
||||||
static let noise = OSLog(subsystem: subsystem, category: "noise")
|
static let noise = OSLog(subsystem: subsystem, category: "noise")
|
||||||
static let encryption = OSLog(subsystem: subsystem, category: "encryption")
|
static let encryption = OSLog(subsystem: subsystem, category: "encryption")
|
||||||
static let keychain = OSLog(subsystem: subsystem, category: "keychain")
|
static let keychain = OSLog(subsystem: subsystem, category: "keychain")
|
||||||
|
|||||||
@@ -7,53 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(os.log)
|
|
||||||
import os.log
|
import os.log
|
||||||
#else
|
|
||||||
public struct OSLog {
|
|
||||||
public let subsystem: String
|
|
||||||
public let category: String
|
|
||||||
|
|
||||||
public init(subsystem: String, category: String) {
|
|
||||||
self.subsystem = subsystem
|
|
||||||
self.category = category
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct OSLogType: CustomStringConvertible {
|
|
||||||
private let label: String
|
|
||||||
|
|
||||||
private init(_ label: String) {
|
|
||||||
self.label = label
|
|
||||||
}
|
|
||||||
|
|
||||||
public var description: String { label }
|
|
||||||
|
|
||||||
public static let debug = OSLogType("debug")
|
|
||||||
public static let info = OSLogType("info")
|
|
||||||
public static let `default` = OSLogType("default")
|
|
||||||
public static let error = OSLogType("error")
|
|
||||||
public static let fault = OSLogType("fault")
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let secureLoggerFallbackFormatter: ISO8601DateFormatter = {
|
|
||||||
let formatter = ISO8601DateFormatter()
|
|
||||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
func os_log(_ message: StaticString, log: OSLog, type: OSLogType, _ args: CVarArg...) {
|
|
||||||
let rawFormat = String(describing: message)
|
|
||||||
let format = rawFormat
|
|
||||||
.replacingOccurrences(of: "%{public}@", with: "%@")
|
|
||||||
.replacingOccurrences(of: "%{private}@", with: "%@")
|
|
||||||
let formatted = String(format: format, arguments: args)
|
|
||||||
let timestamp = secureLoggerFallbackFormatter.string(from: Date())
|
|
||||||
print("[\(timestamp)] [\(log.subsystem)::\(log.category)] [\(type.description)] \(formatted)")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Centralized security-aware logging framework
|
/// Centralized security-aware logging framework
|
||||||
/// Provides safe logging that filters sensitive data and security events
|
/// Provides safe logging that filters sensitive data and security events
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(Network)
|
|
||||||
import Network
|
import Network
|
||||||
#endif
|
|
||||||
#if canImport(Darwin)
|
|
||||||
import Darwin
|
import Darwin
|
||||||
#elseif canImport(Glibc)
|
|
||||||
import Glibc
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if !canImport(Network)
|
// Declare C entrypoint for Tor when statically linked from an xcframework.
|
||||||
private final class NWPathMonitor {
|
@_silgen_name("tor_main")
|
||||||
var pathUpdateHandler: ((Any) -> Void)?
|
private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
|
||||||
|
|
||||||
func start(queue: DispatchQueue) {
|
|
||||||
// Path monitoring is unavailable on this platform; nothing to do.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Preferred: tiny C glue that uses Tor's embedding API (tor_api.h)
|
// Preferred: tiny C glue that uses Tor's embedding API (tor_api.h)
|
||||||
@_silgen_name("tor_host_start")
|
@_silgen_name("tor_host_start")
|
||||||
@@ -298,6 +286,150 @@ public final class TorManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Dynamic loader path (no Swift module required)
|
||||||
|
|
||||||
|
/// Attempt to locate an embedded tor framework binary and launch Tor via `tor_run_main`.
|
||||||
|
/// Returns true if the attempt started and port probing was scheduled.
|
||||||
|
private func startTorViaDlopen() -> Bool {
|
||||||
|
guard let fwURL = frameworkBinaryURL() else {
|
||||||
|
SecureLogger.warning("TorManager: no embedded tor framework found", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the library
|
||||||
|
let mode = RTLD_NOW | RTLD_LOCAL
|
||||||
|
SecureLogger.info("TorManager: dlopen(\(fwURL.lastPathComponent))…", category: .session)
|
||||||
|
guard let handle = dlopen(fwURL.path, mode) else {
|
||||||
|
let err = String(cString: dlerror())
|
||||||
|
self.lastError = NSError(domain: "TorManager", code: -10, userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(err)"])
|
||||||
|
self.isStarting = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve tor_main(argc, argv)
|
||||||
|
typealias TorMainType = @convention(c) (Int32, UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
|
||||||
|
guard let sym = dlsym(handle, "tor_main") else {
|
||||||
|
// Keep handle open but report error
|
||||||
|
let err = String(cString: dlerror())
|
||||||
|
self.lastError = NSError(domain: "TorManager", code: -11, userInfo: [NSLocalizedDescriptionKey: "dlsym tor_main failed: \(err)"])
|
||||||
|
self.isStarting = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let torMain = unsafeBitCast(sym, to: TorMainType.self)
|
||||||
|
self._dlHandle = handle
|
||||||
|
|
||||||
|
// Prepare args: tor -f <torrc>
|
||||||
|
var argv: [String] = ["tor"]
|
||||||
|
if let torrc = torrcURL()?.path {
|
||||||
|
argv.append(contentsOf: ["-f", torrc])
|
||||||
|
}
|
||||||
|
// Run Tor on a background thread to avoid blocking the main actor
|
||||||
|
SecureLogger.info("TorManager: launching tor_main with torrc", category: .session)
|
||||||
|
let argc = Int32(argv.count)
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
// Build stable C argv in this thread
|
||||||
|
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
|
||||||
|
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
|
||||||
|
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
|
||||||
|
cArgv[cStrings.count] = nil
|
||||||
|
|
||||||
|
_ = torMain(argc, cArgv)
|
||||||
|
|
||||||
|
// Free args after exit (Tor usually never returns)
|
||||||
|
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
|
||||||
|
cArgv.deallocate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start control-port monitor and probe readiness asynchronously
|
||||||
|
startControlMonitorIfNeeded()
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let ready = await self.waitForSocksReady(timeout: 60.0)
|
||||||
|
await MainActor.run {
|
||||||
|
self.socksReady = ready
|
||||||
|
if !ready {
|
||||||
|
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"])
|
||||||
|
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
|
||||||
|
} else {
|
||||||
|
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
|
||||||
|
}
|
||||||
|
// isStarting will be cleared when bootstrap reaches 100%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _dlHandle: UnsafeMutableRawPointer?
|
||||||
|
|
||||||
|
private func frameworkBinaryURL() -> URL? {
|
||||||
|
// Try common embedded locations for the framework binary name
|
||||||
|
let candidates = [
|
||||||
|
"tor-nolzma.framework/tor-nolzma",
|
||||||
|
"Tor.framework/Tor",
|
||||||
|
]
|
||||||
|
if let base = Bundle.main.privateFrameworksURL {
|
||||||
|
for rel in candidates {
|
||||||
|
let url = base.appendingPathComponent(rel)
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) { return url }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For macOS apps, also try Contents/Frameworks explicitly
|
||||||
|
#if os(macOS)
|
||||||
|
if let appURL = Bundle.main.bundleURL as URL?,
|
||||||
|
let frameworksURL = Optional(appURL.appendingPathComponent("Contents/Frameworks", isDirectory: true)) {
|
||||||
|
for rel in candidates {
|
||||||
|
let url = frameworksURL.appendingPathComponent(rel)
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) { return url }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Static-link path (no module import)
|
||||||
|
private func startTorViaLinkedSymbol() -> Bool {
|
||||||
|
// Attempt to start tor_run_main directly (statically linked). If the
|
||||||
|
// symbol is not present at link-time, builds will fail — which is
|
||||||
|
// expected when the xcframework is absent.
|
||||||
|
var argv: [String] = ["tor"]
|
||||||
|
if let torrc = torrcURL()?.path { argv.append(contentsOf: ["-f", torrc]) }
|
||||||
|
|
||||||
|
SecureLogger.info("TorManager: starting tor_main (static)", category: .session)
|
||||||
|
let argc = Int32(argv.count)
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
// Build stable C argv in this thread
|
||||||
|
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
|
||||||
|
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
|
||||||
|
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
|
||||||
|
cArgv[cStrings.count] = nil
|
||||||
|
|
||||||
|
_ = tor_main_c(argc, cArgv)
|
||||||
|
|
||||||
|
// If tor_main ever returns, free memory
|
||||||
|
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
|
||||||
|
cArgv.deallocate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start control monitor early
|
||||||
|
startControlMonitorIfNeeded()
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let ready = await self.waitForSocksReady(timeout: 60.0)
|
||||||
|
await MainActor.run {
|
||||||
|
self.socksReady = ready
|
||||||
|
if ready {
|
||||||
|
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
|
||||||
|
} else {
|
||||||
|
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
|
||||||
|
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
|
||||||
|
}
|
||||||
|
// isStarting will be cleared when bootstrap reaches 100%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - ControlPort monitoring (bootstrap progress)
|
// MARK: - ControlPort monitoring (bootstrap progress)
|
||||||
private func startControlMonitorIfNeeded() {
|
private func startControlMonitorIfNeeded() {
|
||||||
guard !controlMonitorStarted else { return }
|
guard !controlMonitorStarted else { return }
|
||||||
@@ -308,6 +440,10 @@ public final class TorManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func controlMonitorLoop() async {}
|
||||||
|
|
||||||
|
private func tryControlSessionOnce() async -> Bool { false }
|
||||||
|
|
||||||
// iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers.
|
// iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers.
|
||||||
private func bootstrapPollLoop() async {
|
private func bootstrapPollLoop() async {
|
||||||
let deadline = Date().addingTimeInterval(75)
|
let deadline = Date().addingTimeInterval(75)
|
||||||
|
|||||||
Reference in New Issue
Block a user