Feat/georelay (#504)

* feat(georelay): route geohash kind 20000 via nearest relays; add GeoRelayDirectory; target geohash subscriptions; avoid duplicate connections

* feat(georelay): fetch daily from remote CSV with fallback to bundled; cache to app support; prefetch on app load

* fix(georelay): make CSV parser nonisolated and call as Self.parseCSV to satisfy Swift 6 actor isolation

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-24 12:49:52 +02:00
committed by GitHub
co-authored by jack
parent d2bfbbcfd4
commit 75c8933091
7 changed files with 545 additions and 12 deletions
+2
View File
@@ -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 {
+152
View File
@@ -0,0 +1,152 @@
import Foundation
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
@MainActor
final class GeoRelayDirectory {
struct Entry: Hashable {
let host: String
let lat: Double
let lon: Double
}
static let shared = GeoRelayDirectory()
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() {
// 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.
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.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty else { return [] }
let sorted = entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.prefix(count)
return sorted.map { "wss://\($0.host)" }
}
// 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)
}
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 = Self.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 = Self.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 Self.parseCSV(text)
}
SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning)
return []
}
nonisolated static func parseCSV(_ text: String) -> [Entry] {
var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline })
// Skip header if present
for (idx, raw) in lines.enumerated() {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
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 }
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
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
}
+25 -4
View File
@@ -78,9 +78,23 @@ class NostrRelayManager: ObservableObject {
updateConnectionStatus()
}
/// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) {
let existing = Set(relays.map { $0.url })
for url in Set(relayUrls) {
if !existing.contains(url) {
relays.append(Relay(url: url))
}
if connections[url] == nil {
connectToRelay(url)
}
}
}
/// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
let targetRelays = relayUrls ?? relays.map { $0.url }
let targetRelays = relayUrls ?? Self.defaultRelays
ensureConnections(to: targetRelays)
// Add to queue for reliability
messageQueueLock.lock()
@@ -95,10 +109,11 @@ class NostrRelayManager: ObservableObject {
}
}
/// Subscribe to events matching a filter
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
func subscribe(
filter: NostrFilter,
id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void
) {
messageHandlers[id] = handler
@@ -120,8 +135,14 @@ class NostrRelayManager: ObservableObject {
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
// category: SecureLogger.session, level: .debug)
// Send subscription to all connected relays
for (relayUrl, connection) in connections {
// Target specific relays if provided; else all connections
let urls = relayUrls ?? Self.defaultRelays
ensureConnections(to: urls)
let targets: [(String, URLSessionWebSocketTask)] = urls.compactMap { url in
connections[url].map { (url, $0) }
}
for (relayUrl, connection) in targets {
connection.send(.string(messageString)) { error in
if let error = error {
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
+31 -1
View File
@@ -4,6 +4,11 @@ import Foundation
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
enum Geohash {
private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
private static let base32Map: [Character: Int] = {
var map: [Character: Int] = [:]
for (i, c) in base32Chars.enumerated() { map[c] = i }
return map
}()
/// Encodes the provided coordinates into a geohash string.
/// - Parameters:
@@ -56,5 +61,30 @@ enum Geohash {
return String(geohash)
}
}
/// Decodes a geohash into the center latitude/longitude of its bounding box.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: (lat, lon) center coordinate.
static func decodeCenter(_ geohash: String) -> (lat: Double, lon: Double) {
var latInterval: (Double, Double) = (-90.0, 90.0)
var lonInterval: (Double, Double) = (-180.0, 180.0)
var isEven = true
for ch in geohash.lowercased() {
guard let cd = base32Map[ch] else { continue }
for mask in [16, 8, 4, 2, 1] {
if isEven {
let mid = (lonInterval.0 + lonInterval.1) / 2
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
} else {
let mid = (latInterval.0 + latInterval.1) / 2
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
}
isEven.toggle()
}
}
let lat = (latInterval.0 + latInterval.1) / 2
let lon = (lonInterval.0 + lonInterval.1) / 2
return (lat, lon)
}
}
+29 -6
View File
@@ -711,7 +711,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Unsubscribe + resubscribe
NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
if self.processedNostrEvents.contains(event.id) { return }
@@ -1275,7 +1276,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
)
NostrRelayManager.shared.sendEvent(event)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
if targetRelays.isEmpty {
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
// Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
@@ -1359,7 +1365,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
geoSubscriptionID = subID
startGeoParticipantsTimer()
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return }
// Only handle ephemeral kind 20000 with matching tag
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
@@ -1730,7 +1737,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let subID = "geo-sample-\(gh)"
geoSamplingSubs[subID] = gh
let filter = NostrFilter.geohashEphemeral(gh, since: Date().addingTimeInterval(-300), limit: 100)
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Update participants for this specific geohash
@@ -2478,7 +2486,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
)
NostrRelayManager.shared.sendEvent(event)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
if targetRelays.isEmpty {
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
if targetRelays.isEmpty {
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
// Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
} catch {
@@ -4276,7 +4294,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
)
NostrRelayManager.shared.sendEvent(event)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
if targetRelays.isEmpty {
NostrRelayManager.shared.sendEvent(event)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
} catch {
SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error)
}