mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 21:45:22 +00:00
tor by default, small (#564)
* feat(tor): Tor-by-default scaffold and integration - Add TorManager with static/dlopen start, torrc generation, SOCKS probe - Add TorURLSession; route Nostr/Web fetches via SOCKS proxy - Add chat system messages for Tor status; show progress (macOS) and ready - Disable ControlPort bootstrap monitor on iOS; keep it on macOS - Make Tor waits non-blocking; avoid main-actor stalls on startup - Queue & flush Nostr subscriptions on relay connect; skip duplicates - Always rewrite torrc at launch to fix iOS container path mismatches - Link libz; add project wiring for tor-nolzma.xcframework - Minor fixes: SOCKS probe resumeOnce guard, entitlement for network.server (macOS) * iOS: deterministic Tor recovery + 100% gating; BLE-first; session rebuild - Restart/wake Tor on foreground via ControlPort (ACTIVE/SHUTDOWN), avoid restarts during bootstrap; add NWPathMonitor to trigger checks - Use NWConnection control polling for GETINFO; remove blocking CFStream readers to avoid QoS inversions; compute readiness from SOCKS + 100% - Rebuild TorURLSession on resume; reset Nostr connections to rebind - Gate all internet after full bootstrap; keep BLE mesh startup fast - Fix Swift 6 capture issues; hop UI updates to @MainActor - Remove Tor progress spam; persist initial "starting tor..." system message * UI: show Tor system messages only in geohash channels (not mesh) - Gate "starting tor..." and readiness/timeout messages to geohash view - Add helper addGeohashOnlySystemMessage() to avoid posting to mesh timeline - Persist system messages in geohash backing store via addPublicSystemMessage() * Relays: treat repeated -1011 handshake failures as permanent; skip reconnects - Classify NSURLErrorBadServerResponse as permanent and stop retrying - Filter permanently-failed relays from subscribe/connect attempts - Avoid reconnect scheduling for permanently failed relays * Embed Tor via tor_api; deterministic restart + Nostr gating; add Tor notifications - Run Tor via tor_api in a dedicated thread with OwningControllerFD - Cleanly stop Tor on background; restart on .active (single instance) - Avoid fallback to tor_main/dlopen; add is-running check to prevent duplicates - Fix argv lifetime in C glue to avoid strcmp crash on start - Gate Nostr connect/subscribe/send until Tor is fully ready - Rebuild URLSession + reset relays after Tor readiness (scene-based) - Remove TorDidBecomeReady double-reset and appDidBecomeActive resubscribe - Add TorWillRestart/TorDidBecomeReady notifications and chat system messages - Debounce path-change restarts; ACTIVE poke first; coalesce subs; cancel stale reconnect timers - Project: add CTorHost.c and TorNotifications.swift to targets; fix libz.tbd path * Defer Nostr setup logs until Tor is ready; fix subscribe coalescing and reconnect generation - Move "Connecting to Nostr relays" log after awaitReady() - Log "Queuing subscription" when Tor not ready; only coalesce when handler exists - Clear coalescer on unsubscribe - Cancel stale reconnect timers using connectionGeneration - Remove app-level TorDidBecomeReady reset to avoid duplicate reconnects - Debounce path-change restarts * Gate Nostr init/subscription logs until Tor is ready - ChatViewModel: await Tor readiness before initializing Nostr and logging - Only log GeoDM subscription when Tor is ready to avoid early noise * Make Nostr connect single-sourced; defer DM subscription until connected - Remove duplicate connect call from ChatViewModel; let scene-based flow connect - Setup DM subscription once on first connection via sink - Reduce early subscription send/cancel noise after Tor restarts * On launch, queue Nostr subscriptions without initiating connects; let centralized connect handle it - In subscribe(), if no connections exist, just list relays and queue subs - Avoids early send/cancel churn before connect() runs post-Tor-ready * Always queue subscriptions and flush on connection; avoid immediate sends - Prevents early send/cancel churn at launch and during reconnects - If relays are already connected, flush immediately; otherwise pending until connected * UI: scope Tor restart messages to geohash channels; skip initial foreground restart on cold launch to avoid confusing system message in #mesh * geo: disable background sampling + notifications\n- Gate sampling to foreground only (beginGeohashSampling, watchers)\n- Suppress geohash activity notifications unless app is active\n- Stop sampling explicitly on background scene phase * Update BitchatApp.swift Co-authored-by: asmo <asmogo@protonmail.com> * Update BitchatApp.swift Co-authored-by: asmo <asmogo@protonmail.com> * Update BitchatApp.swift Co-authored-by: asmo <asmogo@protonmail.com> * Update bitchat/BitchatApp.swift Co-authored-by: asmo <asmogo@protonmail.com> * Update bitchat/BitchatApp.swift Co-authored-by: asmo <asmogo@protonmail.com> * fix(iOS App): resolve merge artifacts in scenePhase handler\n- Remove duplicate didEnterBackground state\n- Fix switch/if braces and logic for foreground restart gating --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: asmo <asmogo@protonmail.com>
This commit is contained in:
@@ -15,6 +15,9 @@ struct BitchatApp: App {
|
||||
#if os(iOS)
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
// Skip the very first .active-triggered Tor restart on cold launch
|
||||
@State private var didHandleInitialActive: Bool = false
|
||||
@State private var didEnterBackground: Bool = false
|
||||
#elseif os(macOS)
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||
#endif
|
||||
@@ -43,6 +46,8 @@ struct BitchatApp: App {
|
||||
#elseif os(macOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#endif
|
||||
// Spin up Tor early; all internet will gate on Tor 100%
|
||||
TorManager.shared.startIfNeeded()
|
||||
// Check for shared content
|
||||
checkForSharedContent()
|
||||
}
|
||||
@@ -54,10 +59,37 @@ struct BitchatApp: App {
|
||||
switch newPhase {
|
||||
case .background:
|
||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||
break
|
||||
// Optionally nudge Tor to dormant to save power
|
||||
TorManager.shared.setAppForeground(false)
|
||||
TorManager.shared.goDormantOnBackground()
|
||||
// Stop geohash sampling while backgrounded
|
||||
Task { @MainActor in
|
||||
chatViewModel.endGeohashSampling()
|
||||
}
|
||||
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
|
||||
NostrRelayManager.shared.disconnect()
|
||||
didEnterBackground = true
|
||||
case .active:
|
||||
// Restart services when becoming active
|
||||
chatViewModel.meshService.startServices()
|
||||
TorManager.shared.setAppForeground(true)
|
||||
// On initial cold launch, Tor was just started in onAppear.
|
||||
// Skip the deterministic restart the first time we become active.
|
||||
if didHandleInitialActive && didEnterBackground {
|
||||
TorManager.shared.ensureRunningOnForeground()
|
||||
} else {
|
||||
didHandleInitialActive = true
|
||||
}
|
||||
didEnterBackground = false
|
||||
Task.detached {
|
||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||
await MainActor.run {
|
||||
// Rebuild proxied sessions to bind to the live Tor after readiness
|
||||
TorURLSession.shared.rebuild()
|
||||
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
||||
NostrRelayManager.shared.resetAllConnections()
|
||||
}
|
||||
}
|
||||
checkForSharedContent()
|
||||
case .inactive:
|
||||
break
|
||||
|
||||
@@ -50,23 +50,31 @@ final class GeoRelayDirectory {
|
||||
|
||||
private func fetchRemote() {
|
||||
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
let task = URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
|
||||
guard let self = self else { return }
|
||||
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
if !parsed.isEmpty {
|
||||
Task { @MainActor in
|
||||
self.entries = parsed
|
||||
self.persistCache(text)
|
||||
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||
SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Ensure Tor readiness before fetching (fail-closed by default)
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
if !ready {
|
||||
SecureLogger.log("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: SecureLogger.session, level: .warning)
|
||||
return
|
||||
}
|
||||
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning)
|
||||
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
||||
guard let self = self else { return }
|
||||
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
if !parsed.isEmpty {
|
||||
Task { @MainActor in
|
||||
self.entries = parsed
|
||||
self.persistCache(text)
|
||||
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||
SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
private func persistCache(_ text: String) {
|
||||
|
||||
@@ -39,8 +39,11 @@ class NostrRelayManager: ObservableObject {
|
||||
@Published private(set) var isConnected = false
|
||||
|
||||
private var connections: [String: URLSessionWebSocketTask] = [:]
|
||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> subscription IDs
|
||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
||||
// Coalesce duplicate subscribe requests for the same id within a short window
|
||||
private var subscribeCoalesce: [String: Date] = [:]
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// Message queue for reliability
|
||||
@@ -62,6 +65,8 @@ class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Reconnection timer
|
||||
private var reconnectionTimer: Timer?
|
||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
init() {
|
||||
// Initialize with default relays
|
||||
@@ -72,23 +77,46 @@ class NostrRelayManager: ObservableObject {
|
||||
|
||||
/// Connect to all configured relays
|
||||
func connect() {
|
||||
SecureLogger.log("🌐 Connecting to \(relays.count) Nostr relays", category: SecureLogger.session, level: .debug)
|
||||
for relay in relays {
|
||||
connectToRelay(relay.url)
|
||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if !ready {
|
||||
SecureLogger.log("❌ Tor not ready; aborting relay connections (fail-closed)", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
SecureLogger.log("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: SecureLogger.session, level: .debug)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from all relays
|
||||
func disconnect() {
|
||||
connectionGeneration &+= 1
|
||||
for (_, task) in connections {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
// Clear known subscriptions and any queued subs since connections are gone
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
func ensureConnections(to relayUrls: [String]) {
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
|
||||
}
|
||||
return
|
||||
}
|
||||
let existing = Set(relays.map { $0.url })
|
||||
for url in Set(relayUrls) {
|
||||
if !existing.contains(url) {
|
||||
@@ -102,6 +130,15 @@ class NostrRelayManager: ObservableObject {
|
||||
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer sends until Tor is ready to avoid premature queueing
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
|
||||
}
|
||||
return
|
||||
}
|
||||
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||
ensureConnections(to: targetRelays)
|
||||
|
||||
@@ -166,11 +203,29 @@ class NostrRelayManager: ObservableObject {
|
||||
relayUrls: [String]? = nil,
|
||||
handler: @escaping (NostrEvent) -> Void
|
||||
) {
|
||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||
let now = Date()
|
||||
if messageHandlers[id] != nil {
|
||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
subscribeCoalesce[id] = now
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if ready {
|
||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
messageHandlers[id] = handler
|
||||
|
||||
SecureLogger.log("📡 Subscribing to Nostr filter id=\(id) kinds=\(filter.kinds ?? []) since=\(filter.since ?? 0)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||
|
||||
do {
|
||||
@@ -183,35 +238,27 @@ class NostrRelayManager: ObservableObject {
|
||||
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
|
||||
// category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Target specific relays if provided; else all connections
|
||||
let urls = relayUrls ?? Self.defaultRelays
|
||||
ensureConnections(to: urls)
|
||||
let targets: [(String, URLSessionWebSocketTask)] = urls.compactMap { url in
|
||||
connections[url].map { (url, $0) }
|
||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||
let urls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||
// Always queue subscriptions; sending happens when a relay reports connected
|
||||
let existingSet = Set(relays.map { $0.url })
|
||||
for url in urls where !existingSet.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
}
|
||||
|
||||
for (relayUrl, connection) in targets {
|
||||
connection.send(.string(messageString)) { error in
|
||||
if let error = error {
|
||||
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
} else {
|
||||
// SecureLogger.log("✅ Subscription '\(id)' sent to relay: \(relayUrl)",
|
||||
// category: SecureLogger.session, level: .debug)
|
||||
// Subscription sent successfully
|
||||
Task { @MainActor in
|
||||
var subs = self.subscriptions[relayUrl] ?? Set<String>()
|
||||
subs.insert(id)
|
||||
self.subscriptions[relayUrl] = subs
|
||||
}
|
||||
}
|
||||
for url in urls {
|
||||
var map = self.pendingSubscriptions[url] ?? [:]
|
||||
map[id] = messageString
|
||||
self.pendingSubscriptions[url] = map
|
||||
}
|
||||
SecureLogger.log("📋 Queued subscription id=\(id) for \(urls.count) relay(s)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// If some targets are already connected, flush immediately for them
|
||||
for url in urls {
|
||||
if let r = relays.first(where: { $0.url == url }), r.isConnected {
|
||||
flushPendingSubscriptions(for: url)
|
||||
}
|
||||
}
|
||||
|
||||
if connections.isEmpty {
|
||||
SecureLogger.log("⚠️ No relay connections available for subscription",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.log("❌ Failed to encode subscription request: \(error)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
@@ -221,6 +268,8 @@ class NostrRelayManager: ObservableObject {
|
||||
/// Unsubscribe from a subscription
|
||||
func unsubscribe(id: String) {
|
||||
messageHandlers.removeValue(forKey: id)
|
||||
// Allow immediate re-subscription by clearing coalescer timestamp
|
||||
subscribeCoalesce.removeValue(forKey: id)
|
||||
|
||||
let req = NostrRequest.close(id: id)
|
||||
let message = try? encoder.encode(req)
|
||||
@@ -247,15 +296,36 @@ class NostrRelayManager: ObservableObject {
|
||||
SecureLogger.log("Invalid relay URL: \(urlString)", category: SecureLogger.session, level: .warning)
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if we already have a connection object
|
||||
if connections[urlString] != nil {
|
||||
return
|
||||
}
|
||||
if isPermanentlyFailed(urlString) {
|
||||
return
|
||||
}
|
||||
|
||||
// Attempting to connect to Nostr relay
|
||||
// Attempting to connect to Nostr relay via the proxied session
|
||||
|
||||
let session = URLSession(configuration: .default)
|
||||
// If Tor is enforced but not ready, delay connection until it is.
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if ready { self.connectToRelay(urlString) }
|
||||
else { SecureLogger.log("❌ Tor not ready; skipping connection to \(urlString)", category: SecureLogger.session, level: .error) }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let session = TorURLSession.shared.session
|
||||
let task = session.webSocketTask(with: url)
|
||||
|
||||
connections[urlString] = task
|
||||
@@ -271,6 +341,8 @@ class NostrRelayManager: ObservableObject {
|
||||
SecureLogger.log("✅ Connected to Nostr relay: \(urlString)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
self?.updateRelayStatus(urlString, isConnected: true)
|
||||
// Flush any pending subscriptions for this relay
|
||||
self?.flushPendingSubscriptions(for: urlString)
|
||||
} else {
|
||||
SecureLogger.log("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")",
|
||||
category: SecureLogger.session, level: .error)
|
||||
@@ -281,6 +353,28 @@ class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send any queued subscriptions for a relay that just connected.
|
||||
private func flushPendingSubscriptions(for relayUrl: String) {
|
||||
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
|
||||
guard let connection = connections[relayUrl] else { return }
|
||||
for (id, messageString) in map {
|
||||
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||
connection.send(.string(messageString)) { error in
|
||||
if let error = error {
|
||||
SecureLogger.log("❌ Failed to send pending subscription to \(relayUrl): \(error)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
} else {
|
||||
Task { @MainActor in
|
||||
var subs = self.subscriptions[relayUrl] ?? Set<String>()
|
||||
subs.insert(id)
|
||||
self.subscriptions[relayUrl] = subs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingSubscriptions[relayUrl] = nil
|
||||
}
|
||||
|
||||
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
|
||||
task.receive { [weak self] result in
|
||||
@@ -420,18 +514,21 @@ class NostrRelayManager: ObservableObject {
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
|
||||
// Check if this is a DNS error
|
||||
// Check if this is a DNS or handshake error; treat as permanent
|
||||
let errorDescription = error.localizedDescription.lowercased()
|
||||
let ns = error as NSError
|
||||
if errorDescription.contains("hostname could not be found") ||
|
||||
errorDescription.contains("dns") {
|
||||
// Only log once for DNS failures
|
||||
errorDescription.contains("dns") ||
|
||||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
|
||||
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
|
||||
SecureLogger.log("Nostr relay DNS failure for \(relayUrl) - not retrying", category: SecureLogger.session, level: .warning)
|
||||
SecureLogger.log("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
// Mark relay as permanently failed
|
||||
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
relays[index].lastError = error
|
||||
relays[index].reconnectAttempts = maxReconnectAttempts
|
||||
relays[index].nextReconnectTime = nil
|
||||
}
|
||||
pendingSubscriptions[relayUrl] = nil
|
||||
return
|
||||
}
|
||||
|
||||
@@ -458,9 +555,11 @@ class NostrRelayManager: ObservableObject {
|
||||
|
||||
|
||||
// Schedule reconnection with exponential backoff
|
||||
let gen = connectionGeneration
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Ignore stale scheduled reconnects from a previous generation
|
||||
guard gen == self.connectionGeneration else { return }
|
||||
// Check if we should still reconnect (relay might have been removed)
|
||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||
self.connectToRelay(relayUrl)
|
||||
@@ -501,6 +600,8 @@ class NostrRelayManager: ObservableObject {
|
||||
/// Reset all relay connections
|
||||
func resetAllConnections() {
|
||||
disconnect()
|
||||
// New generation begins now
|
||||
connectionGeneration &+= 1
|
||||
|
||||
// Reset all relay states
|
||||
for index in relays.indices {
|
||||
@@ -512,6 +613,18 @@ class NostrRelayManager: ObservableObject {
|
||||
// Reconnect
|
||||
connect()
|
||||
}
|
||||
|
||||
// MARK: - Failure classification
|
||||
private func isPermanentlyFailed(_ url: String) -> Bool {
|
||||
guard let r = relays.first(where: { $0.url == url }) else { return false }
|
||||
if r.reconnectAttempts >= maxReconnectAttempts { return true }
|
||||
if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain {
|
||||
if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#include <pthread.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
// Forward declarations from Tor's public embedding API (tor_api.h)
|
||||
typedef struct tor_main_configuration_t tor_main_configuration_t;
|
||||
tor_main_configuration_t *tor_main_configuration_new(void);
|
||||
int tor_main_configuration_set_command_line(tor_main_configuration_t *cfg,
|
||||
int argc,
|
||||
char **argv);
|
||||
void tor_main_configuration_free(tor_main_configuration_t *cfg);
|
||||
int tor_run_main(const tor_main_configuration_t *);
|
||||
|
||||
static pthread_t tor_thread;
|
||||
static int tor_thread_started = 0;
|
||||
static int owning_fd_app = -1; // App side; close to trigger shutdown
|
||||
static int owning_fd_tor = -1; // Tor side; closed when tor exits
|
||||
|
||||
// Persisted copies of args for restart convenience
|
||||
static char data_dir_copy[PATH_MAX] = {0};
|
||||
static char socks_arg_copy[64] = {0};
|
||||
static char control_arg_copy[64] = {0};
|
||||
static char fd_arg_copy[16] = {0};
|
||||
|
||||
// Keep argv array alive as required by tor_api.h until tor_run_main finishes
|
||||
static char **argv_owned = NULL;
|
||||
static int argv_owned_argc = 0;
|
||||
|
||||
// Public: return nonzero if tor thread appears to be running
|
||||
int tor_host_is_running(void) {
|
||||
return (tor_thread_started != 0) && (owning_fd_tor != -1);
|
||||
}
|
||||
|
||||
static void *tor_thread_main(void *arg) {
|
||||
tor_main_configuration_t *cfg = (tor_main_configuration_t *)arg;
|
||||
int rc = tor_run_main(cfg); // blocks until tor exits
|
||||
tor_main_configuration_free(cfg);
|
||||
if (argv_owned) { free(argv_owned); argv_owned = NULL; argv_owned_argc = 0; }
|
||||
if (owning_fd_tor != -1) { close(owning_fd_tor); owning_fd_tor = -1; }
|
||||
return (void*)(intptr_t)rc;
|
||||
}
|
||||
|
||||
static int build_cfg(tor_main_configuration_t **out_cfg) {
|
||||
tor_main_configuration_t *cfg = tor_main_configuration_new();
|
||||
if (!cfg) return -1;
|
||||
|
||||
// Create a private socketpair; give one end to tor as its owning controller
|
||||
int sv[2];
|
||||
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) {
|
||||
tor_main_configuration_free(cfg);
|
||||
return -2;
|
||||
}
|
||||
owning_fd_app = sv[0];
|
||||
owning_fd_tor = sv[1];
|
||||
|
||||
snprintf(fd_arg_copy, sizeof(fd_arg_copy), "%d", owning_fd_tor);
|
||||
|
||||
// Preferred: configure via command-line to avoid stale torrc issues
|
||||
const char *args[] = {
|
||||
"tor",
|
||||
"--DataDirectory", data_dir_copy,
|
||||
"--ClientOnly", "1",
|
||||
"--SocksPort", socks_arg_copy,
|
||||
"--ControlPort", control_arg_copy,
|
||||
"--CookieAuthentication", "1",
|
||||
"--AvoidDiskWrites", "1",
|
||||
"--MaxClientCircuitsPending", "8",
|
||||
"--__OwningControllerFD", fd_arg_copy,
|
||||
// Make client shutdown fast; don't linger
|
||||
"--ShutdownWaitLength", "0",
|
||||
NULL
|
||||
};
|
||||
int argc = 0; while (args[argc]) argc++;
|
||||
|
||||
// Allocate a stable argv array as required by tor_api.h
|
||||
char **argv = (char **)malloc(sizeof(char*) * (argc + 1));
|
||||
if (!argv) {
|
||||
tor_main_configuration_free(cfg);
|
||||
close(owning_fd_app); close(owning_fd_tor);
|
||||
owning_fd_app = owning_fd_tor = -1;
|
||||
return -3;
|
||||
}
|
||||
for (int i = 0; i <= argc; ++i) argv[i] = (char *)args[i];
|
||||
argv_owned = argv;
|
||||
argv_owned_argc = argc;
|
||||
|
||||
if (tor_main_configuration_set_command_line(cfg, argc, argv) != 0) {
|
||||
tor_main_configuration_free(cfg);
|
||||
free(argv);
|
||||
close(owning_fd_app); close(owning_fd_tor);
|
||||
owning_fd_app = owning_fd_tor = -1;
|
||||
return -3;
|
||||
}
|
||||
|
||||
*out_cfg = cfg;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Starts tor in a dedicated thread using Tor's embedding API.
|
||||
// - data_dir: absolute path to DataDirectory
|
||||
// - socks_addr: e.g., "127.0.0.1:39050"
|
||||
// - control_addr: e.g., "127.0.0.1:39051"
|
||||
// - shutdown_fast: if nonzero, we configure a faster shutdown (already default here)
|
||||
int tor_host_start(const char *data_dir, const char *socks_addr, const char *control_addr, int shutdown_fast) {
|
||||
if (!data_dir || !socks_addr || !control_addr) return -1;
|
||||
if (strlen(data_dir) >= sizeof(data_dir_copy) || strlen(socks_addr) >= sizeof(socks_arg_copy) || strlen(control_addr) >= sizeof(control_arg_copy))
|
||||
return -2;
|
||||
|
||||
// Don't start if an instance appears to be running
|
||||
if (owning_fd_app != -1 || owning_fd_tor != -1) return -3;
|
||||
|
||||
// Store copies for potential restart
|
||||
strncpy(data_dir_copy, data_dir, sizeof(data_dir_copy)-1);
|
||||
data_dir_copy[sizeof(data_dir_copy)-1] = '\0';
|
||||
strncpy(socks_arg_copy, socks_addr, sizeof(socks_arg_copy)-1);
|
||||
socks_arg_copy[sizeof(socks_arg_copy)-1] = '\0';
|
||||
strncpy(control_arg_copy, control_addr, sizeof(control_arg_copy)-1);
|
||||
control_arg_copy[sizeof(control_arg_copy)-1] = '\0';
|
||||
|
||||
tor_main_configuration_t *cfg = NULL;
|
||||
int rc = build_cfg(&cfg);
|
||||
if (rc != 0) return rc;
|
||||
|
||||
(void)shutdown_fast; // already configured via argv
|
||||
|
||||
int prc = pthread_create(&tor_thread, NULL, tor_thread_main, cfg);
|
||||
if (prc != 0) {
|
||||
tor_main_configuration_free(cfg);
|
||||
if (argv_owned) { free(argv_owned); argv_owned = NULL; argv_owned_argc = 0; }
|
||||
close(owning_fd_app); close(owning_fd_tor);
|
||||
owning_fd_app = owning_fd_tor = -1;
|
||||
return -4;
|
||||
}
|
||||
tor_thread_started = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Triggers a clean shutdown by closing the app-side owning controller fd,
|
||||
// then joins the tor thread to ensure complete exit before returning.
|
||||
int tor_host_shutdown(void) {
|
||||
if (owning_fd_app != -1) { close(owning_fd_app); owning_fd_app = -1; }
|
||||
void *ret = NULL;
|
||||
if (tor_thread_started) {
|
||||
// Wait for tor_run_main to return
|
||||
pthread_join(tor_thread, &ret);
|
||||
tor_thread_started = 0;
|
||||
}
|
||||
return (int)(intptr_t)ret;
|
||||
}
|
||||
|
||||
// Convenience: shutdown then start again with the stored parameters.
|
||||
int tor_host_restart(const char *data_dir, const char *socks_addr, const char *control_addr, int shutdown_fast) {
|
||||
(void)data_dir; (void)socks_addr; (void)control_addr; (void)shutdown_fast;
|
||||
// If caller provided new args, refresh our copies
|
||||
if (data_dir && data_dir[0]) {
|
||||
strncpy(data_dir_copy, data_dir, sizeof(data_dir_copy)-1);
|
||||
data_dir_copy[sizeof(data_dir_copy)-1] = '\0';
|
||||
}
|
||||
if (socks_addr && socks_addr[0]) {
|
||||
strncpy(socks_arg_copy, socks_addr, sizeof(socks_arg_copy)-1);
|
||||
socks_arg_copy[sizeof(socks_arg_copy)-1] = '\0';
|
||||
}
|
||||
if (control_addr && control_addr[0]) {
|
||||
strncpy(control_arg_copy, control_addr, sizeof(control_arg_copy)-1);
|
||||
control_arg_copy[sizeof(control_arg_copy)-1] = '\0';
|
||||
}
|
||||
(void)tor_host_shutdown();
|
||||
return tor_host_start(data_dir_copy, socks_arg_copy, control_arg_copy, shutdown_fast);
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Darwin
|
||||
|
||||
// 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
|
||||
|
||||
// Preferred: tiny C glue that uses Tor's embedding API (tor_api.h)
|
||||
@_silgen_name("tor_host_start")
|
||||
private func tor_host_start(_ dataDir: UnsafePointer<CChar>, _ socksAddr: UnsafePointer<CChar>, _ controlAddr: UnsafePointer<CChar>, _ shutdownFast: Int32) -> Int32
|
||||
@_silgen_name("tor_host_shutdown")
|
||||
private func tor_host_shutdown() -> Int32
|
||||
@_silgen_name("tor_host_restart")
|
||||
private func tor_host_restart(_ dataDir: UnsafePointer<CChar>, _ socksAddr: UnsafePointer<CChar>, _ controlAddr: UnsafePointer<CChar>, _ shutdownFast: Int32) -> Int32
|
||||
@_silgen_name("tor_host_is_running")
|
||||
private func tor_host_is_running() -> Int32
|
||||
|
||||
/// Minimal Tor integration scaffold.
|
||||
/// - Boots a local Tor client (once integrated) and exposes a SOCKS5 proxy
|
||||
/// on 127.0.0.1:socksPort. All app networking should await readiness and
|
||||
/// route via this proxy. Fails closed by default when Tor is unavailable.
|
||||
/// - Drop-in ready: add your Tor framework and complete `startTor()`.
|
||||
@MainActor
|
||||
final class TorManager: ObservableObject {
|
||||
static let shared = TorManager()
|
||||
|
||||
// SOCKS endpoint where the embedded Tor should listen.
|
||||
let socksHost: String = "127.0.0.1"
|
||||
let socksPort: Int = 39050
|
||||
|
||||
// Optional ControlPort for debugging/diagnostics once Tor is integrated.
|
||||
let controlHost: String = "127.0.0.1"
|
||||
let controlPort: Int = 39051
|
||||
|
||||
// State
|
||||
// True only when SOCKS is reachable AND bootstrap has reached 100%.
|
||||
@Published private(set) var isReady: Bool = false
|
||||
@Published private(set) var isStarting: Bool = false
|
||||
@Published private(set) var lastError: Error?
|
||||
@Published private(set) var bootstrapProgress: Int = 0
|
||||
@Published private(set) var bootstrapSummary: String = ""
|
||||
|
||||
// Internal readiness trackers
|
||||
private var socksReady: Bool = false { didSet { recomputeReady() } }
|
||||
private var restarting: Bool = false
|
||||
|
||||
// Whether the app must enforce Tor for all connections (fail-closed).
|
||||
// This is the default. For local development, you may compile with
|
||||
// `-DBITCHAT_DEV_ALLOW_CLEARNET` to temporarily allow direct network.
|
||||
var torEnforced: Bool {
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
return false
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
// Returns true only when Tor is actually up (or dev fallback is compiled).
|
||||
var networkPermitted: Bool {
|
||||
if torEnforced { return isReady }
|
||||
// Dev bypass allows network even if Tor is not running
|
||||
return true
|
||||
}
|
||||
|
||||
private var didStart = false
|
||||
private var controlMonitorStarted = false
|
||||
private var pathMonitor: NWPathMonitor?
|
||||
private var isAppForeground: Bool = true
|
||||
private var lastRestartAt: Date? = nil
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
func startIfNeeded() {
|
||||
// Do not start in background; caller should wait for foreground
|
||||
guard isAppForeground else { return }
|
||||
guard !didStart else { return }
|
||||
didStart = true
|
||||
isStarting = true
|
||||
lastError = nil
|
||||
ensureFilesystemLayout()
|
||||
startTor()
|
||||
startPathMonitorIfNeeded()
|
||||
}
|
||||
|
||||
/// Called by app lifecycle to indicate foreground/background state.
|
||||
func setAppForeground(_ foreground: Bool) {
|
||||
isAppForeground = foreground
|
||||
}
|
||||
|
||||
/// Foreground state accessor for other @MainActor clients.
|
||||
func isForeground() -> Bool { isAppForeground }
|
||||
|
||||
/// Await Tor bootstrap to readiness. Returns true if network is permitted (Tor ready or dev bypass).
|
||||
/// Nonisolated to avoid blocking the main actor during waits.
|
||||
nonisolated func awaitReady(timeout: TimeInterval = 25.0) async -> Bool {
|
||||
// Only start Tor if we're in foreground; otherwise just wait for it
|
||||
await MainActor.run {
|
||||
if self.isAppForeground { self.startIfNeeded() }
|
||||
}
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
// Early exit if network already permitted
|
||||
if await MainActor.run(body: { self.networkPermitted }) { return true }
|
||||
while Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
|
||||
if await MainActor.run(body: { self.networkPermitted }) { return true }
|
||||
}
|
||||
return await MainActor.run(body: { self.networkPermitted })
|
||||
}
|
||||
|
||||
// MARK: - Filesystem (torrc + data dir)
|
||||
|
||||
func dataDirectoryURL() -> URL? {
|
||||
do {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat/tor", isDirectory: true)
|
||||
return dir
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func torrcURL() -> URL? {
|
||||
dataDirectoryURL()?.appendingPathComponent("torrc")
|
||||
}
|
||||
|
||||
private func ensureFilesystemLayout() {
|
||||
guard let dir = dataDirectoryURL() else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
// Always (re)write torrc at launch so DataDirectory is correct for this container
|
||||
if let torrc = torrcURL() {
|
||||
try torrcTemplate().data(using: .utf8)?.write(to: torrc, options: .atomic)
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal; Tor will surface errors during start if paths are missing
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal, safe torrc for an embedded client.
|
||||
func torrcTemplate() -> String {
|
||||
var lines: [String] = []
|
||||
if let dir = dataDirectoryURL()?.path {
|
||||
lines.append("DataDirectory \(dir)")
|
||||
}
|
||||
lines.append("ClientOnly 1")
|
||||
lines.append("SOCKSPort \(socksHost):\(socksPort)")
|
||||
lines.append("ControlPort \(controlHost):\(controlPort)")
|
||||
lines.append("CookieAuthentication 1")
|
||||
lines.append("AvoidDiskWrites 1")
|
||||
lines.append("MaxClientCircuitsPending 8")
|
||||
lines.append("ShutdownWaitLength 0")
|
||||
// Keep defaults for guard/exit selection to preserve anonymity properties
|
||||
return lines.joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
// MARK: - Integration Hook
|
||||
|
||||
/// Start the embedded Tor. This stub intentionally compiles without any Tor dependency.
|
||||
/// Integrate your Tor framework here and set `isReady = true` once bootstrapped.
|
||||
private func startTor() {
|
||||
// Prefer the embedding API wrapper which cleanly supports restart.
|
||||
// Avoid fallback to prevent accidental second instances in-process.
|
||||
if startTorViaEmbedAPI() { return }
|
||||
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
// Dev bypass: permit network immediately (no Tor). Use ONLY for local development.
|
||||
self.isReady = true
|
||||
self.isStarting = false
|
||||
#else
|
||||
// Production default: fail closed until Tor framework is dropped in and bootstraps.
|
||||
self.isReady = false
|
||||
self.isStarting = false
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Embed API path (owning controller FD + clean restart)
|
||||
private func startTorViaEmbedAPI() -> Bool {
|
||||
guard let dir = dataDirectoryURL()?.path else { return false }
|
||||
let socks = "\(socksHost):\(socksPort)"
|
||||
let control = "\(controlHost):\(controlPort)"
|
||||
var started = false
|
||||
// If already running (per C glue), treat as started
|
||||
if tor_host_is_running() != 0 {
|
||||
SecureLogger.log("TorManager: embed reports already running", category: SecureLogger.session, level: .info)
|
||||
return true
|
||||
}
|
||||
dir.withCString { dptr in
|
||||
socks.withCString { sptr in
|
||||
control.withCString { cptr in
|
||||
let rc = tor_host_start(dptr, sptr, cptr, 1)
|
||||
started = (rc == 0)
|
||||
if rc != 0 {
|
||||
SecureLogger.log("TorManager: tor_host_start failed rc=\(rc)", category: SecureLogger.session, level: .error)
|
||||
} else {
|
||||
SecureLogger.log("TorManager: tor_host_start OK (\(socks), control \(control))", category: SecureLogger.session, level: .info)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !started { return false }
|
||||
|
||||
// Start monitors and probe readiness
|
||||
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.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort) [embed]", category: SecureLogger.session, level: .info)
|
||||
} else {
|
||||
self.lastError = NSError(domain: "TorManager", code: -14, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after embed start"])
|
||||
SecureLogger.log("TorManager: SOCKS not reachable (timeout) [embed]", category: SecureLogger.session, level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
/// Probe the local SOCKS port until it's ready or a timeout elapses.
|
||||
private func waitForSocksReady(timeout: TimeInterval) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if await probeSocksOnce() { return true }
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func probeSocksOnce() async -> Bool {
|
||||
await withCheckedContinuation { cont in
|
||||
let params = NWParameters.tcp
|
||||
let host = NWEndpoint.Host.ipv4(.loopback)
|
||||
guard let port = NWEndpoint.Port(rawValue: UInt16(socksPort)) else {
|
||||
cont.resume(returning: false)
|
||||
return
|
||||
}
|
||||
let endpoint = NWEndpoint.hostPort(host: host, port: port)
|
||||
let conn = NWConnection(to: endpoint, using: params)
|
||||
|
||||
var resumed = false
|
||||
let resumeOnce: (Bool) -> Void = { value in
|
||||
if !resumed {
|
||||
resumed = true
|
||||
cont.resume(returning: value)
|
||||
}
|
||||
}
|
||||
|
||||
conn.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
resumeOnce(true)
|
||||
conn.cancel()
|
||||
case .failed, .cancelled:
|
||||
resumeOnce(false)
|
||||
conn.cancel()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Failsafe timeout to avoid hanging if no callback occurs
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.0) {
|
||||
resumeOnce(false)
|
||||
conn.cancel()
|
||||
}
|
||||
|
||||
conn.start(queue: DispatchQueue.global(qos: .utility))
|
||||
}
|
||||
}
|
||||
|
||||
// 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.log("TorManager: no embedded tor framework found", category: SecureLogger.session, level: .warning)
|
||||
return false
|
||||
}
|
||||
|
||||
// Load the library
|
||||
let mode = RTLD_NOW | RTLD_LOCAL
|
||||
SecureLogger.log("TorManager: dlopen(\(fwURL.lastPathComponent))…", category: SecureLogger.session, level: .info)
|
||||
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.log("TorManager: launching tor_main with torrc", category: SecureLogger.session, level: .info)
|
||||
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.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error)
|
||||
} else {
|
||||
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info)
|
||||
}
|
||||
// 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.log("TorManager: starting tor_main (static)", category: SecureLogger.session, level: .info)
|
||||
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.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info)
|
||||
} else {
|
||||
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
|
||||
SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error)
|
||||
}
|
||||
// isStarting will be cleared when bootstrap reaches 100%
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - ControlPort monitoring (bootstrap progress)
|
||||
private func startControlMonitorIfNeeded() {
|
||||
guard !controlMonitorStarted else { return }
|
||||
controlMonitorStarted = true
|
||||
// Use a simple GETINFO poll on all platforms to avoid long-lived blocking streams
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
await self?.bootstrapPollLoop()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
while Date() < deadline {
|
||||
if let info = await controlGetBootstrapInfo() {
|
||||
await MainActor.run {
|
||||
self.bootstrapProgress = info.progress
|
||||
self.bootstrapSummary = info.summary
|
||||
if info.progress >= 100 { self.isStarting = false }
|
||||
self.recomputeReady()
|
||||
}
|
||||
if info.progress >= 100 { break }
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func controlGetBootstrapInfo() async -> (progress: Int, summary: String)? {
|
||||
guard let text = await controlExchange(lines: ["GETINFO status/bootstrap-phase"], timeout: 2.0) else { return nil }
|
||||
var progress = self.bootstrapProgress
|
||||
var summary = self.bootstrapSummary
|
||||
// Search entire response for PROGRESS and SUMMARY tokens
|
||||
// Typical: "250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=75 TAG=... SUMMARY=\"...\"\r\n250 OK\r\n"
|
||||
let tokens = text.replacingOccurrences(of: "\r", with: " ").replacingOccurrences(of: "\n", with: " ").split(separator: " ")
|
||||
for t in tokens {
|
||||
if t.hasPrefix("PROGRESS=") {
|
||||
progress = Int(t.split(separator: "=").last ?? "0") ?? progress
|
||||
} else if t.hasPrefix("SUMMARY=") {
|
||||
let raw = String(t.dropFirst("SUMMARY=".count))
|
||||
summary = raw.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
}
|
||||
}
|
||||
return (progress, summary)
|
||||
}
|
||||
|
||||
// MARK: - Foreground recovery and control helpers
|
||||
|
||||
func ensureRunningOnForeground() {
|
||||
// iOS can suspend Tor harshly; the most reliable approach for
|
||||
// embedding is to restart Tor every time we become active.
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Claim the restart under MainActor to avoid races
|
||||
let claimed: Bool = await MainActor.run {
|
||||
if self.isStarting || self.restarting { return false }
|
||||
self.restarting = true
|
||||
return true
|
||||
}
|
||||
if !claimed { return }
|
||||
await self.restartTor()
|
||||
await MainActor.run { self.restarting = false }
|
||||
}
|
||||
}
|
||||
|
||||
func goDormantOnBackground() {
|
||||
// Stricter model: fully stop Tor when app backgrounds to save power
|
||||
// and avoid half-suspended states. We'll restart cleanly on .active.
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
_ = tor_host_shutdown()
|
||||
await MainActor.run {
|
||||
self.isReady = false
|
||||
self.socksReady = false
|
||||
self.bootstrapProgress = 0
|
||||
self.bootstrapSummary = ""
|
||||
self.isStarting = false
|
||||
self.didStart = false
|
||||
// Allow control monitor to start anew on next start
|
||||
self.controlMonitorStarted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func restartTor() async {
|
||||
await MainActor.run {
|
||||
// Announce restart so UI can notify the user
|
||||
NotificationCenter.default.post(name: .TorWillRestart, object: nil)
|
||||
self.isReady = false
|
||||
self.socksReady = false
|
||||
self.bootstrapProgress = 0
|
||||
self.bootstrapSummary = ""
|
||||
self.isStarting = true
|
||||
self.lastRestartAt = Date()
|
||||
}
|
||||
// Prefer clean shutdown via owning controller FD; join the tor thread
|
||||
_ = tor_host_shutdown()
|
||||
// As a fallback, try control signal if needed (harmless if tor already down)
|
||||
_ = await controlSendSignal("SHUTDOWN")
|
||||
// Now start fresh
|
||||
await MainActor.run { self.didStart = false }
|
||||
await MainActor.run { self.startIfNeeded() }
|
||||
}
|
||||
|
||||
private func recomputeReady() {
|
||||
let ready = socksReady && bootstrapProgress >= 100
|
||||
if ready != isReady {
|
||||
isReady = ready
|
||||
if ready {
|
||||
// Broadcast readiness so clients can rebuild sessions and reconnect
|
||||
NotificationCenter.default.post(name: .TorDidBecomeReady, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPathMonitorIfNeeded() {
|
||||
guard pathMonitor == nil else { return }
|
||||
let monitor = NWPathMonitor()
|
||||
pathMonitor = monitor
|
||||
let queue = DispatchQueue(label: "TorPathMonitor")
|
||||
monitor.pathUpdateHandler = { [weak self] _ in
|
||||
// On any path change, poke Tor/recover (hop to main actor).
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
// Avoid waking Tor while app is backgrounded; we'll handle on .active
|
||||
if self.isAppForeground {
|
||||
self.pokeTorOnPathChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
private func pokeTorOnPathChange() {
|
||||
// If a restart just happened, avoid thrashing
|
||||
if let last = lastRestartAt, Date().timeIntervalSince(last) < 3.0 {
|
||||
return
|
||||
}
|
||||
// If we're starting or restarting, do nothing and let that complete
|
||||
if isStarting || restarting { return }
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let ok = await self.controlPingBootstrap()
|
||||
if ok {
|
||||
_ = await self.controlSendSignal("ACTIVE")
|
||||
return
|
||||
}
|
||||
// If bootstrapping is underway, let it finish
|
||||
let stillBootstrapping = await MainActor.run { self.socksReady && self.bootstrapProgress < 100 }
|
||||
if stillBootstrapping { return }
|
||||
// As a last resort, restart
|
||||
await self.ensureRunningOnForeground()
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight control: authenticate and GETINFO bootstrap-phase.
|
||||
private func controlPingBootstrap(timeout: TimeInterval = 3.0) async -> Bool {
|
||||
let data = await controlExchange(lines: ["GETINFO status/bootstrap-phase"], timeout: timeout)
|
||||
guard let text = data else { return false }
|
||||
return text.contains("status/bootstrap-phase")
|
||||
}
|
||||
|
||||
private func controlSendSignal(_ signal: String, timeout: TimeInterval = 3.0) async -> Bool {
|
||||
let text = await controlExchange(lines: ["SIGNAL \(signal)"], timeout: timeout)
|
||||
return (text?.contains("250")) == true
|
||||
}
|
||||
|
||||
private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? {
|
||||
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"),
|
||||
let cookie = try? Data(contentsOf: cookiePath) else { return nil }
|
||||
let cookieHex = cookie.map { String(format: "%02X", $0) }.joined()
|
||||
|
||||
let queue = DispatchQueue(label: "TorControl", qos: .userInitiated)
|
||||
let params = NWParameters.tcp
|
||||
guard let port = NWEndpoint.Port(rawValue: UInt16(controlPort)) else { return nil }
|
||||
let endpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: port)
|
||||
let conn = NWConnection(to: endpoint, using: params)
|
||||
|
||||
var resultText = ""
|
||||
var completed = false
|
||||
func send(_ text: String) {
|
||||
let data = (text + "\r\n").data(using: .utf8) ?? Data()
|
||||
conn.send(content: data, completion: .contentProcessed { _ in })
|
||||
}
|
||||
func receiveLoop(deadline: Date) async {
|
||||
while Date() < deadline {
|
||||
let ok: Bool = await withCheckedContinuation { cont in
|
||||
conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, isComplete, error in
|
||||
if let data = data, !data.isEmpty, let s = String(data: data, encoding: .utf8) {
|
||||
resultText.append(s)
|
||||
}
|
||||
if isComplete || error != nil { completed = true }
|
||||
cont.resume(returning: true)
|
||||
}
|
||||
}
|
||||
if !ok || completed { break }
|
||||
// Small delay to avoid tight loop
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
conn.start(queue: queue)
|
||||
// Send immediately; NWConnection will queue until ready
|
||||
send("AUTHENTICATE \(cookieHex)")
|
||||
// Send requested lines
|
||||
for line in lines { send(line) }
|
||||
// Ask tor to close
|
||||
send("QUIT")
|
||||
await receiveLoop(deadline: Date().addingTimeInterval(timeout))
|
||||
conn.cancel()
|
||||
return resultText
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
|
||||
static let TorWillRestart = Notification.Name("TorWillRestart")
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
#if os(macOS)
|
||||
import CFNetwork
|
||||
#endif
|
||||
|
||||
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
|
||||
/// when Tor is enforced/ready. Falls back to a default session only when
|
||||
/// compiled with the `BITCHAT_DEV_ALLOW_CLEARNET` flag.
|
||||
final class TorURLSession {
|
||||
static let shared = TorURLSession()
|
||||
|
||||
// Default (no proxy) session for local development when dev bypass is enabled.
|
||||
private var defaultSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
// Proxied (SOCKS5) session that routes through Tor.
|
||||
private var torSession: URLSession = TorURLSession.makeTorSession()
|
||||
|
||||
var session: URLSession {
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
// Dev bypass: use direct session. Call sites may still await Tor if desired.
|
||||
return defaultSession
|
||||
#else
|
||||
// Production: always use the Tor-proxied session. Call sites ensure readiness.
|
||||
return torSession
|
||||
#endif
|
||||
}
|
||||
|
||||
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
|
||||
func rebuild() {
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
defaultSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
#endif
|
||||
torSession = TorURLSession.makeTorSession()
|
||||
}
|
||||
|
||||
private static func makeTorSession() -> URLSession {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.waitsForConnectivity = true
|
||||
// Keep in sync with TorManager defaults
|
||||
let host = "127.0.0.1"
|
||||
let port = 39050
|
||||
#if os(macOS)
|
||||
cfg.connectionProxyDictionary = [
|
||||
kCFNetworkProxiesSOCKSEnable as String: 1,
|
||||
kCFNetworkProxiesSOCKSProxy as String: host,
|
||||
kCFNetworkProxiesSOCKSPort as String: port
|
||||
]
|
||||
#else
|
||||
// iOS: CFNetwork SOCKS proxy keys are unavailable at compile time.
|
||||
cfg.connectionProxyDictionary = [
|
||||
"SOCKSEnable": 1,
|
||||
"SOCKSProxy": host,
|
||||
"SOCKSPort": port
|
||||
]
|
||||
#endif
|
||||
return URLSession(configuration: cfg)
|
||||
}
|
||||
}
|
||||
@@ -358,6 +358,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
private var geoDmSubscriptionID: String? = nil
|
||||
private var currentGeohash: String? = nil
|
||||
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
||||
// Show Tor status once per app launch
|
||||
private var torStatusAnnounced = false
|
||||
private var torProgressCancellable: AnyCancellable?
|
||||
private var lastTorProgressAnnounced = -1
|
||||
// Queue geohash-only system messages if user isn't on a location channel yet
|
||||
private var pendingGeohashSystemMessages: [String] = []
|
||||
// Track whether a Tor restart is pending so we only announce
|
||||
// "tor restarted" after an actual restart, not the first launch.
|
||||
private var torRestartPending: Bool = false
|
||||
// Ensure we set up DM subscription only once per app session
|
||||
private var nostrHandlersSetup: Bool = false
|
||||
|
||||
// MARK: - Caches
|
||||
|
||||
@@ -526,18 +537,37 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Start mesh service immediately
|
||||
meshService.startServices()
|
||||
|
||||
// Initialize Nostr services
|
||||
// Announce Tor status (geohash-only; do not show in mesh chat)
|
||||
if TorManager.shared.torEnforced && !torStatusAnnounced {
|
||||
torStatusAnnounced = true
|
||||
addGeohashOnlySystemMessage("starting tor...")
|
||||
// Suppress incremental Tor progress messages; only show initial start and readiness.
|
||||
torProgressCancellable = nil
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if ready { self.addGeohashOnlySystemMessage("tor started. routing all chats via tor for privacy.") }
|
||||
else { self.addGeohashOnlySystemMessage("still waiting for tor to start...") }
|
||||
}
|
||||
}
|
||||
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
||||
torStatusAnnounced = true
|
||||
addGeohashOnlySystemMessage("development build: tor bypass enabled.")
|
||||
}
|
||||
|
||||
// Initialize Nostr services only after Tor is fully ready, to avoid
|
||||
// interleaving setup logs during Tor startup.
|
||||
Task { @MainActor in
|
||||
// Wait for Tor readiness before Nostr init
|
||||
let ready = await TorManager.shared.awaitReady(timeout: 60)
|
||||
guard ready else {
|
||||
SecureLogger.log("Nostr init skipped: Tor not ready", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
nostrRelayManager = NostrRelayManager.shared
|
||||
SecureLogger.log("Initializing Nostr relay connections", category: SecureLogger.session, level: .debug)
|
||||
nostrRelayManager?.connect()
|
||||
|
||||
// Small delay to ensure read receipts are fully loaded
|
||||
// This prevents race conditions where messages arrive before initialization completes
|
||||
try? await Task.sleep(nanoseconds: TransportConfig.uiStartupShortSleepNs) // 0.2 seconds
|
||||
|
||||
// Set up Nostr message handling directly
|
||||
setupNostrMessageHandling()
|
||||
// Connect is managed centrally on scene activation; avoid duplicate connects here
|
||||
|
||||
// Attempt to flush any queued outbox after Nostr comes online
|
||||
messageRouter.flushAllOutbox()
|
||||
@@ -584,25 +614,34 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
self.cancellables.insert(cancellable)
|
||||
|
||||
// Resubscribe geohash on relay reconnect
|
||||
if let relayMgr = self.nostrRelayManager {
|
||||
relayMgr.$isConnected
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] connected in
|
||||
guard let self = self else { return }
|
||||
if connected {
|
||||
Task { @MainActor in
|
||||
self.resubscribeCurrentGeohash()
|
||||
// Re-init sampling for regional + bookmarked geohashes after reconnect
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
// Resubscribe geohash on relay reconnect
|
||||
if let relayMgr = self.nostrRelayManager {
|
||||
relayMgr.$isConnected
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] connected in
|
||||
guard let self = self else { return }
|
||||
if connected {
|
||||
Task { @MainActor in
|
||||
// Set up DM handler once on first connect
|
||||
if !self.nostrHandlersSetup {
|
||||
self.setupNostrMessageHandling()
|
||||
self.nostrHandlersSetup = true
|
||||
}
|
||||
self.resubscribeCurrentGeohash()
|
||||
// Re-init sampling for regional + bookmarked geohashes after reconnect
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
if TorManager.shared.isForeground() {
|
||||
self.beginGeohashSampling(for: union)
|
||||
} else {
|
||||
self.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
}
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up Noise encryption callbacks
|
||||
@@ -623,7 +662,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
|
||||
}
|
||||
|
||||
// Background: keep sampling nearby geohashes + bookmarks for notifications even when sheet is closed
|
||||
// Foreground-only: sample nearby geohashes + bookmarks (disabled in background)
|
||||
LocationChannelManager.shared.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channels in
|
||||
@@ -632,7 +671,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
Task { @MainActor in
|
||||
self.beginGeohashSampling(for: union)
|
||||
if TorManager.shared.isForeground() {
|
||||
self.beginGeohashSampling(for: union)
|
||||
} else {
|
||||
self.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -645,17 +688,21 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
Task { @MainActor in
|
||||
self.beginGeohashSampling(for: union)
|
||||
if TorManager.shared.isForeground() {
|
||||
self.beginGeohashSampling(for: union)
|
||||
} else {
|
||||
self.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Kick off initial sampling if we have regional channels or bookmarks
|
||||
// Kick off initial sampling if we have regional channels or bookmarks (foreground only)
|
||||
do {
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
if !union.isEmpty {
|
||||
if !union.isEmpty && TorManager.shared.isForeground() {
|
||||
Task { @MainActor in self.beginGeohashSampling(for: union) }
|
||||
}
|
||||
}
|
||||
@@ -731,6 +778,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
name: NSApplication.willTerminateNotification,
|
||||
object: nil
|
||||
)
|
||||
// Tor lifecycle notifications: inform user when Tor restarts and when ready (macOS)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleTorWillRestart),
|
||||
name: .TorWillRestart,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleTorDidBecomeReady),
|
||||
name: .TorDidBecomeReady,
|
||||
object: nil
|
||||
)
|
||||
#else
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
@@ -762,6 +822,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
name: UIApplication.willTerminateNotification,
|
||||
object: nil
|
||||
)
|
||||
// Tor lifecycle notifications: inform user when Tor restarts and when ready
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleTorWillRestart),
|
||||
name: .TorWillRestart,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleTorDidBecomeReady),
|
||||
name: .TorDidBecomeReady,
|
||||
object: nil
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -770,6 +843,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
deinit {
|
||||
// No need to force UserDefaults synchronization
|
||||
}
|
||||
|
||||
// MARK: - Tor notifications
|
||||
@objc private func handleTorWillRestart() {
|
||||
Task { @MainActor in
|
||||
self.torRestartPending = true
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage("tor restarting to recover connectivity…")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleTorDidBecomeReady() {
|
||||
Task { @MainActor in
|
||||
// Only announce "restarted" if we actually restarted this session
|
||||
if self.torRestartPending {
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage("tor restarted. network routing restored.")
|
||||
self.torRestartPending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resubscribe to the active geohash channel without clearing timeline
|
||||
@MainActor
|
||||
@@ -1447,6 +1540,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
SecureLogger.log("RenderGuard: geohash \(ch.geohash) timeline has \(emptyGeo) empty messages after sanitize", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
// If switching to a location channel, flush any pending geohash-only system messages
|
||||
if case .location = channel, !pendingGeohashSystemMessages.isEmpty {
|
||||
for m in pendingGeohashSystemMessages { addPublicSystemMessage(m) }
|
||||
pendingGeohashSystemMessages.removeAll(keepingCapacity: false)
|
||||
}
|
||||
// Unsubscribe previous
|
||||
if let sub = geoSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
||||
@@ -1576,8 +1674,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let dmSub = "geo-dm-\(ch.geohash)"
|
||||
geoDmSubscriptionID = dmSub
|
||||
// pared back logging: subscribe debug only
|
||||
SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// Log GeoDM subscribe only when Tor is ready to avoid early noise
|
||||
if TorManager.shared.isReady {
|
||||
SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||
guard let self = self else { return }
|
||||
@@ -1842,6 +1943,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
/// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.
|
||||
@MainActor
|
||||
func beginGeohashSampling(for geohashes: [String]) {
|
||||
// Disable sampling when app is backgrounded (Tor is stopped there)
|
||||
if !TorManager.shared.isForeground() {
|
||||
endGeohashSampling()
|
||||
return
|
||||
}
|
||||
// Determine which to add and which to remove
|
||||
let desired = Set(geohashes)
|
||||
let current = Set(geoSamplingSubs.values)
|
||||
@@ -1887,15 +1993,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Avoid notifications for old sampled events when launching or (re)subscribing
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) > 30 { return }
|
||||
// Foreground policy: allow if it's a different geohash than the one currently open
|
||||
// Foreground-only notifications: app must be active, and not already viewing this geohash
|
||||
#if os(iOS)
|
||||
if UIApplication.shared.applicationState == .active {
|
||||
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
||||
}
|
||||
guard UIApplication.shared.applicationState == .active else { return }
|
||||
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
||||
#elseif os(macOS)
|
||||
if NSApplication.shared.isActive {
|
||||
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
||||
}
|
||||
guard NSApplication.shared.isActive else { return }
|
||||
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
||||
#endif
|
||||
// Cooldown per geohash
|
||||
let now = Date()
|
||||
@@ -2607,8 +2711,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
}
|
||||
// Also resubscribe the current geohash channel if active
|
||||
resubscribeCurrentGeohash()
|
||||
// Subscriptions will be resent after connections come back up
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -4873,12 +4976,40 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
messages.append(systemMessage)
|
||||
}
|
||||
|
||||
/// Public helper to add a system message to the public chat timeline
|
||||
/// Public helper to add a system message to the public chat timeline.
|
||||
/// Also persists the message into the active channel's backing store so it survives timeline rebinds.
|
||||
@MainActor
|
||||
func addPublicSystemMessage(_ content: String) {
|
||||
addSystemMessage(content)
|
||||
let systemMessage = BitchatMessage(
|
||||
sender: "system",
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
// Append to current visible messages
|
||||
messages.append(systemMessage)
|
||||
// Persist into the backing store for the active channel to survive rebinds
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
meshTimeline.append(systemMessage)
|
||||
case .location(let ch):
|
||||
var arr = geoTimelines[ch.geohash] ?? []
|
||||
arr.append(systemMessage)
|
||||
geoTimelines[ch.geohash] = arr
|
||||
}
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
/// Add a system message only if viewing a geohash location channel (never post to mesh).
|
||||
@MainActor
|
||||
func addGeohashOnlySystemMessage(_ content: String) {
|
||||
if case .location = activeChannel {
|
||||
addPublicSystemMessage(content)
|
||||
} else {
|
||||
// Not on a location channel yet: queue to show when user switches
|
||||
pendingGeohashSystemMessages.append(content)
|
||||
}
|
||||
}
|
||||
// Send a public message without adding a local user echo.
|
||||
// Used for emotes where we want a local system-style confirmation instead.
|
||||
@MainActor
|
||||
|
||||
@@ -14,5 +14,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Reference in New Issue
Block a user