diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 4aae4c91..e470fd47 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -64,10 +64,6 @@ 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */; }; 38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */; }; 3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; }; - E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; }; - E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; }; - E0A1B2C3D4E5F6012345678D /* online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* online_relays_gps.csv */; }; - E0A1B2C3D4E5F6012345678E /* online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* online_relays_gps.csv */; }; 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; 501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 394E8A1AC76EFAE352075BE9 /* NoiseEncryptionService.swift */; }; 5C93B4FDD0C448C3EDDBF8AE /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; }; @@ -130,6 +126,10 @@ D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; }; D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */; }; D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */; }; + E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; }; + E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; }; + E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; }; + E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; }; E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */; }; E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; }; EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; }; @@ -225,8 +225,6 @@ 5318B743C64628A125261163 /* BinaryEncodingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryEncodingUtils.swift; sourceTree = ""; }; 5BC5AB43F4A8FB62C935CD74 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrIdentity.swift; sourceTree = ""; }; - E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoRelayDirectory.swift; sourceTree = ""; }; - E0A1B2C3D4E5F6012345678A /* online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = ""; }; 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = ""; }; 78595178957244CBDF7E79B6 /* NostrRelayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrRelayManager.swift; sourceTree = ""; }; @@ -249,6 +247,8 @@ C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBluetoothMeshService.swift; sourceTree = ""; }; D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicChatE2ETests.swift; sourceTree = ""; }; D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoRelayDirectory.swift; sourceTree = ""; }; + E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = ""; }; E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = ""; }; E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocolTests.swift; sourceTree = ""; }; EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -308,7 +308,7 @@ isa = PBXGroup; children = ( 2F82C5FC8433F4064F079D1F /* bitchat */, - E0A1B2C3D4E5F6012345678A /* online_relays_gps.csv */, + E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */, A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, C3D98EB3E1B455E321F519F4 /* bitchatTests */, 9F37F9F2C353B58AC809E93B /* Products */, @@ -328,8 +328,8 @@ isa = PBXGroup; children = ( 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */, - FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */, 527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */, + FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */, EF625BB3AD919322C01A46B2 /* BitchatApp.swift */, EA706D8E5097785414646A8E /* Info.plist */, 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */, @@ -526,7 +526,7 @@ 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */, 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */, 78595178957244CBDF7E79B6 /* NostrRelayManager.swift */, - E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */, + E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */, ); path = Nostr; sourceTree = ""; @@ -691,7 +691,7 @@ buildActionMask = 2147483647; files = ( 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */, - E0A1B2C3D4E5F6012345678D /* online_relays_gps.csv in Resources */, + E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -701,7 +701,7 @@ files = ( BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */, E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */, - E0A1B2C3D4E5F6012345678E /* online_relays_gps.csv in Resources */, + E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -766,7 +766,7 @@ D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */, 8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */, AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */, - E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */, + E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -820,7 +820,7 @@ EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */, C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */, AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */, - E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */, + E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index b7a8a1d9..b2297061 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -21,6 +21,8 @@ struct BitchatApp: App { init() { UNUserNotificationCenter.current().delegate = NotificationDelegate.shared + // Warm up georelay directory and refresh if stale (once/day) + GeoRelayDirectory.shared.prefetchIfNeeded() } var body: some Scene { diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/bitchat/Nostr/GeoRelayDirectory.swift index c27be66a..1dc63751 100644 --- a/bitchat/Nostr/GeoRelayDirectory.swift +++ b/bitchat/Nostr/GeoRelayDirectory.swift @@ -1,7 +1,8 @@ import Foundation /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. -struct GeoRelayDirectory { +@MainActor +final class GeoRelayDirectory { struct Entry: Hashable { let host: String let lat: Double @@ -9,10 +10,17 @@ struct GeoRelayDirectory { } static let shared = GeoRelayDirectory() - private let entries: [Entry] + private(set) var entries: [Entry] = [] + private let cacheFileName = "georelays_cache.csv" + private let lastFetchKey = "georelay.lastFetchAt" + private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")! + private let fetchInterval: TimeInterval = 60 * 60 * 24 // 24h private init() { - self.entries = GeoRelayDirectory.loadEntries() + // Load cached or bundled data synchronously + self.entries = self.loadLocalEntries() + // Fire-and-forget remote refresh if stale + prefetchIfNeeded() } /// Returns up to `count` relay URLs (wss://) closest to the geohash center. @@ -32,23 +40,72 @@ struct GeoRelayDirectory { return sorted.map { "wss://\($0.host)" } } - // MARK: - Loading - private static func loadEntries() -> [Entry] { - // Try bundled resource first - if let url = Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv") ?? - Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays"), - let data = try? Data(contentsOf: url), - let text = String(data: data, encoding: .utf8) { - return parseCSV(text) + // MARK: - Remote Fetch + func prefetchIfNeeded() { + let now = Date() + let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast + guard now.timeIntervalSince(last) >= fetchInterval else { return } + fetchRemote() + } + + private func fetchRemote() { + let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) + let task = URLSession.shared.dataTask(with: req) { [weak self] data, _, error in + guard let self = self else { return } + if let data = data, error == nil, let text = String(data: data, encoding: .utf8) { + let parsed = GeoRelayDirectory.parseCSV(text) + if !parsed.isEmpty { + Task { @MainActor in + self.entries = parsed + self.persistCache(text) + UserDefaults.standard.set(Date(), forKey: self.lastFetchKey) + SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info) + } + return + } + } + SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning) } - // Try filesystem path (development/test environments) + task.resume() + } + + private func persistCache(_ text: String) { + guard let url = cacheURL() else { return } + do { + try text.data(using: .utf8)?.write(to: url, options: .atomic) + } catch { + SecureLogger.log("GeoRelayDirectory: failed to write cache: \(error)", category: SecureLogger.session, level: .warning) + } + } + + // MARK: - Loading + private func loadLocalEntries() -> [Entry] { + // Prefer cached file if present + if let cache = self.cacheURL(), + let data = try? Data(contentsOf: cache), + let text = String(data: data, encoding: .utf8) { + let arr = parseCSV(text) + if !arr.isEmpty { return arr } + } + // Try bundled resource(s) + let bundleCandidates = [ + 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 } + for url in bundleCandidates { + if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) { + let arr = parseCSV(text) + if !arr.isEmpty { return arr } + } + } + // Try filesystem path (development/test) if let cwd = FileManager.default.currentDirectoryPath as String?, let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let text = String(data: data, encoding: .utf8) { return parseCSV(text) } - // No data available - SecureLogger.log("GeoRelayDirectory: relay CSV not found; falling back to default relay set", category: SecureLogger.session, level: .warning) + SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning) return [] } @@ -62,12 +119,26 @@ struct GeoRelayDirectory { if idx == 0 && line.lowercased().contains("relay url") { continue } let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } guard parts.count >= 3 else { continue } - let host = parts[0].replacingOccurrences(of: "wss://", with: "") + var host = parts[0] + host = host.replacingOccurrences(of: "https://", with: "") + host = host.replacingOccurrences(of: "http://", with: "") + host = host.replacingOccurrences(of: "wss://", with: "") + host = host.replacingOccurrences(of: "ws://", with: "") + host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/")) 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) } + + private func cacheURL() -> URL? { + 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(cacheFileName) + } catch { return nil } + } } // MARK: - Distance