From 96992c0a5978e85d1ca225dd916afc0fa84e9f8f Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Sep 2025 12:14:55 +0200 Subject: [PATCH] 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 --- bitchat.xcodeproj/project.pbxproj | 14 +- bitchat/BitchatApp.swift | 19 ++- bitchat/Nostr/NostrRelayManager.swift | 60 ++++++- bitchat/Services/Tor/CTorHost.c | 173 ++++++++++++++++++++ bitchat/Services/Tor/TorManager.swift | 168 ++++++++++++++++--- bitchat/Services/Tor/TorNotifications.swift | 6 + bitchat/ViewModels/ChatViewModel.swift | 60 ++++++- 7 files changed, 464 insertions(+), 36 deletions(-) create mode 100644 bitchat/Services/Tor/CTorHost.c create mode 100644 bitchat/Services/Tor/TorNotifications.swift diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 87958a08..00dd1c63 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -39,6 +39,10 @@ 0481A3592E6D929E00FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */; }; 0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; }; 0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; }; + 0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */; }; + 0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */; }; + 0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */; }; + 0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */; }; 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; @@ -220,10 +224,12 @@ 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = ""; }; 0481A3432E6D869F00FC845E /* TorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorManager.swift; sourceTree = ""; }; 0481A3442E6D869F00FC845E /* TorURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorURLSession.swift; sourceTree = ""; }; + 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = CTorHost.c; sourceTree = ""; }; + 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorNotifications.swift; sourceTree = ""; }; 0481A3532E6D877600FC845E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = ""; }; 0481A35A2E6D9BEF00FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; - 0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.5.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; + 0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = ""; }; 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = ""; }; 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = ""; }; @@ -343,6 +349,8 @@ children = ( 0481A3432E6D869F00FC845E /* TorManager.swift */, 0481A3442E6D869F00FC845E /* TorURLSession.swift */, + 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */, + 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */, ); path = Tor; sourceTree = ""; @@ -794,6 +802,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */, + 0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */, 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */, AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */, @@ -856,6 +866,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */, + 0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */, 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */, AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */, diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 80dbc7cd..fd22c16b 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -57,17 +57,26 @@ struct BitchatApp: App { case .background: // Keep BLE mesh running in background; BLEService adapts scanning automatically // Optionally nudge Tor to dormant to save power + TorManager.shared.setAppForeground(false) TorManager.shared.goDormantOnBackground() + // Proactively disconnect Nostr to avoid spurious socket errors while Tor is down + NostrRelayManager.shared.disconnect() break case .active: // Restart services when becoming active chatViewModel.meshService.startServices() - // Ensure Tor is healthy; restart if suspended; wake ACTIVE + TorManager.shared.setAppForeground(true) + // Ensure Tor is healthy; restart deterministically, then wait until ready TorManager.shared.ensureRunningOnForeground() - // Rebuild proxied sessions to bind to live Tor - TorURLSession.shared.rebuild() - // Reconnect Nostr via fresh sessions; will gate until Tor 100% - NostrRelayManager.shared.resetAllConnections() + 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 diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 37517a9a..559bc8af 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -42,6 +42,8 @@ class NostrRelayManager: ObservableObject { private var subscriptions: [String: Set] = [:] // 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() // Message queue for reliability @@ -63,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 @@ -91,15 +95,28 @@ class NostrRelayManager: ObservableObject { /// 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) { @@ -113,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) @@ -177,6 +203,27 @@ 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)", @@ -248,6 +295,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) @@ -274,6 +323,11 @@ 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 { @@ -528,9 +582,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) @@ -571,6 +627,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 { diff --git a/bitchat/Services/Tor/CTorHost.c b/bitchat/Services/Tor/CTorHost.c new file mode 100644 index 00000000..76194e30 --- /dev/null +++ b/bitchat/Services/Tor/CTorHost.c @@ -0,0 +1,173 @@ +#include +#include +#include +#include +#include +#include +#include + +// 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); +} diff --git a/bitchat/Services/Tor/TorManager.swift b/bitchat/Services/Tor/TorManager.swift index 59c99187..8189514c 100644 --- a/bitchat/Services/Tor/TorManager.swift +++ b/bitchat/Services/Tor/TorManager.swift @@ -6,6 +6,16 @@ import Darwin @_silgen_name("tor_main") private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer?>?) -> 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, _ socksAddr: UnsafePointer, _ controlAddr: UnsafePointer, _ 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, _ socksAddr: UnsafePointer, _ controlAddr: UnsafePointer, _ 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 @@ -56,12 +66,16 @@ final class TorManager: ObservableObject { 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 @@ -71,10 +85,21 @@ final class TorManager: ObservableObject { 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 { - await MainActor.run { self.startIfNeeded() } + // 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 } @@ -131,6 +156,7 @@ final class TorManager: ObservableObject { 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" } @@ -140,11 +166,9 @@ final class TorManager: ObservableObject { /// 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() { - // If linked statically (xcframework with static framework), call tor_run_main directly. - if startTorViaLinkedSymbol() { return } - - // Dynamic loading path is intended for dynamic frameworks only. - if startTorViaDlopen() { return } + // 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. @@ -156,6 +180,50 @@ final class TorManager: ObservableObject { 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) @@ -404,48 +472,69 @@ final class TorManager: ObservableObject { // MARK: - Foreground recovery and control helpers func ensureRunningOnForeground() { - // If we can talk to ControlPort, wake Tor and verify bootstrap; else restart. + // 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 } - // Avoid restarts while starting/restarting - if await MainActor.run(body: { self.isStarting || self.restarting }) { return } - let ok = await self.controlPingBootstrap() - if ok { - _ = await self.controlSendSignal("ACTIVE") - 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 Tor is still bootstrapping (SOCKS up but progress < 100), don't thrash; let monitor update - let stillBootstrapping = await MainActor.run(body: { self.socksReady && self.bootstrapProgress < 100 }) - if stillBootstrapping { return } + 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 - _ = await self?.controlSendSignal("DORMANT") + 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 { self.restarting = true; self.isReady = false; self.socksReady = false; self.bootstrapProgress = 0; self.bootstrapSummary = ""; self.isStarting = true } - // Try graceful shutdown if control is reachable - _ = await controlSendSignal("SHUTDOWN") - // Wait for SOCKS to go down - let downDeadline = Date().addingTimeInterval(5) - while Date() < downDeadline { - if await !probeSocksOnce() { break } - try? await Task.sleep(nanoseconds: 200_000_000) + 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() } - await MainActor.run { self.restarting = false } } 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) + } } } @@ -457,12 +546,37 @@ final class TorManager: ObservableObject { monitor.pathUpdateHandler = { [weak self] _ in // On any path change, poke Tor/recover (hop to main actor). Task { @MainActor in - self?.ensureRunningOnForeground() + 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) diff --git a/bitchat/Services/Tor/TorNotifications.swift b/bitchat/Services/Tor/TorNotifications.swift new file mode 100644 index 00000000..e14e5929 --- /dev/null +++ b/bitchat/Services/Tor/TorNotifications.swift @@ -0,0 +1,6 @@ +import Foundation + +extension Notification.Name { + static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady") + static let TorWillRestart = Notification.Name("TorWillRestart") +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 1de69579..fe8ae898 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -362,6 +362,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { 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 // MARK: - Caches @@ -754,6 +759,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, @@ -785,6 +803,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 } @@ -793,6 +824,24 @@ class ChatViewModel: ObservableObject, BitchatDelegate { deinit { // No need to force UserDefaults synchronization } + + // MARK: - Tor notifications + @objc private func handleTorWillRestart() { + Task { @MainActor in + self.torRestartPending = true + self.addPublicSystemMessage("tor restarting to recover connectivity…") + } + } + + @objc private func handleTorDidBecomeReady() { + Task { @MainActor in + // Only announce "restarted" if we actually restarted this session + if self.torRestartPending { + self.addPublicSystemMessage("tor restarted. network routing restored.") + self.torRestartPending = false + } + } + } // Resubscribe to the active geohash channel without clearing timeline @MainActor @@ -1470,6 +1519,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) @@ -2630,8 +2684,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 @@ -4925,6 +4978,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { 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.