tor by default, small (#564)

* feat(tor): Tor-by-default scaffold and integration

- Add TorManager with static/dlopen start, torrc generation, SOCKS probe
- Add TorURLSession; route Nostr/Web fetches via SOCKS proxy
- Add chat system messages for Tor status; show progress (macOS) and ready
- Disable ControlPort bootstrap monitor on iOS; keep it on macOS
- Make Tor waits non-blocking; avoid main-actor stalls on startup
- Queue & flush Nostr subscriptions on relay connect; skip duplicates
- Always rewrite torrc at launch to fix iOS container path mismatches
- Link libz; add project wiring for tor-nolzma.xcframework
- Minor fixes: SOCKS probe resumeOnce guard, entitlement for network.server (macOS)

* iOS: deterministic Tor recovery + 100% gating; BLE-first; session rebuild

- Restart/wake Tor on foreground via ControlPort (ACTIVE/SHUTDOWN),
  avoid restarts during bootstrap; add NWPathMonitor to trigger checks
- Use NWConnection control polling for GETINFO; remove blocking CFStream
  readers to avoid QoS inversions; compute readiness from SOCKS + 100%
- Rebuild TorURLSession on resume; reset Nostr connections to rebind
- Gate all internet after full bootstrap; keep BLE mesh startup fast
- Fix Swift 6 capture issues; hop UI updates to @MainActor
- Remove Tor progress spam; persist initial "starting tor..." system message

* UI: show Tor system messages only in geohash channels (not mesh)

- Gate "starting tor..." and readiness/timeout messages to geohash view
- Add helper addGeohashOnlySystemMessage() to avoid posting to mesh timeline
- Persist system messages in geohash backing store via addPublicSystemMessage()

* Relays: treat repeated -1011 handshake failures as permanent; skip reconnects

- Classify NSURLErrorBadServerResponse as permanent and stop retrying
- Filter permanently-failed relays from subscribe/connect attempts
- Avoid reconnect scheduling for permanently failed relays

* Embed Tor via tor_api; deterministic restart + Nostr gating; add Tor notifications

- Run Tor via tor_api in a dedicated thread with OwningControllerFD
- Cleanly stop Tor on background; restart on .active (single instance)
- Avoid fallback to tor_main/dlopen; add is-running check to prevent duplicates
- Fix argv lifetime in C glue to avoid strcmp crash on start
- Gate Nostr connect/subscribe/send until Tor is fully ready
- Rebuild URLSession + reset relays after Tor readiness (scene-based)
- Remove TorDidBecomeReady double-reset and appDidBecomeActive resubscribe
- Add TorWillRestart/TorDidBecomeReady notifications and chat system messages
- Debounce path-change restarts; ACTIVE poke first; coalesce subs; cancel stale reconnect timers
- Project: add CTorHost.c and TorNotifications.swift to targets; fix libz.tbd path

* Defer Nostr setup logs until Tor is ready; fix subscribe coalescing and reconnect generation

- Move "Connecting to Nostr relays" log after awaitReady()
- Log "Queuing subscription" when Tor not ready; only coalesce when handler exists
- Clear coalescer on unsubscribe
- Cancel stale reconnect timers using connectionGeneration
- Remove app-level TorDidBecomeReady reset to avoid duplicate reconnects
- Debounce path-change restarts

* Gate Nostr init/subscription logs until Tor is ready

- ChatViewModel: await Tor readiness before initializing Nostr and logging
- Only log GeoDM subscription when Tor is ready to avoid early noise

* Make Nostr connect single-sourced; defer DM subscription until connected

- Remove duplicate connect call from ChatViewModel; let scene-based flow connect
- Setup DM subscription once on first connection via  sink
- Reduce early subscription send/cancel noise after Tor restarts

* On launch, queue Nostr subscriptions without initiating connects; let centralized connect handle it

- In subscribe(), if no connections exist, just list relays and queue subs
- Avoids early send/cancel churn before connect() runs post-Tor-ready

* Always queue subscriptions and flush on connection; avoid immediate sends

- Prevents early send/cancel churn at launch and during reconnects
- If relays are already connected, flush immediately; otherwise pending until connected

* UI: scope Tor restart messages to geohash channels; skip initial foreground restart on cold launch to avoid confusing system message in #mesh

* geo: disable background sampling + notifications\n- Gate sampling to foreground only (beginGeohashSampling, watchers)\n- Suppress geohash activity notifications unless app is active\n- Stop sampling explicitly on background scene phase

* Update BitchatApp.swift

Co-authored-by: asmo <asmogo@protonmail.com>

* Update BitchatApp.swift

Co-authored-by: asmo <asmogo@protonmail.com>

* Update BitchatApp.swift

Co-authored-by: asmo <asmogo@protonmail.com>

* Update bitchat/BitchatApp.swift

Co-authored-by: asmo <asmogo@protonmail.com>

* Update bitchat/BitchatApp.swift

Co-authored-by: asmo <asmogo@protonmail.com>

* fix(iOS App): resolve merge artifacts in scenePhase handler\n- Remove duplicate didEnterBackground state\n- Fix switch/if braces and logic for foreground restart gating

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: asmo <asmogo@protonmail.com>
This commit is contained in:
jack
2025-09-11 19:08:43 +02:00
committed by GitHub
co-authored by jack asmo
parent 6e1fb15edf
commit 5f44af19da
1496 changed files with 236105 additions and 108 deletions
+173 -42
View File
@@ -358,6 +358,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private var geoDmSubscriptionID: String? = nil
private var currentGeohash: String? = nil
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
// Show Tor status once per app launch
private var torStatusAnnounced = false
private var torProgressCancellable: AnyCancellable?
private var lastTorProgressAnnounced = -1
// Queue geohash-only system messages if user isn't on a location channel yet
private var pendingGeohashSystemMessages: [String] = []
// Track whether a Tor restart is pending so we only announce
// "tor restarted" after an actual restart, not the first launch.
private var torRestartPending: Bool = false
// Ensure we set up DM subscription only once per app session
private var nostrHandlersSetup: Bool = false
// MARK: - Caches
@@ -526,18 +537,37 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Start mesh service immediately
meshService.startServices()
// Initialize Nostr services
// Announce Tor status (geohash-only; do not show in mesh chat)
if TorManager.shared.torEnforced && !torStatusAnnounced {
torStatusAnnounced = true
addGeohashOnlySystemMessage("starting tor...")
// Suppress incremental Tor progress messages; only show initial start and readiness.
torProgressCancellable = nil
Task.detached {
let ready = await TorManager.shared.awaitReady()
Task { @MainActor [weak self] in
guard let self else { return }
if ready { self.addGeohashOnlySystemMessage("tor started. routing all chats via tor for privacy.") }
else { self.addGeohashOnlySystemMessage("still waiting for tor to start...") }
}
}
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
torStatusAnnounced = true
addGeohashOnlySystemMessage("development build: tor bypass enabled.")
}
// Initialize Nostr services only after Tor is fully ready, to avoid
// interleaving setup logs during Tor startup.
Task { @MainActor in
// Wait for Tor readiness before Nostr init
let ready = await TorManager.shared.awaitReady(timeout: 60)
guard ready else {
SecureLogger.log("Nostr init skipped: Tor not ready", category: SecureLogger.session, level: .error)
return
}
nostrRelayManager = NostrRelayManager.shared
SecureLogger.log("Initializing Nostr relay connections", category: SecureLogger.session, level: .debug)
nostrRelayManager?.connect()
// Small delay to ensure read receipts are fully loaded
// This prevents race conditions where messages arrive before initialization completes
try? await Task.sleep(nanoseconds: TransportConfig.uiStartupShortSleepNs) // 0.2 seconds
// Set up Nostr message handling directly
setupNostrMessageHandling()
// Connect is managed centrally on scene activation; avoid duplicate connects here
// Attempt to flush any queued outbox after Nostr comes online
messageRouter.flushAllOutbox()
@@ -584,25 +614,34 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.cancellables.insert(cancellable)
// Resubscribe geohash on relay reconnect
if let relayMgr = self.nostrRelayManager {
relayMgr.$isConnected
.receive(on: DispatchQueue.main)
.sink { [weak self] connected in
guard let self = self else { return }
if connected {
Task { @MainActor in
self.resubscribeCurrentGeohash()
// Re-init sampling for regional + bookmarked geohashes after reconnect
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
// Resubscribe geohash on relay reconnect
if let relayMgr = self.nostrRelayManager {
relayMgr.$isConnected
.receive(on: DispatchQueue.main)
.sink { [weak self] connected in
guard let self = self else { return }
if connected {
Task { @MainActor in
// Set up DM handler once on first connect
if !self.nostrHandlersSetup {
self.setupNostrMessageHandling()
self.nostrHandlersSetup = true
}
self.resubscribeCurrentGeohash()
// Re-init sampling for regional + bookmarked geohashes after reconnect
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
if TorManager.shared.isForeground() {
self.beginGeohashSampling(for: union)
} else {
self.endGeohashSampling()
}
}
}
.store(in: &self.cancellables)
}
}
.store(in: &self.cancellables)
}
}
// Set up Noise encryption callbacks
@@ -623,7 +662,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
}
// Background: keep sampling nearby geohashes + bookmarks for notifications even when sheet is closed
// Foreground-only: sample nearby geohashes + bookmarks (disabled in background)
LocationChannelManager.shared.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] channels in
@@ -632,7 +671,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
Task { @MainActor in
self.beginGeohashSampling(for: union)
if TorManager.shared.isForeground() {
self.beginGeohashSampling(for: union)
} else {
self.endGeohashSampling()
}
}
}
.store(in: &cancellables)
@@ -645,17 +688,21 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let union = Array(Set(regional).union(bookmarks))
Task { @MainActor in
self.beginGeohashSampling(for: union)
if TorManager.shared.isForeground() {
self.beginGeohashSampling(for: union)
} else {
self.endGeohashSampling()
}
}
}
.store(in: &cancellables)
// Kick off initial sampling if we have regional channels or bookmarks
// Kick off initial sampling if we have regional channels or bookmarks (foreground only)
do {
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
if !union.isEmpty {
if !union.isEmpty && TorManager.shared.isForeground() {
Task { @MainActor in self.beginGeohashSampling(for: union) }
}
}
@@ -731,6 +778,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
name: NSApplication.willTerminateNotification,
object: nil
)
// Tor lifecycle notifications: inform user when Tor restarts and when ready (macOS)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleTorWillRestart),
name: .TorWillRestart,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleTorDidBecomeReady),
name: .TorDidBecomeReady,
object: nil
)
#else
NotificationCenter.default.addObserver(
self,
@@ -762,6 +822,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
name: UIApplication.willTerminateNotification,
object: nil
)
// Tor lifecycle notifications: inform user when Tor restarts and when ready
NotificationCenter.default.addObserver(
self,
selector: #selector(handleTorWillRestart),
name: .TorWillRestart,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleTorDidBecomeReady),
name: .TorDidBecomeReady,
object: nil
)
#endif
}
@@ -770,6 +843,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
deinit {
// No need to force UserDefaults synchronization
}
// MARK: - Tor notifications
@objc private func handleTorWillRestart() {
Task { @MainActor in
self.torRestartPending = true
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage("tor restarting to recover connectivity…")
}
}
@objc private func handleTorDidBecomeReady() {
Task { @MainActor in
// Only announce "restarted" if we actually restarted this session
if self.torRestartPending {
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage("tor restarted. network routing restored.")
self.torRestartPending = false
}
}
}
// Resubscribe to the active geohash channel without clearing timeline
@MainActor
@@ -1447,6 +1540,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
SecureLogger.log("RenderGuard: geohash \(ch.geohash) timeline has \(emptyGeo) empty messages after sanitize", category: SecureLogger.session, level: .debug)
}
}
// If switching to a location channel, flush any pending geohash-only system messages
if case .location = channel, !pendingGeohashSystemMessages.isEmpty {
for m in pendingGeohashSystemMessages { addPublicSystemMessage(m) }
pendingGeohashSystemMessages.removeAll(keepingCapacity: false)
}
// Unsubscribe previous
if let sub = geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
@@ -1576,8 +1674,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub
// pared back logging: subscribe debug only
SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)",
category: SecureLogger.session, level: .debug)
// Log GeoDM subscribe only when Tor is ready to avoid early noise
if TorManager.shared.isReady {
SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)",
category: SecureLogger.session, level: .debug)
}
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
guard let self = self else { return }
@@ -1842,6 +1943,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
/// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.
@MainActor
func beginGeohashSampling(for geohashes: [String]) {
// Disable sampling when app is backgrounded (Tor is stopped there)
if !TorManager.shared.isForeground() {
endGeohashSampling()
return
}
// Determine which to add and which to remove
let desired = Set(geohashes)
let current = Set(geoSamplingSubs.values)
@@ -1887,15 +1993,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Avoid notifications for old sampled events when launching or (re)subscribing
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) > 30 { return }
// Foreground policy: allow if it's a different geohash than the one currently open
// Foreground-only notifications: app must be active, and not already viewing this geohash
#if os(iOS)
if UIApplication.shared.applicationState == .active {
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
}
guard UIApplication.shared.applicationState == .active else { return }
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
#elseif os(macOS)
if NSApplication.shared.isActive {
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
}
guard NSApplication.shared.isActive else { return }
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
#endif
// Cooldown per geohash
let now = Date()
@@ -2607,8 +2711,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.markPrivateMessagesAsRead(from: peerID)
}
}
// Also resubscribe the current geohash channel if active
resubscribeCurrentGeohash()
// Subscriptions will be resent after connections come back up
}
@MainActor
@@ -4873,12 +4976,40 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
messages.append(systemMessage)
}
/// Public helper to add a system message to the public chat timeline
/// Public helper to add a system message to the public chat timeline.
/// Also persists the message into the active channel's backing store so it survives timeline rebinds.
@MainActor
func addPublicSystemMessage(_ content: String) {
addSystemMessage(content)
let systemMessage = BitchatMessage(
sender: "system",
content: content,
timestamp: Date(),
isRelay: false
)
// Append to current visible messages
messages.append(systemMessage)
// Persist into the backing store for the active channel to survive rebinds
switch activeChannel {
case .mesh:
meshTimeline.append(systemMessage)
case .location(let ch):
var arr = geoTimelines[ch.geohash] ?? []
arr.append(systemMessage)
geoTimelines[ch.geohash] = arr
}
objectWillChange.send()
}
/// Add a system message only if viewing a geohash location channel (never post to mesh).
@MainActor
func addGeohashOnlySystemMessage(_ content: String) {
if case .location = activeChannel {
addPublicSystemMessage(content)
} else {
// Not on a location channel yet: queue to show when user switches
pendingGeohashSystemMessages.append(content)
}
}
// Send a public message without adding a local user echo.
// Used for emotes where we want a local system-style confirmation instead.
@MainActor