mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +00:00
* Fix launch crash: recursive dispatch_once between NostrRelayManager and NetworkActivationService NostrRelayManager.init() runs applyDefaultRelayPolicy(force: true), which calls dependencies.activationAllowed() when the user has location permission or a mutual favorite. That closure resolves NetworkActivationService.shared, whose init captured NostrRelayManager.shared — re-entering the still-running dispatch_once on the same thread. libdispatch traps on recursive dispatch_once (EXC_BREAKPOINT in _dispatch_once_wait), killing the app ~50ms after launch, before the first frame. Fresh installs were unaffected (no permission, no favorites, so the policy path never touched NetworkActivationService during init), which is why this passed local testing but crashed established TestFlight users on every launch. Two independent TestFlight crash reports on 1.5.2 (1) show the identical stack. Break the cycle by resolving the relay controller lazily: store a provider closure in init and dereference NostrRelayManager.shared on first use (start()/reevaluate()), after both singletons have finished initializing. The injectable test initializer keeps its signature. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bump version to 1.5.3 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
181 lines
6.8 KiB
Swift
181 lines
6.8 KiB
Swift
import Foundation
|
|
import BitLogger
|
|
import Combine
|
|
import Tor
|
|
|
|
@MainActor
|
|
protocol NetworkActivationTorControlling: AnyObject {
|
|
func setAutoStartAllowed(_ allowed: Bool)
|
|
func startIfNeeded()
|
|
func shutdownCompletely()
|
|
}
|
|
|
|
@MainActor
|
|
protocol NetworkActivationRelayControlling: AnyObject {
|
|
func connect()
|
|
func disconnect()
|
|
}
|
|
|
|
protocol NetworkActivationProxyControlling: AnyObject {
|
|
func setProxyMode(useTor: Bool)
|
|
}
|
|
|
|
extension TorManager: NetworkActivationTorControlling {}
|
|
extension NostrRelayManager: NetworkActivationRelayControlling {}
|
|
extension TorURLSession: NetworkActivationProxyControlling {}
|
|
|
|
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
|
/// Policy: permit start when either location permissions are authorized OR
|
|
/// there exists at least one mutual favorite. Otherwise, do not start.
|
|
@MainActor
|
|
final class NetworkActivationService: ObservableObject {
|
|
static let shared = NetworkActivationService()
|
|
|
|
@Published private(set) var activationAllowed: Bool = false
|
|
@Published private(set) var userTorEnabled: Bool = true
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private var started = false
|
|
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
|
private var torAutoStartDesired: Bool = false
|
|
private let storage: UserDefaults
|
|
private let locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
|
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
|
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
|
private let mutualFavoritesProvider: () -> Set<Data>
|
|
private let torController: NetworkActivationTorControlling
|
|
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
|
|
// (via its live dependencies), so capturing NostrRelayManager.shared here would
|
|
// re-enter whichever singleton's dispatch_once started first and trap at launch.
|
|
private lazy var relayController: NetworkActivationRelayControlling = relayControllerProvider()
|
|
private let relayControllerProvider: () -> NetworkActivationRelayControlling
|
|
private let proxyController: NetworkActivationProxyControlling
|
|
private let notificationCenter: NotificationCenter
|
|
|
|
private init() {
|
|
storage = .standard
|
|
locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()
|
|
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
|
|
permissionProvider = { LocationChannelManager.shared.permissionState }
|
|
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
|
torController = TorManager.shared
|
|
relayControllerProvider = { NostrRelayManager.shared }
|
|
proxyController = TorURLSession.shared
|
|
notificationCenter = .default
|
|
}
|
|
|
|
internal init(
|
|
storage: UserDefaults,
|
|
locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>,
|
|
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
|
|
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
|
|
mutualFavoritesProvider: @escaping () -> Set<Data>,
|
|
torController: NetworkActivationTorControlling,
|
|
relayController: NetworkActivationRelayControlling,
|
|
proxyController: NetworkActivationProxyControlling,
|
|
notificationCenter: NotificationCenter = .default
|
|
) {
|
|
self.storage = storage
|
|
self.locationPermissionPublisher = locationPermissionPublisher
|
|
self.mutualFavoritesPublisher = mutualFavoritesPublisher
|
|
self.permissionProvider = permissionProvider
|
|
self.mutualFavoritesProvider = mutualFavoritesProvider
|
|
self.torController = torController
|
|
self.relayControllerProvider = { relayController }
|
|
self.proxyController = proxyController
|
|
self.notificationCenter = notificationCenter
|
|
}
|
|
|
|
func start() {
|
|
guard !started else { return }
|
|
started = true
|
|
|
|
if let stored = storage.object(forKey: torPreferenceKey) as? Bool {
|
|
userTorEnabled = stored
|
|
} else {
|
|
userTorEnabled = true
|
|
}
|
|
|
|
// Initial compute
|
|
let allowed = basePolicyAllowed()
|
|
activationAllowed = allowed
|
|
torAutoStartDesired = allowed && userTorEnabled
|
|
torController.setAutoStartAllowed(torAutoStartDesired)
|
|
applyTorState(torDesired: torAutoStartDesired)
|
|
if allowed {
|
|
relayController.connect()
|
|
} else {
|
|
relayController.disconnect()
|
|
}
|
|
|
|
// React to location permission changes
|
|
locationPermissionPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.reevaluate()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
// React to mutual favorites changes
|
|
mutualFavoritesPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.reevaluate()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
func setUserTorEnabled(_ enabled: Bool) {
|
|
guard enabled != userTorEnabled else { return }
|
|
userTorEnabled = enabled
|
|
storage.set(enabled, forKey: torPreferenceKey)
|
|
notificationCenter.post(
|
|
name: .TorUserPreferenceChanged,
|
|
object: nil,
|
|
userInfo: ["enabled": enabled]
|
|
)
|
|
reevaluate()
|
|
}
|
|
|
|
private func reevaluate() {
|
|
let allowed = basePolicyAllowed()
|
|
let torDesired = allowed && userTorEnabled
|
|
let statusChanged = allowed != activationAllowed
|
|
let torChanged = torDesired != torAutoStartDesired
|
|
if statusChanged {
|
|
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
|
|
activationAllowed = allowed
|
|
}
|
|
if statusChanged || torChanged {
|
|
torAutoStartDesired = torDesired
|
|
torController.setAutoStartAllowed(torDesired)
|
|
applyTorState(torDesired: torDesired)
|
|
}
|
|
|
|
if allowed {
|
|
if torChanged {
|
|
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
|
relayController.disconnect()
|
|
}
|
|
relayController.connect()
|
|
} else if statusChanged {
|
|
relayController.disconnect()
|
|
}
|
|
}
|
|
|
|
private func basePolicyAllowed() -> Bool {
|
|
let permOK = permissionProvider() == .authorized
|
|
let hasMutual = !mutualFavoritesProvider().isEmpty
|
|
return permOK || hasMutual
|
|
}
|
|
|
|
private func applyTorState(torDesired: Bool) {
|
|
proxyController.setProxyMode(useTor: torDesired)
|
|
if torDesired {
|
|
torController.startIfNeeded()
|
|
} else {
|
|
torController.shutdownCompletely()
|
|
}
|
|
}
|
|
}
|