mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Merge main into feature/ble-binary-transfers
Resolved conflicts in BLEService.swift: - Kept refactored versions of broadcastPacket() and sendAnnounce() - These functions were moved earlier in PR #809 with enhancements - Main's changes (gossipSyncManager integration) already present in refactored versions Changes from main: - Dead code removal (PR #811) - Performance optimizations (PR #812) - Linux/cross-platform compatibility improvements Build: ✅ Successful Tests: 155/156 passing (1 pre-existing failure in FragmentationTests)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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..<overflow {
|
||||
if let victim = contentLRUOrder.first {
|
||||
contentLRUOrder.removeFirst()
|
||||
contentLRUMap.removeValue(forKey: victim)
|
||||
}
|
||||
}
|
||||
trimContentLRUIfNeeded()
|
||||
}
|
||||
|
||||
private func trimContentLRUIfNeeded() {
|
||||
let activeCount = contentLRUOrder.count - contentLRUHead
|
||||
guard activeCount > contentLRUCap else { return }
|
||||
|
||||
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
|
||||
|
||||
@Published var messages: [BitchatMessage] = []
|
||||
@@ -349,6 +366,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// PeerManager replaced by UnifiedPeerService
|
||||
private var processedNostrEvents = Set<String>() // 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..<overflow {
|
||||
if let old = processedNostrEventOrder.first {
|
||||
processedNostrEventOrder.removeFirst()
|
||||
processedNostrEvents.remove(old)
|
||||
}
|
||||
}
|
||||
trimProcessedNostrEventsIfNeeded()
|
||||
}
|
||||
|
||||
private func trimProcessedNostrEventsIfNeeded() {
|
||||
let activeCount = processedNostrEventOrder.count - processedNostrEventHead
|
||||
guard activeCount > maxProcessedNostrEvents else { return }
|
||||
|
||||
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.
|
||||
/// - 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
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<UnsafeMutablePointer<CChar>?>?) -> 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<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)
|
||||
private func startControlMonitorIfNeeded() {
|
||||
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.
|
||||
private func bootstrapPollLoop() async {
|
||||
let deadline = Date().addingTimeInterval(75)
|
||||
|
||||
Reference in New Issue
Block a user