mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 16:05:19 +00:00
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
This commit is contained in:
@@ -43,6 +43,8 @@ struct BitchatApp: App {
|
|||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
#endif
|
#endif
|
||||||
|
// Spin up Tor early; all internet will gate on Tor 100%
|
||||||
|
TorManager.shared.startIfNeeded()
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
@@ -54,10 +56,18 @@ struct BitchatApp: App {
|
|||||||
switch newPhase {
|
switch newPhase {
|
||||||
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
|
||||||
|
TorManager.shared.goDormantOnBackground()
|
||||||
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.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()
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
case .inactive:
|
case .inactive:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -24,11 +24,16 @@ final class TorManager: ObservableObject {
|
|||||||
let controlPort: Int = 39051
|
let controlPort: Int = 39051
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
// True only when SOCKS is reachable AND bootstrap has reached 100%.
|
||||||
@Published private(set) var isReady: Bool = false
|
@Published private(set) var isReady: Bool = false
|
||||||
@Published private(set) var isStarting: Bool = false
|
@Published private(set) var isStarting: Bool = false
|
||||||
@Published private(set) var lastError: Error?
|
@Published private(set) var lastError: Error?
|
||||||
@Published private(set) var bootstrapProgress: Int = 0
|
@Published private(set) var bootstrapProgress: Int = 0
|
||||||
@Published private(set) var bootstrapSummary: String = ""
|
@Published private(set) var bootstrapSummary: String = ""
|
||||||
|
|
||||||
|
// Internal readiness trackers
|
||||||
|
private var socksReady: Bool = false { didSet { recomputeReady() } }
|
||||||
|
private var restarting: Bool = false
|
||||||
|
|
||||||
// Whether the app must enforce Tor for all connections (fail-closed).
|
// Whether the app must enforce Tor for all connections (fail-closed).
|
||||||
// This is the default. For local development, you may compile with
|
// This is the default. For local development, you may compile with
|
||||||
@@ -50,6 +55,7 @@ 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 init() {}
|
private init() {}
|
||||||
|
|
||||||
@@ -62,6 +68,7 @@ final class TorManager: ObservableObject {
|
|||||||
lastError = nil
|
lastError = nil
|
||||||
ensureFilesystemLayout()
|
ensureFilesystemLayout()
|
||||||
startTor()
|
startTor()
|
||||||
|
startPathMonitorIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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).
|
||||||
@@ -257,20 +264,18 @@ final class TorManager: ObservableObject {
|
|||||||
|
|
||||||
// Start control-port monitor and probe readiness asynchronously
|
// Start control-port monitor and probe readiness asynchronously
|
||||||
startControlMonitorIfNeeded()
|
startControlMonitorIfNeeded()
|
||||||
// Start control-port monitor and probe readiness asynchronously
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
startControlMonitorIfNeeded()
|
|
||||||
Task.detached(priority: .utility) { [weak self] in
|
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let ready = await self.waitForSocksReady(timeout: 60.0)
|
let ready = await self.waitForSocksReady(timeout: 60.0)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isReady = ready
|
self.socksReady = ready
|
||||||
self.isStarting = false
|
|
||||||
if !ready {
|
if !ready {
|
||||||
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"])
|
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"])
|
||||||
SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error)
|
SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info)
|
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info)
|
||||||
}
|
}
|
||||||
|
// isStarting will be cleared when bootstrap reaches 100%
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,18 +335,18 @@ final class TorManager: ObservableObject {
|
|||||||
|
|
||||||
// Start control monitor early
|
// Start control monitor early
|
||||||
startControlMonitorIfNeeded()
|
startControlMonitorIfNeeded()
|
||||||
Task.detached(priority: .utility) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let ready = await self.waitForSocksReady(timeout: 60.0)
|
let ready = await self.waitForSocksReady(timeout: 60.0)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isReady = ready
|
self.socksReady = ready
|
||||||
self.isStarting = false
|
|
||||||
if ready {
|
if ready {
|
||||||
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info)
|
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info)
|
||||||
} else {
|
} else {
|
||||||
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
|
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
|
||||||
SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error)
|
SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error)
|
||||||
}
|
}
|
||||||
|
// isStarting will be cleared when bootstrap reaches 100%
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -349,102 +354,170 @@ final class TorManager: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - ControlPort monitoring (bootstrap progress)
|
// MARK: - ControlPort monitoring (bootstrap progress)
|
||||||
private func startControlMonitorIfNeeded() {
|
private func startControlMonitorIfNeeded() {
|
||||||
#if os(iOS)
|
|
||||||
// iOS: no-op; we skip ControlPort monitoring to keep startup lean.
|
|
||||||
return
|
|
||||||
#else
|
|
||||||
guard !controlMonitorStarted else { return }
|
guard !controlMonitorStarted else { return }
|
||||||
controlMonitorStarted = true
|
controlMonitorStarted = true
|
||||||
Task.detached(priority: .background) { [weak self] in
|
// Use a simple GETINFO poll on all platforms to avoid long-lived blocking streams
|
||||||
guard let self else { return }
|
Task.detached(priority: .utility) { [weak self] in
|
||||||
await self.controlMonitorLoop()
|
await self?.bootstrapPollLoop()
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func controlMonitorLoop() async {
|
private func controlMonitorLoop() async {}
|
||||||
|
|
||||||
|
private func tryControlSessionOnce() async -> Bool { false }
|
||||||
|
|
||||||
|
// iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers.
|
||||||
|
private func bootstrapPollLoop() async {
|
||||||
let deadline = Date().addingTimeInterval(75)
|
let deadline = Date().addingTimeInterval(75)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if await self.tryControlSessionOnce() { return }
|
if let info = await controlGetBootstrapInfo() {
|
||||||
|
await MainActor.run {
|
||||||
|
self.bootstrapProgress = info.progress
|
||||||
|
self.bootstrapSummary = info.summary
|
||||||
|
if info.progress >= 100 { self.isStarting = false }
|
||||||
|
self.recomputeReady()
|
||||||
|
}
|
||||||
|
if info.progress >= 100 { break }
|
||||||
|
}
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tryControlSessionOnce() async -> Bool {
|
private func controlGetBootstrapInfo() async -> (progress: Int, summary: String)? {
|
||||||
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie") else { return false }
|
guard let text = await controlExchange(lines: ["GETINFO status/bootstrap-phase"], timeout: 2.0) else { return nil }
|
||||||
guard let cookie = try? Data(contentsOf: cookiePath) else { return false }
|
var progress = self.bootstrapProgress
|
||||||
|
var summary = self.bootstrapSummary
|
||||||
|
// Search entire response for PROGRESS and SUMMARY tokens
|
||||||
|
// Typical: "250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=75 TAG=... SUMMARY=\"...\"\r\n250 OK\r\n"
|
||||||
|
let tokens = text.replacingOccurrences(of: "\r", with: " ").replacingOccurrences(of: "\n", with: " ").split(separator: " ")
|
||||||
|
for t in tokens {
|
||||||
|
if t.hasPrefix("PROGRESS=") {
|
||||||
|
progress = Int(t.split(separator: "=").last ?? "0") ?? progress
|
||||||
|
} else if t.hasPrefix("SUMMARY=") {
|
||||||
|
let raw = String(t.dropFirst("SUMMARY=".count))
|
||||||
|
summary = raw.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (progress, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Foreground recovery and control helpers
|
||||||
|
|
||||||
|
func ensureRunningOnForeground() {
|
||||||
|
// If we can talk to ControlPort, wake Tor and verify bootstrap; else restart.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// 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 }
|
||||||
|
await self.restartTor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func goDormantOnBackground() {
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
_ = await self?.controlSendSignal("DORMANT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPathMonitorIfNeeded() {
|
||||||
|
guard pathMonitor == nil else { return }
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
pathMonitor = monitor
|
||||||
|
let queue = DispatchQueue(label: "TorPathMonitor")
|
||||||
|
monitor.pathUpdateHandler = { [weak self] _ in
|
||||||
|
// On any path change, poke Tor/recover (hop to main actor).
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.ensureRunningOnForeground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
guard let text = data else { return false }
|
||||||
|
return text.contains("status/bootstrap-phase")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func controlSendSignal(_ signal: String, timeout: TimeInterval = 3.0) async -> Bool {
|
||||||
|
let text = await controlExchange(lines: ["SIGNAL \(signal)"], timeout: timeout)
|
||||||
|
return (text?.contains("250")) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? {
|
||||||
|
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"),
|
||||||
|
let cookie = try? Data(contentsOf: cookiePath) else { return nil }
|
||||||
let cookieHex = cookie.map { String(format: "%02X", $0) }.joined()
|
let cookieHex = cookie.map { String(format: "%02X", $0) }.joined()
|
||||||
|
|
||||||
var inStream: InputStream?
|
let queue = DispatchQueue(label: "TorControl", qos: .userInitiated)
|
||||||
var outStream: OutputStream?
|
let params = NWParameters.tcp
|
||||||
Stream.getStreamsToHost(withName: controlHost, port: controlPort, inputStream: &inStream, outputStream: &outStream)
|
guard let port = NWEndpoint.Port(rawValue: UInt16(controlPort)) else { return nil }
|
||||||
guard let input = inStream, let output = outStream else { return false }
|
let endpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: port)
|
||||||
input.open(); output.open()
|
let conn = NWConnection(to: endpoint, using: params)
|
||||||
|
|
||||||
func send(_ s: String) {
|
var resultText = ""
|
||||||
let bytes = Array(s.utf8)
|
var completed = false
|
||||||
_ = bytes.withUnsafeBytes { raw -> Int in
|
func send(_ text: String) {
|
||||||
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return 0 }
|
let data = (text + "\r\n").data(using: .utf8) ?? Data()
|
||||||
return output.write(base, maxLength: bytes.count)
|
conn.send(content: data, completion: .contentProcessed { _ in })
|
||||||
}
|
|
||||||
}
|
}
|
||||||
func readLine(timeout: TimeInterval = 3.0) -> String? {
|
func receiveLoop(deadline: Date) async {
|
||||||
var data = Data()
|
while Date() < deadline {
|
||||||
let start = Date()
|
let ok: Bool = await withCheckedContinuation { cont in
|
||||||
var buf = [UInt8](repeating: 0, count: 1024)
|
conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, isComplete, error in
|
||||||
while Date().timeIntervalSince(start) < timeout {
|
if let data = data, !data.isEmpty, let s = String(data: data, encoding: .utf8) {
|
||||||
if input.hasBytesAvailable {
|
resultText.append(s)
|
||||||
let n = input.read(&buf, maxLength: buf.count)
|
|
||||||
if n > 0 {
|
|
||||||
data.append(buf, count: n)
|
|
||||||
if let range = data.range(of: Data([13,10])) { // CRLF
|
|
||||||
let lineData = data.prefix(upTo: range.lowerBound)
|
|
||||||
let line = String(data: lineData, encoding: .utf8)
|
|
||||||
let rest = data.suffix(from: range.upperBound)
|
|
||||||
data = Data(rest)
|
|
||||||
return line
|
|
||||||
}
|
}
|
||||||
} else if n == 0 {
|
if isComplete || error != nil { completed = true }
|
||||||
break
|
cont.resume(returning: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
usleep(20_000)
|
if !ok || completed { break }
|
||||||
}
|
// Small delay to avoid tight loop
|
||||||
return nil
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
}
|
|
||||||
|
|
||||||
// Greeting
|
|
||||||
_ = readLine()
|
|
||||||
send("AUTHENTICATE \(cookieHex)\r\n")
|
|
||||||
guard let auth = readLine(), auth.hasPrefix("250") else {
|
|
||||||
input.close(); output.close(); return false
|
|
||||||
}
|
|
||||||
send("SETEVENTS STATUS_CLIENT\r\n")
|
|
||||||
_ = readLine() // 250 OK
|
|
||||||
|
|
||||||
while true {
|
|
||||||
if Task.isCancelled { break }
|
|
||||||
guard let line = readLine(timeout: 10.0) else { continue }
|
|
||||||
if line.hasPrefix("650 ") && line.contains("BOOTSTRAP") {
|
|
||||||
var progress = self.bootstrapProgress
|
|
||||||
var summary = self.bootstrapSummary
|
|
||||||
for part in line.split(separator: " ") {
|
|
||||||
if part.hasPrefix("PROGRESS=") {
|
|
||||||
progress = Int(part.split(separator: "=").last ?? "0") ?? progress
|
|
||||||
} else if part.hasPrefix("SUMMARY=") {
|
|
||||||
let raw = String(part.dropFirst("SUMMARY=".count))
|
|
||||||
summary = raw.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await MainActor.run {
|
|
||||||
self.bootstrapProgress = progress
|
|
||||||
self.bootstrapSummary = summary
|
|
||||||
}
|
|
||||||
if progress >= 100 { break }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input.close(); output.close()
|
conn.start(queue: queue)
|
||||||
return true
|
// Send immediately; NWConnection will queue until ready
|
||||||
|
send("AUTHENTICATE \(cookieHex)")
|
||||||
|
// Send requested lines
|
||||||
|
for line in lines { send(line) }
|
||||||
|
// Ask tor to close
|
||||||
|
send("QUIT")
|
||||||
|
await receiveLoop(deadline: Date().addingTimeInterval(timeout))
|
||||||
|
conn.cancel()
|
||||||
|
return resultText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,38 @@ final class TorURLSession {
|
|||||||
static let shared = TorURLSession()
|
static let shared = TorURLSession()
|
||||||
|
|
||||||
// Default (no proxy) session for local development when dev bypass is enabled.
|
// Default (no proxy) session for local development when dev bypass is enabled.
|
||||||
private lazy var defaultSession: URLSession = {
|
private var defaultSession: URLSession = {
|
||||||
let cfg = URLSessionConfiguration.default
|
let cfg = URLSessionConfiguration.default
|
||||||
cfg.waitsForConnectivity = true
|
cfg.waitsForConnectivity = true
|
||||||
return URLSession(configuration: cfg)
|
return URLSession(configuration: cfg)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Proxied (SOCKS5) session that routes through Tor.
|
// Proxied (SOCKS5) session that routes through Tor.
|
||||||
private lazy var torSession: URLSession = {
|
private var torSession: URLSession = TorURLSession.makeTorSession()
|
||||||
|
|
||||||
|
var session: URLSession {
|
||||||
|
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||||
|
// Dev bypass: use direct session. Call sites may still await Tor if desired.
|
||||||
|
return defaultSession
|
||||||
|
#else
|
||||||
|
// Production: always use the Tor-proxied session. Call sites ensure readiness.
|
||||||
|
return torSession
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
|
||||||
|
func rebuild() {
|
||||||
|
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||||
|
defaultSession = {
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
cfg.waitsForConnectivity = true
|
||||||
|
return URLSession(configuration: cfg)
|
||||||
|
}()
|
||||||
|
#endif
|
||||||
|
torSession = TorURLSession.makeTorSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeTorSession() -> URLSession {
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
let cfg = URLSessionConfiguration.ephemeral
|
||||||
cfg.waitsForConnectivity = true
|
cfg.waitsForConnectivity = true
|
||||||
// Keep in sync with TorManager defaults
|
// Keep in sync with TorManager defaults
|
||||||
@@ -31,10 +55,6 @@ final class TorURLSession {
|
|||||||
]
|
]
|
||||||
#else
|
#else
|
||||||
// iOS: CFNetwork SOCKS proxy keys are unavailable at compile time.
|
// iOS: CFNetwork SOCKS proxy keys are unavailable at compile time.
|
||||||
// Using the documented string keys keeps the build green. On some iOS
|
|
||||||
// versions, URLSession may ignore per-session SOCKS; we still enforce
|
|
||||||
// Tor via fail-closed gating and can add platform-specific transport
|
|
||||||
// if needed.
|
|
||||||
cfg.connectionProxyDictionary = [
|
cfg.connectionProxyDictionary = [
|
||||||
"SOCKSEnable": 1,
|
"SOCKSEnable": 1,
|
||||||
"SOCKSProxy": host,
|
"SOCKSProxy": host,
|
||||||
@@ -42,15 +62,5 @@ final class TorURLSession {
|
|||||||
]
|
]
|
||||||
#endif
|
#endif
|
||||||
return URLSession(configuration: cfg)
|
return URLSession(configuration: cfg)
|
||||||
}()
|
|
||||||
|
|
||||||
var session: URLSession {
|
|
||||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
|
||||||
// Dev bypass: use direct session. Call sites may still await Tor if desired.
|
|
||||||
return defaultSession
|
|
||||||
#else
|
|
||||||
// Production: always use the Tor-proxied session. Call sites ensure readiness.
|
|
||||||
return torSession
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -533,30 +533,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Announce Tor status to the chat timeline as early as possible
|
// Announce Tor status to the chat timeline as early as possible
|
||||||
if TorManager.shared.torEnforced && !torStatusAnnounced {
|
if TorManager.shared.torEnforced && !torStatusAnnounced {
|
||||||
torStatusAnnounced = true
|
torStatusAnnounced = true
|
||||||
addPublicSystemMessage("Starting Tor…")
|
addPublicSystemMessage("starting tor...")
|
||||||
torProgressCancellable = TorManager.shared.$bootstrapProgress
|
// Suppress incremental Tor progress messages; only show initial start and readiness.
|
||||||
.receive(on: DispatchQueue.main)
|
torProgressCancellable = nil
|
||||||
.sink { [weak self] p in
|
Task.detached {
|
||||||
guard let self = self else { return }
|
|
||||||
if p == 0 { return }
|
|
||||||
if p == 100 || p / 10 > self.lastTorProgressAnnounced / 10 {
|
|
||||||
self.lastTorProgressAnnounced = p
|
|
||||||
let summary = TorManager.shared.bootstrapSummary
|
|
||||||
let msg = summary.isEmpty ? "Tor \(p)%" : "Tor \(p)% – \(summary)"
|
|
||||||
self.addPublicSystemMessage(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Task.detached { [weak self] in
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await TorManager.shared.awaitReady()
|
||||||
await MainActor.run {
|
Task { @MainActor [weak self] in
|
||||||
guard let strongSelf = self else { return }
|
guard let self else { return }
|
||||||
if ready { strongSelf.addPublicSystemMessage("Tor ready. Routing all connections via Tor.") }
|
if ready { self.addPublicSystemMessage("tor started. routing all chats via tor for privacy.") }
|
||||||
else { strongSelf.addPublicSystemMessage("Still waiting for Tor to start…") }
|
else { self.addPublicSystemMessage("still waiting for tor to start...") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
||||||
torStatusAnnounced = true
|
torStatusAnnounced = true
|
||||||
addPublicSystemMessage("Development build: Tor bypass enabled.")
|
addPublicSystemMessage("development build: tor bypass enabled.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Nostr services
|
// Initialize Nostr services
|
||||||
@@ -4906,10 +4896,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
messages.append(systemMessage)
|
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
|
@MainActor
|
||||||
func addPublicSystemMessage(_ content: String) {
|
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()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
// Send a public message without adding a local user echo.
|
// Send a public message without adding a local user echo.
|
||||||
|
|||||||
Reference in New Issue
Block a user