mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 21:05:20 +00:00
* Make location notes robust: durable relay subscriptions, failure decay, auto-recovery Location notes (and geohash chat / DMs) intermittently stopped showing events because the Nostr relay layer lost subscriptions and blacklisted relays: - Replay active subscriptions on every relay (re)connect. Relays drop REQs with the socket; previously a drop silently killed the subscription on that relay for the rest of the session. Durable subscription intent now also survives disconnect()/resetAllConnections (background -> foreground). - Keep failed REQ sends queued instead of dropping them. - Raise the EOSE fallback from a fixed 2s Timer to a 10s injected schedule (Tor needs more than 2s), and settle EOSE trackers when a relay disconnects before answering so initial load doesn't stall. - Decay "permanently failed" relay markings after a 10-minute cooldown; previously ~9 minutes of outage (or one DNS hiccup) excluded a relay until app restart, with nothing resetting it on macOS. - Make geo relay selection deterministic (distance, then host) so publishers and subscribers with the same directory agree on relays. - Post .geoRelayDirectoryDidRefresh after a directory fetch and let LocationNotesManager auto-resubscribe out of the "no relays" state instead of requiring a manual retry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Guard subscription activation on connection identity A REQ send completion from a dead socket could land after handleDisconnection cleared the relay's subscriptions and re-mark the subscription active, making the next connection skip the durable replay and leave that relay silent. Only mark a subscription active if the completing socket is still the relay's live connection. Regression test defers send completions in the mock so the stale completion deterministically interleaves between disconnect and reconnect. Addresses Codex review on #1333. 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>
441 lines
16 KiB
Swift
441 lines
16 KiB
Swift
import BitLogger
|
|
import Foundation
|
|
import Tor
|
|
#if os(iOS)
|
|
import UIKit
|
|
#elseif os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
extension Notification.Name {
|
|
/// Posted after the geo relay directory successfully refreshes its entries.
|
|
static let geoRelayDirectoryDidRefresh = Notification.Name("bitchat.geoRelayDirectoryDidRefresh")
|
|
}
|
|
|
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
|
struct GeoRelayDirectoryDependencies {
|
|
var userDefaults: UserDefaults
|
|
var notificationCenter: NotificationCenter
|
|
var now: () -> Date
|
|
var remoteURL: URL
|
|
var fetchInterval: TimeInterval
|
|
var refreshCheckInterval: TimeInterval
|
|
var retryInitialSeconds: TimeInterval
|
|
var retryMaxSeconds: TimeInterval
|
|
var awaitTorReady: @Sendable () async -> Bool
|
|
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
|
|
var readData: (URL) -> Data?
|
|
var writeData: (Data, URL) throws -> Void
|
|
var cacheURL: () -> URL?
|
|
var bundledCSVURLs: () -> [URL]
|
|
var currentDirectoryPath: () -> String?
|
|
var retrySleep: (TimeInterval) async -> Void
|
|
var activeNotificationName: Notification.Name?
|
|
var autoStart: Bool
|
|
}
|
|
|
|
private extension GeoRelayDirectoryDependencies {
|
|
@MainActor
|
|
static func live() -> Self {
|
|
#if os(iOS)
|
|
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
|
#elseif os(macOS)
|
|
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
|
#else
|
|
let activeNotificationName: Notification.Name? = nil
|
|
#endif
|
|
|
|
return Self(
|
|
userDefaults: .standard,
|
|
notificationCenter: .default,
|
|
now: Date.init,
|
|
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
|
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
|
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
|
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
|
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
|
awaitTorReady: { await TorManager.shared.awaitReady() },
|
|
makeFetchData: {
|
|
let session = TorURLSession.shared.session
|
|
return { request in
|
|
let (data, _) = try await session.data(for: request)
|
|
return data
|
|
}
|
|
},
|
|
readData: { try? Data(contentsOf: $0) },
|
|
writeData: { data, url in
|
|
try data.write(to: url, options: .atomic)
|
|
},
|
|
cacheURL: {
|
|
do {
|
|
let base = try FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
)
|
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir.appendingPathComponent("georelays_cache.csv")
|
|
} catch {
|
|
return nil
|
|
}
|
|
},
|
|
bundledCSVURLs: {
|
|
[
|
|
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
|
].compactMap { $0 }
|
|
},
|
|
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
|
|
retrySleep: { delay in
|
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
},
|
|
activeNotificationName: activeNotificationName,
|
|
autoStart: true
|
|
)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class GeoRelayDirectory {
|
|
private final class CleanupState {
|
|
let notificationCenter: NotificationCenter
|
|
var observers: [NSObjectProtocol] = []
|
|
var refreshTimer: Timer?
|
|
var retryTask: Task<Void, Never>?
|
|
|
|
init(notificationCenter: NotificationCenter) {
|
|
self.notificationCenter = notificationCenter
|
|
}
|
|
|
|
deinit {
|
|
observers.forEach { notificationCenter.removeObserver($0) }
|
|
refreshTimer?.invalidate()
|
|
retryTask?.cancel()
|
|
}
|
|
}
|
|
|
|
struct Entry: Hashable, Sendable {
|
|
let host: String
|
|
let lat: Double
|
|
let lon: Double
|
|
}
|
|
|
|
private enum DetachedFetchOutcome: Sendable {
|
|
case success(entries: [Entry], csv: String)
|
|
case torNotReady
|
|
case invalidData
|
|
case network(String)
|
|
}
|
|
|
|
static let shared = GeoRelayDirectory()
|
|
|
|
private(set) var entries: [Entry] = []
|
|
private let lastFetchKey = "georelay.lastFetchAt"
|
|
private let dependencies: GeoRelayDirectoryDependencies
|
|
private let cleanupState: CleanupState
|
|
|
|
private var retryAttempt: Int = 0
|
|
private var isFetching: Bool = false
|
|
|
|
private init() {
|
|
self.dependencies = .live()
|
|
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
|
entries = loadLocalEntries()
|
|
if dependencies.autoStart {
|
|
registerObservers()
|
|
startRefreshTimer()
|
|
prefetchIfNeeded()
|
|
}
|
|
}
|
|
|
|
internal init(dependencies: GeoRelayDirectoryDependencies) {
|
|
self.dependencies = dependencies
|
|
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
|
entries = loadLocalEntries()
|
|
if dependencies.autoStart {
|
|
registerObservers()
|
|
startRefreshTimer()
|
|
prefetchIfNeeded()
|
|
}
|
|
}
|
|
|
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
|
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
|
let center = Geohash.decodeCenter(geohash)
|
|
return closestRelays(toLat: center.lat, lon: center.lon, count: count)
|
|
}
|
|
|
|
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
|
/// Ties break by host so every device with the same directory picks the
|
|
/// same relay set — publishers and subscribers must agree on relays.
|
|
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
|
guard !entries.isEmpty, count > 0 else { return [] }
|
|
|
|
return entries
|
|
.map { (entry: $0, distance: haversineKm(lat, lon, $0.lat, $0.lon)) }
|
|
.sorted { ($0.distance, $0.entry.host) < ($1.distance, $1.entry.host) }
|
|
.prefix(count)
|
|
.map { "wss://\($0.entry.host)" }
|
|
}
|
|
|
|
// MARK: - Remote Fetch
|
|
func prefetchIfNeeded(force: Bool = false) {
|
|
guard !isFetching else { return }
|
|
|
|
let now = dependencies.now()
|
|
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
|
|
|
if !force {
|
|
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
|
} else if last != .distantPast,
|
|
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
|
// Skip forced fetches if we just refreshed moments ago.
|
|
return
|
|
}
|
|
|
|
cancelRetry()
|
|
fetchRemote()
|
|
}
|
|
|
|
private func fetchRemote() {
|
|
guard !isFetching else { return }
|
|
isFetching = true
|
|
|
|
let request = URLRequest(
|
|
url: dependencies.remoteURL,
|
|
cachePolicy: .reloadIgnoringLocalCacheData,
|
|
timeoutInterval: 15
|
|
)
|
|
let awaitTorReady = dependencies.awaitTorReady
|
|
let fetchData = dependencies.makeFetchData()
|
|
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
|
|
let outcome = await Self.fetchRemoteOutcome(
|
|
request: request,
|
|
awaitTorReady: awaitTorReady,
|
|
fetchData: fetchData
|
|
)
|
|
|
|
switch outcome {
|
|
case .success(let parsed, let csv):
|
|
self.handleFetchSuccess(entries: parsed, csv: csv)
|
|
case .torNotReady:
|
|
self.handleFetchFailure(.torNotReady)
|
|
case .invalidData:
|
|
self.handleFetchFailure(.invalidData)
|
|
case .network(let description):
|
|
self.handleFetchFailure(.network(description))
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated private static func fetchRemoteOutcome(
|
|
request: URLRequest,
|
|
awaitTorReady: @escaping @Sendable () async -> Bool,
|
|
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
|
) async -> DetachedFetchOutcome {
|
|
await Task.detached(priority: .utility) {
|
|
let ready = await awaitTorReady()
|
|
guard ready else { return .torNotReady }
|
|
|
|
do {
|
|
let data = try await fetchData(request)
|
|
guard let text = String(data: data, encoding: .utf8) else {
|
|
return .invalidData
|
|
}
|
|
|
|
let parsed = Self.parseCSV(text)
|
|
guard !parsed.isEmpty else {
|
|
return .invalidData
|
|
}
|
|
|
|
return .success(entries: parsed, csv: text)
|
|
} catch {
|
|
return .network(error.localizedDescription)
|
|
}
|
|
}.value
|
|
}
|
|
|
|
private enum FetchFailure {
|
|
case torNotReady
|
|
case invalidData
|
|
case network(String)
|
|
}
|
|
|
|
@MainActor
|
|
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
|
entries = parsed
|
|
persistCache(csv)
|
|
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
|
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
|
isFetching = false
|
|
retryAttempt = 0
|
|
cancelRetry()
|
|
// Let waiters (e.g. location notes stuck in a "no relays" state) retry.
|
|
dependencies.notificationCenter.post(name: .geoRelayDirectoryDidRefresh, object: nil)
|
|
}
|
|
|
|
@MainActor
|
|
private func handleFetchFailure(_ reason: FetchFailure) {
|
|
switch reason {
|
|
case .torNotReady:
|
|
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
|
case .invalidData:
|
|
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
|
case .network(let errorDescription):
|
|
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
|
|
}
|
|
isFetching = false
|
|
scheduleRetry()
|
|
}
|
|
|
|
@MainActor
|
|
private func scheduleRetry() {
|
|
retryAttempt = min(retryAttempt + 1, 10)
|
|
let base = dependencies.retryInitialSeconds
|
|
let maxDelay = dependencies.retryMaxSeconds
|
|
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
|
let calculated = base * multiplier
|
|
let delay = min(maxDelay, max(base, calculated))
|
|
|
|
cancelRetry()
|
|
cleanupState.retryTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.dependencies.retrySleep(delay)
|
|
guard !Task.isCancelled else { return }
|
|
await MainActor.run {
|
|
self.prefetchIfNeeded(force: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func cancelRetry() {
|
|
cleanupState.retryTask?.cancel()
|
|
cleanupState.retryTask = nil
|
|
}
|
|
|
|
private func persistCache(_ text: String) {
|
|
guard let url = dependencies.cacheURL() else { return }
|
|
guard let data = text.data(using: .utf8) else { return }
|
|
do {
|
|
try dependencies.writeData(data, url)
|
|
} catch {
|
|
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
|
}
|
|
}
|
|
|
|
// MARK: - Loading
|
|
private func loadLocalEntries() -> [Entry] {
|
|
// Prefer cached file if present
|
|
if let cache = dependencies.cacheURL(),
|
|
let data = dependencies.readData(cache),
|
|
let text = String(data: data, encoding: .utf8) {
|
|
let arr = Self.parseCSV(text)
|
|
if !arr.isEmpty { return arr }
|
|
}
|
|
|
|
// Try bundled resource(s)
|
|
let bundleCandidates = dependencies.bundledCSVURLs()
|
|
|
|
for url in bundleCandidates {
|
|
if let data = dependencies.readData(url),
|
|
let text = String(data: data, encoding: .utf8) {
|
|
let arr = Self.parseCSV(text)
|
|
if !arr.isEmpty { return arr }
|
|
}
|
|
}
|
|
|
|
// Try filesystem path (development/test)
|
|
if let cwd = dependencies.currentDirectoryPath(),
|
|
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
|
let text = String(data: data, encoding: .utf8) {
|
|
return Self.parseCSV(text)
|
|
}
|
|
|
|
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
|
return []
|
|
}
|
|
|
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
|
var result: Set<Entry> = []
|
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
|
for (idx, raw) in lines.enumerated() {
|
|
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
|
let parts = line.split(separator: ",").map { $0.trimmed }
|
|
guard parts.count >= 3 else { continue }
|
|
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
|
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
|
result.insert(Entry(host: host, lat: lat, lon: lon))
|
|
}
|
|
return Array(result)
|
|
}
|
|
|
|
// MARK: - Observers & Timers
|
|
private func registerObservers() {
|
|
let center = dependencies.notificationCenter
|
|
|
|
let torReady = center.addObserver(
|
|
forName: .TorDidBecomeReady,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor in
|
|
self.prefetchIfNeeded(force: true)
|
|
}
|
|
}
|
|
cleanupState.observers.append(torReady)
|
|
|
|
if let activeNotificationName = dependencies.activeNotificationName {
|
|
let didBecomeActive = center.addObserver(
|
|
forName: activeNotificationName,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor in
|
|
self.prefetchIfNeeded()
|
|
}
|
|
}
|
|
cleanupState.observers.append(didBecomeActive)
|
|
}
|
|
}
|
|
|
|
private func startRefreshTimer() {
|
|
cleanupState.refreshTimer?.invalidate()
|
|
let interval = dependencies.refreshCheckInterval
|
|
guard interval > 0 else { return }
|
|
|
|
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor in
|
|
self.prefetchIfNeeded()
|
|
}
|
|
}
|
|
cleanupState.refreshTimer = timer
|
|
RunLoop.main.add(timer, forMode: .common)
|
|
}
|
|
|
|
var debugRetryAttempt: Int { retryAttempt }
|
|
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
|
var debugObserverCount: Int { cleanupState.observers.count }
|
|
}
|
|
|
|
// MARK: - Distance
|
|
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
|
|
let r = 6371.0 // Earth radius in km
|
|
let dLat = (lat2 - lat1) * .pi / 180
|
|
let dLon = (lon2 - lon1) * .pi / 180
|
|
let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)
|
|
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
|
return r * c
|
|
}
|