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
This commit is contained in:
jack
2025-09-11 12:14:55 +02:00
parent 08d1fa7653
commit 96992c0a59
7 changed files with 464 additions and 36 deletions
+13 -1
View File
@@ -39,6 +39,10 @@
0481A3592E6D929E00FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */; }; 0481A3592E6D929E00FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */; };
0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; }; 0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; };
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* 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 */; }; 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* 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 */; }; 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 = "<group>"; }; 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
0481A3432E6D869F00FC845E /* TorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorManager.swift; sourceTree = "<group>"; }; 0481A3432E6D869F00FC845E /* TorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorManager.swift; sourceTree = "<group>"; };
0481A3442E6D869F00FC845E /* TorURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorURLSession.swift; sourceTree = "<group>"; }; 0481A3442E6D869F00FC845E /* TorURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorURLSession.swift; sourceTree = "<group>"; };
0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = CTorHost.c; sourceTree = "<group>"; };
0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorNotifications.swift; sourceTree = "<group>"; };
0481A3532E6D877600FC845E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; }; 0481A3532E6D877600FC845E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; }; 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
0481A35A2E6D9BEF00FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 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 = "<group>"; }; 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; }; 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; }; 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
@@ -343,6 +349,8 @@
children = ( children = (
0481A3432E6D869F00FC845E /* TorManager.swift */, 0481A3432E6D869F00FC845E /* TorManager.swift */,
0481A3442E6D869F00FC845E /* TorURLSession.swift */, 0481A3442E6D869F00FC845E /* TorURLSession.swift */,
0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */,
0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */,
); );
path = Tor; path = Tor;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -794,6 +802,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */,
0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */,
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */, 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */, AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
@@ -856,6 +866,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */,
0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */,
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */, 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */, AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
+14 -5
View File
@@ -57,17 +57,26 @@ struct BitchatApp: App {
case .background: case .background:
// Keep BLE mesh running in background; BLEService adapts scanning automatically // Keep BLE mesh running in background; BLEService adapts scanning automatically
// Optionally nudge Tor to dormant to save power // Optionally nudge Tor to dormant to save power
TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground() TorManager.shared.goDormantOnBackground()
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
NostrRelayManager.shared.disconnect()
break break
case .active: case .active:
// Restart services when becoming active // Restart services when becoming active
chatViewModel.meshService.startServices() 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() TorManager.shared.ensureRunningOnForeground()
// Rebuild proxied sessions to bind to live Tor Task.detached {
TorURLSession.shared.rebuild() let _ = await TorManager.shared.awaitReady(timeout: 60)
// Reconnect Nostr via fresh sessions; will gate until Tor 100% await MainActor.run {
NostrRelayManager.shared.resetAllConnections() // 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() checkForSharedContent()
case .inactive: case .inactive:
break break
+59 -1
View File
@@ -42,6 +42,8 @@ class NostrRelayManager: ObservableObject {
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active 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 pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
private var messageHandlers: [String: (NostrEvent) -> Void] = [:] 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>() private var cancellables = Set<AnyCancellable>()
// Message queue for reliability // Message queue for reliability
@@ -63,6 +65,8 @@ class NostrRelayManager: ObservableObject {
// Reconnection timer // Reconnection timer
private var reconnectionTimer: Timer? private var reconnectionTimer: Timer?
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0
init() { init() {
// Initialize with default relays // Initialize with default relays
@@ -91,15 +95,28 @@ class NostrRelayManager: ObservableObject {
/// Disconnect from all relays /// Disconnect from all relays
func disconnect() { func disconnect() {
connectionGeneration &+= 1
for (_, task) in connections { for (_, task) in connections {
task.cancel(with: .goingAway, reason: nil) task.cancel(with: .goingAway, reason: nil)
} }
connections.removeAll() connections.removeAll()
// Clear known subscriptions and any queued subs since connections are gone
subscriptions.removeAll()
pendingSubscriptions.removeAll()
updateConnectionStatus() updateConnectionStatus()
} }
/// Ensure connections exist to the given relay URLs (idempotent). /// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) { 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 }) let existing = Set(relays.map { $0.url })
for url in Set(relayUrls) { for url in Set(relayUrls) {
if !existing.contains(url) { if !existing.contains(url) {
@@ -113,6 +130,15 @@ class NostrRelayManager: ObservableObject {
/// Send an event to specified relays (or all if none specified) /// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { 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 let targetRelays = relayUrls ?? Self.defaultRelays
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
@@ -177,6 +203,27 @@ class NostrRelayManager: ObservableObject {
relayUrls: [String]? = nil, relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void 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 messageHandlers[id] = handler
SecureLogger.log("📡 Subscribing to Nostr filter id=\(id) kinds=\(filter.kinds ?? []) since=\(filter.since ?? 0)", 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 /// Unsubscribe from a subscription
func unsubscribe(id: String) { func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id)
let req = NostrRequest.close(id: id) let req = NostrRequest.close(id: id)
let message = try? encoder.encode(req) let message = try? encoder.encode(req)
@@ -274,6 +323,11 @@ class NostrRelayManager: ObservableObject {
SecureLogger.log("Invalid relay URL: \(urlString)", category: SecureLogger.session, level: .warning) SecureLogger.log("Invalid relay URL: \(urlString)", category: SecureLogger.session, level: .warning)
return 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 // Skip if we already have a connection object
if connections[urlString] != nil { if connections[urlString] != nil {
@@ -528,9 +582,11 @@ class NostrRelayManager: ObservableObject {
// Schedule reconnection with exponential backoff // Schedule reconnection with exponential backoff
let gen = connectionGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
guard let self = self else { return } 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) // Check if we should still reconnect (relay might have been removed)
if self.relays.contains(where: { $0.url == relayUrl }) { if self.relays.contains(where: { $0.url == relayUrl }) {
self.connectToRelay(relayUrl) self.connectToRelay(relayUrl)
@@ -571,6 +627,8 @@ class NostrRelayManager: ObservableObject {
/// Reset all relay connections /// Reset all relay connections
func resetAllConnections() { func resetAllConnections() {
disconnect() disconnect()
// New generation begins now
connectionGeneration &+= 1
// Reset all relay states // Reset all relay states
for index in relays.indices { for index in relays.indices {
+173
View File
@@ -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);
}
+141 -27
View File
@@ -6,6 +6,16 @@ import Darwin
@_silgen_name("tor_main") @_silgen_name("tor_main")
private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32 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. /// Minimal Tor integration scaffold.
/// - Boots a local Tor client (once integrated) and exposes a SOCKS5 proxy /// - 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 /// 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 didStart = false
private var controlMonitorStarted = false private var controlMonitorStarted = false
private var pathMonitor: NWPathMonitor? private var pathMonitor: NWPathMonitor?
private var isAppForeground: Bool = true
private var lastRestartAt: Date? = nil
private init() {} private init() {}
// MARK: - Public API // MARK: - Public API
func startIfNeeded() { func startIfNeeded() {
// Do not start in background; caller should wait for foreground
guard isAppForeground else { return }
guard !didStart else { return } guard !didStart else { return }
didStart = true didStart = true
isStarting = true isStarting = true
@@ -71,10 +85,21 @@ final class TorManager: ObservableObject {
startPathMonitorIfNeeded() 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). /// 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 to avoid blocking the main actor during waits.
nonisolated func awaitReady(timeout: TimeInterval = 25.0) async -> Bool { 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) let deadline = Date().addingTimeInterval(timeout)
// Early exit if network already permitted // Early exit if network already permitted
if await MainActor.run(body: { self.networkPermitted }) { return true } if await MainActor.run(body: { self.networkPermitted }) { return true }
@@ -131,6 +156,7 @@ final class TorManager: ObservableObject {
lines.append("CookieAuthentication 1") lines.append("CookieAuthentication 1")
lines.append("AvoidDiskWrites 1") lines.append("AvoidDiskWrites 1")
lines.append("MaxClientCircuitsPending 8") lines.append("MaxClientCircuitsPending 8")
lines.append("ShutdownWaitLength 0")
// Keep defaults for guard/exit selection to preserve anonymity properties // Keep defaults for guard/exit selection to preserve anonymity properties
return lines.joined(separator: "\n") + "\n" 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. /// Start the embedded Tor. This stub intentionally compiles without any Tor dependency.
/// Integrate your Tor framework here and set `isReady = true` once bootstrapped. /// Integrate your Tor framework here and set `isReady = true` once bootstrapped.
private func startTor() { private func startTor() {
// If linked statically (xcframework with static framework), call tor_run_main directly. // Prefer the embedding API wrapper which cleanly supports restart.
if startTorViaLinkedSymbol() { return } // Avoid fallback to prevent accidental second instances in-process.
if startTorViaEmbedAPI() { return }
// Dynamic loading path is intended for dynamic frameworks only.
if startTorViaDlopen() { return }
#if BITCHAT_DEV_ALLOW_CLEARNET #if BITCHAT_DEV_ALLOW_CLEARNET
// Dev bypass: permit network immediately (no Tor). Use ONLY for local development. // Dev bypass: permit network immediately (no Tor). Use ONLY for local development.
@@ -156,6 +180,50 @@ final class TorManager: ObservableObject {
self.isStarting = false self.isStarting = false
#endif #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. /// Probe the local SOCKS port until it's ready or a timeout elapses.
private func waitForSocksReady(timeout: TimeInterval) async -> Bool { private func waitForSocksReady(timeout: TimeInterval) async -> Bool {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
@@ -404,48 +472,69 @@ final class TorManager: ObservableObject {
// MARK: - Foreground recovery and control helpers // MARK: - Foreground recovery and control helpers
func ensureRunningOnForeground() { 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 Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
// Avoid restarts while starting/restarting // Claim the restart under MainActor to avoid races
if await MainActor.run(body: { self.isStarting || self.restarting }) { return } let claimed: Bool = await MainActor.run {
let ok = await self.controlPingBootstrap() if self.isStarting || self.restarting { return false }
if ok { self.restarting = true
_ = await self.controlSendSignal("ACTIVE") return true
return
} }
// If Tor is still bootstrapping (SOCKS up but progress < 100), don't thrash; let monitor update if !claimed { return }
let stillBootstrapping = await MainActor.run(body: { self.socksReady && self.bootstrapProgress < 100 })
if stillBootstrapping { return }
await self.restartTor() await self.restartTor()
await MainActor.run { self.restarting = false }
} }
} }
func goDormantOnBackground() { 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 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 { private func restartTor() async {
await MainActor.run { self.restarting = true; self.isReady = false; self.socksReady = false; self.bootstrapProgress = 0; self.bootstrapSummary = ""; self.isStarting = true } await MainActor.run {
// Try graceful shutdown if control is reachable // Announce restart so UI can notify the user
_ = await controlSendSignal("SHUTDOWN") NotificationCenter.default.post(name: .TorWillRestart, object: nil)
// Wait for SOCKS to go down self.isReady = false
let downDeadline = Date().addingTimeInterval(5) self.socksReady = false
while Date() < downDeadline { self.bootstrapProgress = 0
if await !probeSocksOnce() { break } self.bootstrapSummary = ""
try? await Task.sleep(nanoseconds: 200_000_000) 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.didStart = false }
await MainActor.run { self.startIfNeeded() } await MainActor.run { self.startIfNeeded() }
await MainActor.run { self.restarting = false }
} }
private func recomputeReady() { private func recomputeReady() {
let ready = socksReady && bootstrapProgress >= 100 let ready = socksReady && bootstrapProgress >= 100
if ready != isReady { if ready != isReady {
isReady = ready 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 monitor.pathUpdateHandler = { [weak self] _ in
// On any path change, poke Tor/recover (hop to main actor). // On any path change, poke Tor/recover (hop to main actor).
Task { @MainActor in 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) 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. // Lightweight control: authenticate and GETINFO bootstrap-phase.
private func controlPingBootstrap(timeout: TimeInterval = 3.0) async -> Bool { private func controlPingBootstrap(timeout: TimeInterval = 3.0) async -> Bool {
let data = await controlExchange(lines: ["GETINFO status/bootstrap-phase"], timeout: timeout) let data = await controlExchange(lines: ["GETINFO status/bootstrap-phase"], timeout: timeout)
@@ -0,0 +1,6 @@
import Foundation
extension Notification.Name {
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
static let TorWillRestart = Notification.Name("TorWillRestart")
}
+58 -2
View File
@@ -362,6 +362,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private var torStatusAnnounced = false private var torStatusAnnounced = false
private var torProgressCancellable: AnyCancellable? private var torProgressCancellable: AnyCancellable?
private var lastTorProgressAnnounced = -1 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 // MARK: - Caches
@@ -754,6 +759,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
name: NSApplication.willTerminateNotification, name: NSApplication.willTerminateNotification,
object: nil 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 #else
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
@@ -785,6 +803,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
name: UIApplication.willTerminateNotification, name: UIApplication.willTerminateNotification,
object: nil 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 #endif
} }
@@ -793,6 +824,24 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
deinit { deinit {
// No need to force UserDefaults synchronization // 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 // Resubscribe to the active geohash channel without clearing timeline
@MainActor @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) 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 // Unsubscribe previous
if let sub = geoSubscriptionID { if let sub = geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub) NostrRelayManager.shared.unsubscribe(id: sub)
@@ -2630,8 +2684,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.markPrivateMessagesAsRead(from: peerID) self.markPrivateMessagesAsRead(from: peerID)
} }
} }
// Also resubscribe the current geohash channel if active // Subscriptions will be resent after connections come back up
resubscribeCurrentGeohash()
} }
@MainActor @MainActor
@@ -4925,6 +4978,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func addGeohashOnlySystemMessage(_ content: String) { func addGeohashOnlySystemMessage(_ content: String) {
if case .location = activeChannel { if case .location = activeChannel {
addPublicSystemMessage(content) 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. // Send a public message without adding a local user echo.