diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 654b85de..7d2ff2a5 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -35,10 +35,12 @@ final class NostrRelayManager: ObservableObject { "wss://nostr21.com" // For local testing, you can add: "ws://localhost:8080" ] + private static let defaultRelaySet = Set(defaultRelays) @Published private(set) var relays: [Relay] = [] @Published private(set) var isConnected = false + private var allowDefaultRelays: Bool = false private var connections: [String: URLSessionWebSocketTask] = [:] private var subscriptions: [String: Set] = [:] // relay URL -> active subscription IDs private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) @@ -65,6 +67,8 @@ final class NostrRelayManager: ObservableObject { private let messageQueueLock = NSLock() private let encoder = JSONEncoder() private let decoder = JSONDecoder() + private var networkService: NetworkActivationService { NetworkActivationService.shared } + private var shouldUseTor: Bool { networkService.userTorEnabled } // Exponential backoff configuration private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds @@ -78,29 +82,45 @@ final class NostrRelayManager: ObservableObject { private var connectionGeneration: Int = 0 init() { - // Initialize with default relays - self.relays = Self.defaultRelays.map { Relay(url: $0) } + let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty + allowDefaultRelays = hasMutual + if hasMutual { + self.relays = Self.defaultRelays.map { Relay(url: $0) } + } // Deterministic JSON shape for outbound requests self.encoder.outputFormatting = .sortedKeys + FavoritesPersistenceService.shared.$mutualFavorites + .receive(on: DispatchQueue.main) + .sink { [weak self] favorites in + self?.updateDefaultRelayPolicy(hasMutual: !favorites.isEmpty) + } + .store(in: &cancellables) } /// Connect to all configured relays func connect() { // Global network policy gate - if !TorManager.shared.isAutoStartAllowed() { return } - // 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.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) - return - } - SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session) - for relay in self.relays { - self.connectToRelay(relay.url) + guard networkService.activationAllowed else { return } + if shouldUseTor { + // 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.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) + return + } + SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session) + for relay in self.relays { + self.connectToRelay(relay.url) + } } } + } else { + SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session) + for relay in self.relays { + connectToRelay(relay.url) + } } } @@ -120,8 +140,10 @@ final class NostrRelayManager: ObservableObject { /// Ensure connections exist to the given relay URLs (idempotent). func ensureConnections(to relayUrls: [String]) { // Global network policy gate - if !TorManager.shared.isAutoStartAllowed() { return } - if TorManager.shared.torEnforced && !TorManager.shared.isReady { + guard networkService.activationAllowed else { return } + let targets = allowedRelayList(from: relayUrls) + guard !targets.isEmpty else { return } + if shouldUseTor && 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 } @@ -130,22 +152,21 @@ final class NostrRelayManager: ObservableObject { } return } - let existing = Set(relays.map { $0.url }) - for url in Set(relayUrls) { - if !existing.contains(url) { - relays.append(Relay(url: url)) - } - if connections[url] == nil { - connectToRelay(url) - } + var existing = Set(relays.map { $0.url }) + for url in targets where !existing.contains(url) { + relays.append(Relay(url: url)) + existing.insert(url) + } + for url in targets where connections[url] == nil { + connectToRelay(url) } } /// Send an event to specified relays (or all if none specified) func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { // Global network policy gate - if !TorManager.shared.isAutoStartAllowed() { return } - if TorManager.shared.torEnforced && !TorManager.shared.isReady { + guard networkService.activationAllowed else { return } + if shouldUseTor && 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 } @@ -154,7 +175,9 @@ final class NostrRelayManager: ObservableObject { } return } - let targetRelays = relayUrls ?? Self.defaultRelays + let requestedRelays = relayUrls ?? Self.defaultRelays + let targetRelays = allowedRelayList(from: requestedRelays) + guard !targetRelays.isEmpty else { return } ensureConnections(to: targetRelays) // Attempt immediate send to relays with active connections; queue the rest @@ -220,7 +243,7 @@ final class NostrRelayManager: ObservableObject { onEOSE: (() -> Void)? = nil ) { // Global network policy gate - if !TorManager.shared.isAutoStartAllowed() { return } + guard networkService.activationAllowed else { return } // Coalesce rapid duplicate subscribe requests only if a handler already exists let now = Date() if messageHandlers[id] != nil { @@ -229,7 +252,7 @@ final class NostrRelayManager: ObservableObject { } } subscribeCoalesce[id] = now - if TorManager.shared.torEnforced && !TorManager.shared.isReady { + if shouldUseTor && 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 } @@ -257,32 +280,37 @@ final class NostrRelayManager: ObservableObject { // Target specific relays if provided; else default. Filter permanently failed relays. let baseUrls = relayUrls ?? Self.defaultRelays - let urls = baseUrls.filter { !isPermanentlyFailed($0) } + let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) } + let urls = allowedRelayList(from: candidateUrls) // 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 url in urls { + for url in candidateUrls { var map = self.pendingSubscriptions[url] ?? [:] map[id] = messageString self.pendingSubscriptions[url] = map } // Initialize EOSE tracking if requested if let onEOSE = onEOSE { - var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil) - // Fallback timeout to avoid hanging if a relay never sends EOSE - tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in - Task { @MainActor in - guard let self = self else { return } - if let t = self.eoseTrackers[id] { - t.timer?.invalidate() - self.eoseTrackers.removeValue(forKey: id) - onEOSE() + if urls.isEmpty { + onEOSE() + } else { + var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil) + // Fallback timeout to avoid hanging if a relay never sends EOSE + tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + Task { @MainActor in + guard let self = self else { return } + if let t = self.eoseTrackers[id] { + t.timer?.invalidate() + self.eoseTrackers.removeValue(forKey: id) + onEOSE() + } } } + eoseTrackers[id] = tracker } - eoseTrackers[id] = tracker } SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session) // Ensure we actually have sockets opening to these relays so queued REQs can flush @@ -297,6 +325,54 @@ final class NostrRelayManager: ObservableObject { SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session) } } + + private func updateDefaultRelayPolicy(hasMutual: Bool) { + guard hasMutual != allowDefaultRelays else { return } + allowDefaultRelays = hasMutual + if hasMutual { + var existing = Set(relays.map { $0.url }) + for url in Self.defaultRelays where !existing.contains(url) { + relays.append(Relay(url: url)) + existing.insert(url) + } + if networkService.activationAllowed { + ensureConnections(to: Self.defaultRelays) + } + } else { + for url in Self.defaultRelays { + if let connection = connections[url] { + connection.cancel(with: .goingAway, reason: nil) + } + connections.removeValue(forKey: url) + subscriptions.removeValue(forKey: url) + } + messageQueueLock.lock() + for index in (0.. [String] { + var seen = Set() + var result: [String] = [] + for url in urls { + if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue } + if seen.insert(url).inserted { + result.append(url) + } + } + return result + } /// Unsubscribe from a subscription func unsubscribe(id: String) { @@ -326,14 +402,14 @@ final class NostrRelayManager: ObservableObject { private func connectToRelay(_ urlString: String) { // Global network policy gate - if !TorManager.shared.isAutoStartAllowed() { return } + guard networkService.activationAllowed else { return } guard let url = URL(string: urlString) else { SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) return } // Avoid initiating connections while app is backgrounded; we'll reconnect on foreground - if TorManager.shared.torEnforced && !TorManager.shared.isForeground() { + if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() { return } @@ -348,7 +424,7 @@ final class NostrRelayManager: ObservableObject { // Attempting to connect to Nostr relay via the proxied session // If Tor is enforced but not ready, delay connection until it is. - if TorManager.shared.torEnforced && !TorManager.shared.isReady { + if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { Task.detached { [weak self] in guard let self = self else { return } let ready = await TorManager.shared.awaitReady() @@ -534,7 +610,7 @@ final class NostrRelayManager: ObservableObject { private func handleDisconnection(relayUrl: String, error: Error) { // If networking is disallowed, do not schedule reconnection - if !TorManager.shared.isAutoStartAllowed() { + if !networkService.activationAllowed { connections.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl) updateRelayStatus(relayUrl, isConnected: false, error: error) diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift index 5281e97e..cbccb297 100644 --- a/bitchat/Services/NetworkActivationService.swift +++ b/bitchat/Services/NetworkActivationService.swift @@ -10,9 +10,12 @@ final class NetworkActivationService: ObservableObject { static let shared = NetworkActivationService() @Published private(set) var activationAllowed: Bool = false + @Published private(set) var userTorEnabled: Bool = true private var cancellables = Set() private var started = false + private let torPreferenceKey = "networkActivationService.userTorEnabled" + private var torAutoStartDesired: Bool = false private init() {} @@ -20,9 +23,23 @@ final class NetworkActivationService: ObservableObject { guard !started else { return } started = true + if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool { + userTorEnabled = stored + } else { + userTorEnabled = true + } + // Initial compute - activationAllowed = Self.computeAllowed() - TorManager.shared.setAutoStartAllowed(activationAllowed) + let allowed = basePolicyAllowed() + activationAllowed = allowed + torAutoStartDesired = allowed && userTorEnabled + TorManager.shared.setAutoStartAllowed(torAutoStartDesired) + applyTorState(torDesired: torAutoStartDesired) + if allowed { + NostrRelayManager.shared.connect() + } else { + NostrRelayManager.shared.disconnect() + } // React to location permission changes LocationChannelManager.shared.$permissionState @@ -41,30 +58,56 @@ final class NetworkActivationService: ObservableObject { .store(in: &cancellables) } + func setUserTorEnabled(_ enabled: Bool) { + guard enabled != userTorEnabled else { return } + userTorEnabled = enabled + UserDefaults.standard.set(enabled, forKey: torPreferenceKey) + NotificationCenter.default.post( + name: .TorUserPreferenceChanged, + object: nil, + userInfo: ["enabled": enabled] + ) + reevaluate() + } + private func reevaluate() { - let allowed = Self.computeAllowed() - if allowed != activationAllowed { + let allowed = basePolicyAllowed() + let torDesired = allowed && userTorEnabled + let statusChanged = allowed != activationAllowed + let torChanged = torDesired != torAutoStartDesired + if statusChanged { SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session) activationAllowed = allowed - TorManager.shared.setAutoStartAllowed(allowed) - if allowed { - // Kick Tor + relays if we're now permitted - TorManager.shared.startIfNeeded() - // If app is in foreground, begin relay connections - if TorManager.shared.isForeground() { - NostrRelayManager.shared.connect() - } - } else { - // Transitioned to disallowed: disconnect relays and shut down Tor + } + if statusChanged || torChanged { + torAutoStartDesired = torDesired + TorManager.shared.setAutoStartAllowed(torDesired) + applyTorState(torDesired: torDesired) + } + + if allowed { + if torChanged { + // Reset relay sockets when switching transport path (Tor ↔︎ direct) NostrRelayManager.shared.disconnect() - TorManager.shared.goDormantOnBackground() } + NostrRelayManager.shared.connect() + } else if statusChanged { + NostrRelayManager.shared.disconnect() } } - private static func computeAllowed() -> Bool { + private func basePolicyAllowed() -> Bool { let permOK = LocationChannelManager.shared.permissionState == .authorized let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty return permOK || hasMutual } + + private func applyTorState(torDesired: Bool) { + TorURLSession.shared.setProxyMode(useTor: torDesired) + if torDesired { + TorManager.shared.startIfNeeded() + } else { + TorManager.shared.shutdownCompletely() + } + } } diff --git a/bitchat/Services/Tor/TorManager.swift b/bitchat/Services/Tor/TorManager.swift index 08f04ce1..31f9e261 100644 --- a/bitchat/Services/Tor/TorManager.swift +++ b/bitchat/Services/Tor/TorManager.swift @@ -539,6 +539,24 @@ final class TorManager: ObservableObject { } } + func shutdownCompletely() { + Task.detached { [weak self] in + guard let self = self else { return } + _ = tor_host_shutdown() + await MainActor.run { + self.isDormant = false + self.isReady = false + self.socksReady = false + self.bootstrapProgress = 0 + self.bootstrapSummary = "" + self.isStarting = false + self.didStart = false + self.restarting = false + self.controlMonitorStarted = false + } + } + } + private func restartTor() async { await MainActor.run { // Announce restart so UI can notify the user diff --git a/bitchat/Services/Tor/TorNotifications.swift b/bitchat/Services/Tor/TorNotifications.swift index 6cff19a2..84636818 100644 --- a/bitchat/Services/Tor/TorNotifications.swift +++ b/bitchat/Services/Tor/TorNotifications.swift @@ -4,4 +4,5 @@ extension Notification.Name { static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady") static let TorWillRestart = Notification.Name("TorWillRestart") static let TorWillStart = Notification.Name("TorWillStart") + static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged") } diff --git a/bitchat/Services/Tor/TorURLSession.swift b/bitchat/Services/Tor/TorURLSession.swift index d2995d00..1813ac82 100644 --- a/bitchat/Services/Tor/TorURLSession.swift +++ b/bitchat/Services/Tor/TorURLSession.swift @@ -4,43 +4,34 @@ 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. +/// when Tor is enforced/ready. Allows swapping between proxied and direct +/// sessions so UI can toggle Tor usage at runtime. 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) - }() + // Default (no proxy) session for direct Nostr access when Tor is disabled. + private var defaultSession: URLSession = TorURLSession.makeDefaultSession() // Proxied (SOCKS5) session that routes through Tor. private var torSession: URLSession = TorURLSession.makeTorSession() + private var useTorProxy: Bool = true 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 + useTorProxy ? torSession : defaultSession } // 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 + defaultSession = TorURLSession.makeDefaultSession() torSession = TorURLSession.makeTorSession() } + func setProxyMode(useTor: Bool) { + guard useTorProxy != useTor else { return } + useTorProxy = useTor + rebuild() + } + private static func makeTorSession() -> URLSession { let cfg = URLSessionConfiguration.ephemeral cfg.waitsForConnectivity = true @@ -63,4 +54,10 @@ final class TorURLSession { #endif return URLSession(configuration: cfg) } + + private static func makeDefaultSession() -> URLSession { + let cfg = URLSessionConfiguration.default + cfg.waitsForConnectivity = true + return URLSession(configuration: cfg) + } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index b62080eb..21b21077 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -778,6 +778,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { name: .TorWillStart, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorPreferenceChanged(_:)), + name: .TorUserPreferenceChanged, + object: nil + ) #else NotificationCenter.default.addObserver( self, @@ -828,6 +834,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { name: .TorWillStart, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorPreferenceChanged(_:)), + name: .TorUserPreferenceChanged, + object: nil + ) #endif } @@ -869,6 +881,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } } } + + @objc private func handleTorPreferenceChanged(_ notification: Notification) { + Task { @MainActor in + self.torStatusAnnounced = false + self.torInitialReadyAnnounced = false + self.torRestartPending = false + } + } // Resubscribe to the active geohash channel without clearing timeline @MainActor diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 19f20083..52d6b702 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -9,6 +9,7 @@ struct LocationChannelsSheet: View { @Binding var isPresented: Bool @ObservedObject private var manager = LocationChannelManager.shared @ObservedObject private var bookmarks = GeohashBookmarksStore.shared + @ObservedObject private var network = NetworkActivationService.shared @EnvironmentObject var viewModel: ChatViewModel @Environment(\.colorScheme) var colorScheme @State private var customGeohash: String = "" @@ -266,6 +267,7 @@ struct LocationChannelsSheet: View { // Footer action inside the list if manager.permissionState == LocationChannelManager.PermissionState.authorized { + torToggleSection Button(action: { openSystemLocationSettings() }) { @@ -409,8 +411,36 @@ struct LocationChannelsSheet: View { } } -// MARK: - Standardized Colors +// MARK: - TOR Toggle & Standardized Colors extension LocationChannelsSheet { + private var torToggleBinding: Binding { + Binding( + get: { network.userTorEnabled }, + set: { network.setUserTorEnabled($0) } + ) + } + + private var torToggleSection: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: torToggleBinding) { + VStack(alignment: .leading, spacing: 2) { + Text("tor routing") + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(.primary) + Text("hides your ip for location channels. recommended: on.") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + } + .toggleStyle(IRCToggleStyle(accent: standardGreen)) + } + .padding(12) + .background(Color.secondary.opacity(0.12)) + .cornerRadius(8) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + private var standardGreen: Color { (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) } @@ -419,6 +449,34 @@ extension LocationChannelsSheet { } } +private struct IRCToggleStyle: ToggleStyle { + let accent: Color + + func makeBody(configuration: Configuration) -> some View { + Button(action: { configuration.isOn.toggle() }) { + HStack(spacing: 12) { + configuration.label + Spacer() + Text(configuration.isOn ? "on" : "off") + .textCase(.uppercase) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(configuration.isOn ? accent : .secondary) + .padding(.vertical, 4) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(accent.opacity(configuration.isOn ? 0.18 : 0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1) + ) + } + } + .buttonStyle(.plain) + } +} + // MARK: - Coverage helpers extension LocationChannelsSheet { private func coverageString(forPrecision len: Int) -> String {