diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/bitchat/Nostr/GeoRelayDirectory.swift index c55a3bed..a0dd0afd 100644 --- a/bitchat/Nostr/GeoRelayDirectory.swift +++ b/bitchat/Nostr/GeoRelayDirectory.swift @@ -33,13 +33,32 @@ final class GeoRelayDirectory { /// Returns up to `count` relay URLs (wss://) closest to the given coordinate. func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] { - guard !entries.isEmpty else { return [] } - let sorted = entries - .sorted { a, b in - haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon) + guard !entries.isEmpty, count > 0 else { return [] } + + if entries.count <= count { + return entries + .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 diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 3ac8d23c..f621e749 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -65,14 +65,11 @@ final class CommandProcessor { case "/unfav": if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: false) - // - case "/help", "/h": - return .error(message: "unknown command: \(cmd)") default: return .error(message: "unknown command: \(cmd)") } } - + // MARK: - Command Handlers private func handleMessage(_ args: String) -> CommandResult { @@ -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) - } } diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index cbc379f4..be6ed784 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -27,34 +27,6 @@ final class KeychainManager: KeychainManagerProtocol { private let service = 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 func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index f007b605..7e3acdda 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -198,20 +198,37 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Persistent recent content map (LRU) to speed near-duplicate checks private var contentLRUMap: [String: Date] = [:] private var contentLRUOrder: [String] = [] + private var contentLRUHead = 0 private let contentLRUCap = TransportConfig.contentLRUCap private func recordContentKey(_ key: String, timestamp: Date) { if contentLRUMap[key] == nil { contentLRUOrder.append(key) } contentLRUMap[key] = timestamp - if contentLRUOrder.count > contentLRUCap { - let overflow = contentLRUOrder.count - contentLRUCap - for _ in 0.. contentLRUCap else { return } + + let overflow = activeCount - contentLRUCap + for _ in 0.. 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 @Published var messages: [BitchatMessage] = [] @@ -349,6 +366,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // PeerManager replaced by UnifiedPeerService private var processedNostrEvents = Set() // Simple deduplication private var processedNostrEventOrder: [String] = [] + private var processedNostrEventHead = 0 private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap private let userDefaults = UserDefaults.standard private let keychain: KeychainManagerProtocol @@ -2175,30 +2193,35 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { 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 private func recordProcessedEvent(_ id: String) { processedNostrEvents.insert(id) processedNostrEventOrder.append(id) - if processedNostrEventOrder.count > maxProcessedNostrEvents { - let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents - for _ in 0.. maxProcessedNostrEvents else { return } + + let overflow = activeCount - maxProcessedNostrEvents + for _ in 0.. 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. /// - Parameters: @@ -5411,11 +5434,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { private func parseMentions(from content: String) -> [String] { // Allow optional disambiguation suffix '#abcd' for duplicate nicknames - let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)" - let regex = try? NSRegularExpression(pattern: pattern, options: []) + let regex = Regexes.mention let nsContent = content as NSString 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] = [] let peerNicknames = meshService.getPeerNicknames() @@ -5926,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 private static func base64URLDecode(_ s: String) -> Data? { var str = s.replacingOccurrences(of: "-", with: "+") @@ -6039,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 private func findNoiseKey(for nostrPubkey: String) -> Data? { // Convert hex to npub if needed for comparison diff --git a/localPackages/BitLogger/Sources/OSLog+Categories.swift b/localPackages/BitLogger/Sources/OSLog+Categories.swift index 7ebe4da0..91e7eeb1 100644 --- a/localPackages/BitLogger/Sources/OSLog+Categories.swift +++ b/localPackages/BitLogger/Sources/OSLog+Categories.swift @@ -6,11 +6,13 @@ // For more information, see // +#if canImport(os.log) import os.log +#endif public extension OSLog { private static let subsystem = "chat.bitchat" - + static let noise = OSLog(subsystem: subsystem, category: "noise") static let encryption = OSLog(subsystem: subsystem, category: "encryption") static let keychain = OSLog(subsystem: subsystem, category: "keychain") diff --git a/localPackages/BitLogger/Sources/SecureLogger.swift b/localPackages/BitLogger/Sources/SecureLogger.swift index e5f60cbf..84bb997d 100644 --- a/localPackages/BitLogger/Sources/SecureLogger.swift +++ b/localPackages/BitLogger/Sources/SecureLogger.swift @@ -7,7 +7,53 @@ // import Foundation +#if canImport(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 /// Provides safe logging that filters sensitive data and security events diff --git a/localPackages/Tor/Sources/TorManager.swift b/localPackages/Tor/Sources/TorManager.swift index 00c4c4c7..32154b86 100644 --- a/localPackages/Tor/Sources/TorManager.swift +++ b/localPackages/Tor/Sources/TorManager.swift @@ -1,11 +1,23 @@ import BitLogger import Foundation +#if canImport(Network) import Network +#endif +#if canImport(Darwin) import Darwin +#elseif canImport(Glibc) +import Glibc +#endif -// Declare C entrypoint for Tor when statically linked from an xcframework. -@_silgen_name("tor_main") -private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer?>?) -> Int32 +#if !canImport(Network) +private final class NWPathMonitor { + 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) @_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?>?) -> 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 - 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?] = argv.map { strdup($0) } - let cArgv = UnsafeMutablePointer?>.allocate(capacity: cStrings.count + 1) - for i in 0.. 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?] = argv.map { strdup($0) } - let cArgv = UnsafeMutablePointer?>.allocate(capacity: cStrings.count + 1) - for i in 0.. Bool { false } - // iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers. private func bootstrapPollLoop() async { let deadline = Date().addingTimeInterval(75)