mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 03:45:21 +00:00
Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bd8a69c0 | ||
|
|
fb43a8b0f5 | ||
|
|
eb35608fa1 | ||
|
|
629bea7fd0 | ||
|
|
1cf4ea9e57 | ||
|
|
b81ae0b4c0 | ||
|
|
3b96d8590d | ||
|
|
6cbfe8ecfb | ||
|
|
8481e1552d | ||
|
|
9ebaa9108c | ||
|
|
2b3264b581 | ||
|
|
830f7ee4b8 | ||
|
|
c8768c32b8 | ||
|
|
1352484491 | ||
|
|
ef396936a3 | ||
|
|
c3ce32ad07 | ||
|
|
d7ca99ae5d | ||
|
|
b25f0d9f63 | ||
|
|
abb998d809 | ||
|
|
d71befd8b7 | ||
|
|
78c4bed1ad | ||
|
|
c52a2a7772 | ||
|
|
3df0aa0cc4 | ||
|
|
46caded099 | ||
|
|
a04ac9dabd | ||
|
|
1825d115bc | ||
|
|
9754881af8 | ||
|
|
59cc857fde | ||
|
|
f0ca1b27c2 | ||
|
|
5beff8b4dc | ||
|
|
ba49b885bb | ||
|
|
ac10e906c0 | ||
|
|
119e882574 | ||
|
|
0acbbdf2e3 | ||
|
|
40fe0c8ba5 | ||
|
|
7593053137 | ||
|
|
4dde063027 | ||
|
|
8c4fead945 | ||
|
|
2543277755 | ||
|
|
a8ce4cbf64 | ||
|
|
93aa714ed4 | ||
|
|
9c7bf26e13 | ||
|
|
c29c7d83fe | ||
|
|
7f743d48fe | ||
|
|
27c39332d5 | ||
|
|
53c08d6807 | ||
|
|
7efe0a23b1 | ||
|
|
8dedc81512 | ||
|
|
5cbe9d4aa7 | ||
|
|
515fae9e25 | ||
|
|
0946d3921f | ||
|
|
1d40950118 | ||
|
|
b404d48ba6 | ||
|
|
2f1ba27d51 | ||
|
|
23124b36b7 | ||
|
|
3e8d05fa2e | ||
|
|
c36b582209 | ||
|
|
3c66ca7499 | ||
|
|
a042576652 | ||
|
|
3c120042a4 | ||
|
|
4e3603d5e4 | ||
|
|
f7ad970a83 | ||
|
|
4cc87633e1 | ||
|
|
2d565ad918 | ||
|
|
b4cb4d36f1 | ||
|
|
7fcef2a9cb | ||
|
|
e7dc0e0c5a | ||
|
|
3b2c7d2f73 | ||
|
|
209f926990 | ||
|
|
ef8b509ea5 | ||
|
|
06b5bdb6da | ||
|
|
a5a3efebcb | ||
|
|
cd39d8c8c0 | ||
|
|
1046bfec0f | ||
|
|
d8c83e25f4 | ||
|
|
a9fa614416 | ||
|
|
ecbbc23862 | ||
|
|
b6d42261d0 |
@@ -14,8 +14,11 @@ default:
|
|||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
check:
|
check:
|
||||||
@echo "Checking prerequisites..."
|
@echo "Checking prerequisites..."
|
||||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
||||||
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
@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)
|
||||||
|
@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,13 +33,32 @@ 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 else { return [] }
|
guard !entries.isEmpty, count > 0 else { return [] }
|
||||||
let sorted = entries
|
|
||||||
|
if entries.count <= count {
|
||||||
|
return entries
|
||||||
.sorted { a, b in
|
.sorted { a, b in
|
||||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||||
}
|
}
|
||||||
.prefix(count)
|
.map { "wss://\($0.host)" }
|
||||||
return sorted.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best.map { "wss://\($0.entry.host)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
|
|||||||
@@ -1142,9 +1142,19 @@ 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
|
||||||
guard currentSize + fragmentData.count <= FileTransferLimits.maxPayloadBytes else {
|
let assemblyLimit: Int = {
|
||||||
|
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("🚫 Fragment assembly exceeds size limit (\(currentSize + fragmentData.count) bytes), evicting", category: .security)
|
SecureLogger.warning(
|
||||||
|
"🚫 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
|
||||||
@@ -3804,18 +3814,20 @@ 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 = peers[peerID], info.isVerifiedNickname {
|
else if let info = peersSnapshot[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 = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
|
let hasCollision = peersSnapshot.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,9 +65,6 @@ 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)")
|
||||||
}
|
}
|
||||||
@@ -311,19 +308,4 @@ 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,34 +27,6 @@ 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,6 +8,16 @@ 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,19 +198,36 @@ 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
|
||||||
if contentLRUOrder.count > contentLRUCap {
|
trimContentLRUIfNeeded()
|
||||||
let overflow = contentLRUOrder.count - contentLRUCap
|
}
|
||||||
|
|
||||||
|
private func trimContentLRUIfNeeded() {
|
||||||
|
let activeCount = contentLRUOrder.count - contentLRUHead
|
||||||
|
guard activeCount > contentLRUCap else { return }
|
||||||
|
|
||||||
|
let overflow = activeCount - contentLRUCap
|
||||||
for _ in 0..<overflow {
|
for _ in 0..<overflow {
|
||||||
if let victim = contentLRUOrder.first {
|
guard let victim = popOldestContentKey() else { break }
|
||||||
contentLRUOrder.removeFirst()
|
|
||||||
contentLRUMap.removeValue(forKey: victim)
|
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
|
||||||
|
|
||||||
@@ -349,6 +366,7 @@ 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
|
||||||
@@ -2175,29 +2193,34 @@ 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)
|
||||||
if processedNostrEventOrder.count > maxProcessedNostrEvents {
|
trimProcessedNostrEventsIfNeeded()
|
||||||
let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents
|
}
|
||||||
|
|
||||||
|
private func trimProcessedNostrEventsIfNeeded() {
|
||||||
|
let activeCount = processedNostrEventOrder.count - processedNostrEventHead
|
||||||
|
guard activeCount > maxProcessedNostrEvents else { return }
|
||||||
|
|
||||||
|
let overflow = activeCount - maxProcessedNostrEvents
|
||||||
for _ in 0..<overflow {
|
for _ in 0..<overflow {
|
||||||
if let old = processedNostrEventOrder.first {
|
guard let old = popOldestProcessedEvent() else { break }
|
||||||
processedNostrEventOrder.removeFirst()
|
|
||||||
processedNostrEvents.remove(old)
|
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.
|
||||||
@@ -3797,7 +3820,6 @@ 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
|
||||||
@@ -3809,7 +3831,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
if let myGeo = myGeo {
|
if let myGeo {
|
||||||
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5412,11 +5434,10 @@ 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 pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
let regex = Regexes.mention
|
||||||
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()
|
||||||
@@ -5927,61 +5948,6 @@ 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: "+")
|
||||||
@@ -6040,111 +6006,6 @@ 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 privatePeer {
|
if let peerID = PeerID(str: privatePeer) {
|
||||||
return viewModel.getPrivateChatMessages(for: PeerID(str: privatePeer))
|
return viewModel.getPrivateChatMessages(for: peerID)
|
||||||
}
|
}
|
||||||
return viewModel.messages
|
return viewModel.messages
|
||||||
}()
|
}()
|
||||||
@@ -765,20 +765,20 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleOpenURL(_ url: URL) {
|
private func handleOpenURL(_ url: URL) {
|
||||||
guard url.scheme == "bitchat", url.host == "user" else { return }
|
guard url.scheme == "bitchat" else { return }
|
||||||
|
switch url.host {
|
||||||
|
case "user":
|
||||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||||
selectedMessageSenderID = peerID.id
|
selectedMessageSenderID = peerID.id
|
||||||
|
|
||||||
if peerID.isGeoDM || peerID.isGeoChat {
|
if peerID.isGeoDM || peerID.isGeoChat {
|
||||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||||
} else {
|
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
||||||
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
|
||||||
@@ -786,14 +786,43 @@ struct ContentView: View {
|
|||||||
} 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToBottom(on proxy: ScrollViewProxy,
|
private func scrollToBottom(on proxy: ScrollViewProxy,
|
||||||
privatePeer: String?,
|
privatePeer: String?,
|
||||||
isAtBottom: Binding<Bool>) {
|
isAtBottom: Binding<Bool>) {
|
||||||
let targetID: String? = {
|
let targetID: String? = {
|
||||||
if let peer = privatePeer,
|
if let peer = PeerID(str: privatePeer),
|
||||||
let last = viewModel.getPrivateChatMessages(for: PeerID(str: peer)).suffix(300).last?.id {
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
return "dm:\(peer)|\(last)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
@@ -802,17 +831,41 @@ 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 { return "\(contextKey)|\(last)" }
|
if let last = viewModel.messages.suffix(300).last?.id {
|
||||||
|
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 {
|
||||||
proxy.scrollTo(target, anchor: .bottom)
|
if let targetID {
|
||||||
|
proxy.scrollTo(targetID, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
||||||
proxy.scrollTo(target, anchor: .bottom)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|||||||
@@ -93,6 +93,62 @@ struct FragmentationTests {
|
|||||||
#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 {
|
||||||
let ble = BLEService(
|
let ble = BLEService(
|
||||||
@@ -142,7 +198,10 @@ 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)] = []
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
var receivedMessages: [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]) {}
|
||||||
@@ -173,8 +232,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) -> [BitchatPacket] {
|
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] {
|
||||||
let fullData = packet.toBinaryData() ?? Data()
|
guard let fullData = packet.toBinaryData(padding: pad) else { return [] }
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func assemblesCompressedLargeFrame() throws {
|
func testAssemblesCompressedLargeFrame() 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,6 +108,7 @@ 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,
|
||||||
@@ -124,7 +125,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))
|
||||||
@@ -133,7 +134,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)
|
#expect(result.reset == false)
|
||||||
|
|
||||||
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,10 +68,9 @@ 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 version")
|
let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), "Invalid packet version")
|
||||||
|
|
||||||
let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
||||||
#expect(encodedData.count < uncompressedSize)
|
#expect(encodedData.count < uncompressedSize, "Compressed packet should be smaller than uncompressed form")
|
||||||
|
|
||||||
// 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,7 +6,9 @@
|
|||||||
// 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"
|
||||||
|
|||||||
@@ -7,7 +7,53 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
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,11 +1,23 @@
|
|||||||
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
|
||||||
|
|
||||||
// Declare C entrypoint for Tor when statically linked from an xcframework.
|
#if !canImport(Network)
|
||||||
@_silgen_name("tor_main")
|
private final class NWPathMonitor {
|
||||||
private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
|
var pathUpdateHandler: ((Any) -> Void)?
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -286,150 +298,6 @@ 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 }
|
||||||
@@ -440,10 +308,6 @@ 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