Skip to content

Commit 54bb812

Browse files
authored
Gate relays on mutual favorites and add Tor toggle (#631)
Co-authored-by: jack <[email protected]>
1 parent 482fca8 commit 54bb812

File tree

7 files changed

+297
-84
lines changed

7 files changed

+297
-84
lines changed

bitchat/Nostr/NostrRelayManager.swift

Lines changed: 121 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@ final class NostrRelayManager: ObservableObject {
3535
"wss://nostr21.com"
3636
// For local testing, you can add: "ws://localhost:8080"
3737
]
38+
private static let defaultRelaySet = Set(defaultRelays)
3839

3940
@Published private(set) var relays: [Relay] = []
4041
@Published private(set) var isConnected = false
4142

43+
private var allowDefaultRelays: Bool = false
4244
private var connections: [String: URLSessionWebSocketTask] = [:]
4345
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
4446
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
@@ -65,6 +67,8 @@ final class NostrRelayManager: ObservableObject {
6567
private let messageQueueLock = NSLock()
6668
private let encoder = JSONEncoder()
6769
private let decoder = JSONDecoder()
70+
private var networkService: NetworkActivationService { NetworkActivationService.shared }
71+
private var shouldUseTor: Bool { networkService.userTorEnabled }
6872

6973
// Exponential backoff configuration
7074
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -78,29 +82,45 @@ final class NostrRelayManager: ObservableObject {
7882
private var connectionGeneration: Int = 0
7983

8084
init() {
81-
// Initialize with default relays
82-
self.relays = Self.defaultRelays.map { Relay(url: $0) }
85+
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
86+
allowDefaultRelays = hasMutual
87+
if hasMutual {
88+
self.relays = Self.defaultRelays.map { Relay(url: $0) }
89+
}
8390
// Deterministic JSON shape for outbound requests
8491
self.encoder.outputFormatting = .sortedKeys
92+
FavoritesPersistenceService.shared.$mutualFavorites
93+
.receive(on: DispatchQueue.main)
94+
.sink { [weak self] favorites in
95+
self?.updateDefaultRelayPolicy(hasMutual: !favorites.isEmpty)
96+
}
97+
.store(in: &cancellables)
8598
}
8699

87100
/// Connect to all configured relays
88101
func connect() {
89102
// Global network policy gate
90-
if !TorManager.shared.isAutoStartAllowed() { return }
91-
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
92-
Task.detached {
93-
let ready = await TorManager.shared.awaitReady()
94-
await MainActor.run {
95-
if !ready {
96-
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
97-
return
98-
}
99-
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
100-
for relay in self.relays {
101-
self.connectToRelay(relay.url)
103+
guard networkService.activationAllowed else { return }
104+
if shouldUseTor {
105+
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
106+
Task.detached {
107+
let ready = await TorManager.shared.awaitReady()
108+
await MainActor.run {
109+
if !ready {
110+
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
111+
return
112+
}
113+
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
114+
for relay in self.relays {
115+
self.connectToRelay(relay.url)
116+
}
102117
}
103118
}
119+
} else {
120+
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
121+
for relay in self.relays {
122+
connectToRelay(relay.url)
123+
}
104124
}
105125
}
106126

@@ -120,8 +140,10 @@ final class NostrRelayManager: ObservableObject {
120140
/// Ensure connections exist to the given relay URLs (idempotent).
121141
func ensureConnections(to relayUrls: [String]) {
122142
// Global network policy gate
123-
if !TorManager.shared.isAutoStartAllowed() { return }
124-
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
143+
guard networkService.activationAllowed else { return }
144+
let targets = allowedRelayList(from: relayUrls)
145+
guard !targets.isEmpty else { return }
146+
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
125147
// Defer until Tor is fully ready; avoid queuing connection attempts early
126148
Task.detached { [weak self] in
127149
guard let self = self else { return }
@@ -130,22 +152,21 @@ final class NostrRelayManager: ObservableObject {
130152
}
131153
return
132154
}
133-
let existing = Set(relays.map { $0.url })
134-
for url in Set(relayUrls) {
135-
if !existing.contains(url) {
136-
relays.append(Relay(url: url))
137-
}
138-
if connections[url] == nil {
139-
connectToRelay(url)
140-
}
155+
var existing = Set(relays.map { $0.url })
156+
for url in targets where !existing.contains(url) {
157+
relays.append(Relay(url: url))
158+
existing.insert(url)
159+
}
160+
for url in targets where connections[url] == nil {
161+
connectToRelay(url)
141162
}
142163
}
143164

144165
/// Send an event to specified relays (or all if none specified)
145166
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
146167
// Global network policy gate
147-
if !TorManager.shared.isAutoStartAllowed() { return }
148-
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
168+
guard networkService.activationAllowed else { return }
169+
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
149170
// Defer sends until Tor is ready to avoid premature queueing
150171
Task.detached { [weak self] in
151172
guard let self = self else { return }
@@ -154,7 +175,9 @@ final class NostrRelayManager: ObservableObject {
154175
}
155176
return
156177
}
157-
let targetRelays = relayUrls ?? Self.defaultRelays
178+
let requestedRelays = relayUrls ?? Self.defaultRelays
179+
let targetRelays = allowedRelayList(from: requestedRelays)
180+
guard !targetRelays.isEmpty else { return }
158181
ensureConnections(to: targetRelays)
159182

160183
// Attempt immediate send to relays with active connections; queue the rest
@@ -220,7 +243,7 @@ final class NostrRelayManager: ObservableObject {
220243
onEOSE: (() -> Void)? = nil
221244
) {
222245
// Global network policy gate
223-
if !TorManager.shared.isAutoStartAllowed() { return }
246+
guard networkService.activationAllowed else { return }
224247
// Coalesce rapid duplicate subscribe requests only if a handler already exists
225248
let now = Date()
226249
if messageHandlers[id] != nil {
@@ -229,7 +252,7 @@ final class NostrRelayManager: ObservableObject {
229252
}
230253
}
231254
subscribeCoalesce[id] = now
232-
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
255+
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
233256
// Defer subscription setup until Tor is ready; avoid queuing subs early
234257
Task.detached { [weak self] in
235258
guard let self = self else { return }
@@ -257,32 +280,37 @@ final class NostrRelayManager: ObservableObject {
257280

258281
// Target specific relays if provided; else default. Filter permanently failed relays.
259282
let baseUrls = relayUrls ?? Self.defaultRelays
260-
let urls = baseUrls.filter { !isPermanentlyFailed($0) }
283+
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
284+
let urls = allowedRelayList(from: candidateUrls)
261285
// Always queue subscriptions; sending happens when a relay reports connected
262286
let existingSet = Set(relays.map { $0.url })
263287
for url in urls where !existingSet.contains(url) {
264288
relays.append(Relay(url: url))
265289
}
266-
for url in urls {
290+
for url in candidateUrls {
267291
var map = self.pendingSubscriptions[url] ?? [:]
268292
map[id] = messageString
269293
self.pendingSubscriptions[url] = map
270294
}
271295
// Initialize EOSE tracking if requested
272296
if let onEOSE = onEOSE {
273-
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
274-
// Fallback timeout to avoid hanging if a relay never sends EOSE
275-
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
276-
Task { @MainActor in
277-
guard let self = self else { return }
278-
if let t = self.eoseTrackers[id] {
279-
t.timer?.invalidate()
280-
self.eoseTrackers.removeValue(forKey: id)
281-
onEOSE()
297+
if urls.isEmpty {
298+
onEOSE()
299+
} else {
300+
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
301+
// Fallback timeout to avoid hanging if a relay never sends EOSE
302+
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
303+
Task { @MainActor in
304+
guard let self = self else { return }
305+
if let t = self.eoseTrackers[id] {
306+
t.timer?.invalidate()
307+
self.eoseTrackers.removeValue(forKey: id)
308+
onEOSE()
309+
}
282310
}
283311
}
312+
eoseTrackers[id] = tracker
284313
}
285-
eoseTrackers[id] = tracker
286314
}
287315
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
288316
// Ensure we actually have sockets opening to these relays so queued REQs can flush
@@ -297,6 +325,54 @@ final class NostrRelayManager: ObservableObject {
297325
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
298326
}
299327
}
328+
329+
private func updateDefaultRelayPolicy(hasMutual: Bool) {
330+
guard hasMutual != allowDefaultRelays else { return }
331+
allowDefaultRelays = hasMutual
332+
if hasMutual {
333+
var existing = Set(relays.map { $0.url })
334+
for url in Self.defaultRelays where !existing.contains(url) {
335+
relays.append(Relay(url: url))
336+
existing.insert(url)
337+
}
338+
if networkService.activationAllowed {
339+
ensureConnections(to: Self.defaultRelays)
340+
}
341+
} else {
342+
for url in Self.defaultRelays {
343+
if let connection = connections[url] {
344+
connection.cancel(with: .goingAway, reason: nil)
345+
}
346+
connections.removeValue(forKey: url)
347+
subscriptions.removeValue(forKey: url)
348+
}
349+
messageQueueLock.lock()
350+
for index in (0..<messageQueue.count).reversed() {
351+
var item = messageQueue[index]
352+
item.pendingRelays.subtract(Self.defaultRelaySet)
353+
if item.pendingRelays.isEmpty {
354+
messageQueue.remove(at: index)
355+
} else {
356+
messageQueue[index] = item
357+
}
358+
}
359+
messageQueueLock.unlock()
360+
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
361+
updateConnectionStatus()
362+
}
363+
}
364+
365+
private func allowedRelayList(from urls: [String]) -> [String] {
366+
var seen = Set<String>()
367+
var result: [String] = []
368+
for url in urls {
369+
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
370+
if seen.insert(url).inserted {
371+
result.append(url)
372+
}
373+
}
374+
return result
375+
}
300376

301377
/// Unsubscribe from a subscription
302378
func unsubscribe(id: String) {
@@ -326,14 +402,14 @@ final class NostrRelayManager: ObservableObject {
326402

327403
private func connectToRelay(_ urlString: String) {
328404
// Global network policy gate
329-
if !TorManager.shared.isAutoStartAllowed() { return }
405+
guard networkService.activationAllowed else { return }
330406
guard let url = URL(string: urlString) else {
331407
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
332408
return
333409
}
334410

335411
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
336-
if TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
412+
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
337413
return
338414
}
339415

@@ -348,7 +424,7 @@ final class NostrRelayManager: ObservableObject {
348424
// Attempting to connect to Nostr relay via the proxied session
349425

350426
// If Tor is enforced but not ready, delay connection until it is.
351-
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
427+
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
352428
Task.detached { [weak self] in
353429
guard let self = self else { return }
354430
let ready = await TorManager.shared.awaitReady()
@@ -534,7 +610,7 @@ final class NostrRelayManager: ObservableObject {
534610

535611
private func handleDisconnection(relayUrl: String, error: Error) {
536612
// If networking is disallowed, do not schedule reconnection
537-
if !TorManager.shared.isAutoStartAllowed() {
613+
if !networkService.activationAllowed {
538614
connections.removeValue(forKey: relayUrl)
539615
subscriptions.removeValue(forKey: relayUrl)
540616
updateRelayStatus(relayUrl, isConnected: false, error: error)

0 commit comments

Comments
 (0)