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? 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 = [] 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 }