mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Extract Nostr into a dedicated module
This commit is contained in:
@@ -23,6 +23,8 @@ jobs:
|
||||
path: localPackages/BitFoundation
|
||||
- name: Noise
|
||||
path: localPackages/Noise
|
||||
- name: Nostr
|
||||
path: localPackages/Nostr
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
+4
-1
@@ -20,6 +20,7 @@ let package = Package(
|
||||
.package(path: "localPackages/Noise"),
|
||||
.package(path: "localPackages/BitFoundation"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
.package(path: "localPackages/Nostr"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||
],
|
||||
targets: [
|
||||
@@ -30,6 +31,7 @@ let package = Package(
|
||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "Noise", package: "Noise"),
|
||||
.product(name: "Nostr", package: "Nostr"),
|
||||
.product(name: "Tor", package: "Arti")
|
||||
],
|
||||
path: "bitchat",
|
||||
@@ -50,7 +52,8 @@ let package = Package(
|
||||
name: "bitchatTests",
|
||||
dependencies: [
|
||||
"bitchat",
|
||||
.product(name: "BitFoundation", package: "BitFoundation")
|
||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||
.product(name: "Nostr", package: "Nostr")
|
||||
],
|
||||
path: "bitchatTests",
|
||||
exclude: [
|
||||
|
||||
Generated
+20
@@ -12,6 +12,8 @@
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||
A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; };
|
||||
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; };
|
||||
A63180832F8103AB00B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180822F8103AB00B8B128 /* Nostr */; };
|
||||
A63180852F8103B600B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180842F8103B600B8B128 /* Nostr */; };
|
||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
|
||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||
@@ -160,6 +162,7 @@
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */,
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||
A63180852F8103B600B8B128 /* Nostr in Frameworks */,
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
||||
);
|
||||
@@ -170,6 +173,7 @@
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||
A63163B62F80CB2500B8B128 /* Noise in Frameworks */,
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||
A63180832F8103AB00B8B128 /* Nostr in Frameworks */,
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
||||
);
|
||||
@@ -233,6 +237,7 @@
|
||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
||||
A63163B72F80CB2D00B8B128 /* Noise */,
|
||||
A63180842F8103B600B8B128 /* Nostr */,
|
||||
);
|
||||
productName = bitchat_macOS;
|
||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||
@@ -315,6 +320,7 @@
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
||||
A63163B52F80CB2500B8B128 /* Noise */,
|
||||
A63180822F8103AB00B8B128 /* Nostr */,
|
||||
);
|
||||
productName = bitchat_iOS;
|
||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||
@@ -358,6 +364,7 @@
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
||||
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
|
||||
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 90;
|
||||
projectDirPath = "";
|
||||
@@ -927,6 +934,10 @@
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Noise;
|
||||
};
|
||||
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Nostr;
|
||||
};
|
||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitFoundation;
|
||||
@@ -967,6 +978,15 @@
|
||||
package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */;
|
||||
productName = Noise;
|
||||
};
|
||||
A63180822F8103AB00B8B128 /* Nostr */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Nostr;
|
||||
};
|
||||
A63180842F8103B600B8B128 /* Nostr */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */;
|
||||
productName = Nostr;
|
||||
};
|
||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = BitFoundation;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import Tor
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
@@ -27,9 +28,11 @@ struct BitchatApp: App {
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
private let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||
|
||||
init() {
|
||||
GeoRelayDirectory.setupShared(dependencies: .live())
|
||||
NostrRelayManager.setupShared(dependencies: .live())
|
||||
let keychain = KeychainManager()
|
||||
let idBridge = self.idBridge
|
||||
_chatViewModel = StateObject(
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import Nostr
|
||||
import Combine
|
||||
import Foundation
|
||||
import Tor
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
// MARK: - GeoRelayDirectory Live Dependencies
|
||||
|
||||
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)
|
||||
},
|
||||
torReadyNotificationName: .TorDidBecomeReady,
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NostrRelayManager Live Dependencies
|
||||
|
||||
extension NostrRelayManagerDependencies {
|
||||
@MainActor
|
||||
static func live() -> Self {
|
||||
Self(
|
||||
activationAllowed: { NetworkActivationService.shared.activationAllowed },
|
||||
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
|
||||
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
|
||||
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
|
||||
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
|
||||
locationPermissionPublisher: LocationChannelManager.shared.$permissionState
|
||||
.map { state -> LocationPermissionState in
|
||||
switch state {
|
||||
case .notDetermined:.notDetermined
|
||||
case .authorized: .authorized
|
||||
case .denied: .denied
|
||||
case .restricted: .denied
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher(),
|
||||
torEnforced: { TorManager.shared.torEnforced },
|
||||
torIsReady: { TorManager.shared.isReady },
|
||||
torIsForeground: { TorManager.shared.isForeground() },
|
||||
awaitTorReady: { completion in
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
completion(ready)
|
||||
}
|
||||
}
|
||||
},
|
||||
makeSession: { NostrRelayManager.makeURLSession(TorURLSession.shared.session) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
},
|
||||
now: Date.init
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Nostr
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import Combine
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
@@ -74,7 +75,7 @@ final class GeohashPresenceService: ObservableObject {
|
||||
]
|
||||
|
||||
private init() {
|
||||
let idBridge = NostrIdentityBridge()
|
||||
let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
|
||||
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
|
||||
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import protocol Nostr.NostrKeychainStoring
|
||||
import protocol Noise.SecureMemoryCleaner
|
||||
import Foundation
|
||||
import Security
|
||||
@@ -52,7 +53,7 @@ enum KeychainSaveResult {
|
||||
}
|
||||
}
|
||||
|
||||
protocol KeychainManagerProtocol: SecureMemoryCleaner {
|
||||
protocol KeychainManagerProtocol: SecureMemoryCleaner, NostrKeychainStoring {
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||
func getIdentityKey(forKey key: String) -> Data?
|
||||
func deleteIdentityKey(forKey key: String) -> Bool
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BitLogger
|
||||
import Nostr
|
||||
import Foundation
|
||||
|
||||
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
||||
@@ -15,7 +16,7 @@ struct LocationNotesDependencies {
|
||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||
var now: () -> Date
|
||||
|
||||
private static let idBridge = NostrIdentityBridge()
|
||||
private static let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||
|
||||
static let live = LocationNotesDependencies(
|
||||
relayLookup: { geohash, count in
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Nostr
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
@@ -1967,7 +1968,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
||||
|
||||
// Reinitialize Nostr relay manager with new identity
|
||||
nostrRelayManager = NostrRelayManager()
|
||||
nostrRelayManager = NostrRelayManager(dependencies: .live())
|
||||
setupNostrMessageHandling()
|
||||
nostrRelayManager?.connect()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Geohash and Nostr logic for ChatViewModel
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Private chat and media transfer logic for ChatViewModel
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Islam on 29/10/2025.
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import SwiftUI
|
||||
|
||||
struct CommandSuggestionsView: View {
|
||||
@@ -76,7 +77,7 @@ struct CommandSuggestionsView: View {
|
||||
let keychain = KeychainManager()
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(),
|
||||
idBridge: NostrIdentityBridge(keychain: keychain),
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import SwiftUI
|
||||
|
||||
struct TextMessageView: View {
|
||||
@@ -89,7 +90,7 @@ struct TextMessageView: View {
|
||||
.environmentObject(
|
||||
ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(),
|
||||
idBridge: NostrIdentityBridge(keychain: keychain),
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Nostr
|
||||
import SwiftUI
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
#if os(iOS)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Nostr
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
@testable import bitchat
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Nostr
|
||||
import Tor
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
@@ -303,6 +304,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
retrySleep: { delay in
|
||||
await retryRecorder.record(delay)
|
||||
},
|
||||
torReadyNotificationName: .TorDidBecomeReady,
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: autoStart
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Tests for NIP-17 gift-wrapped private messages
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import Testing
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Combine
|
||||
import Nostr
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Nostr
|
||||
import Combine
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
@@ -697,7 +698,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func makeContext(
|
||||
permission: LocationChannelManager.PermissionState,
|
||||
permission: LocationPermissionState,
|
||||
favorites: Set<Data> = [],
|
||||
activationAllowed: Bool = true,
|
||||
userTorEnabled: Bool = false,
|
||||
@@ -705,7 +706,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
torIsReady: Bool = true,
|
||||
torIsForeground: Bool = true
|
||||
) -> RelayManagerTestContext {
|
||||
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
||||
let permissionSubject = CurrentValueSubject<LocationPermissionState, Never>(permission)
|
||||
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
|
||||
let sessionFactory = MockRelaySessionFactory()
|
||||
let scheduler = MockRelayScheduler()
|
||||
@@ -782,7 +783,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
@MainActor
|
||||
private struct RelayManagerTestContext {
|
||||
let manager: NostrRelayManager
|
||||
let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>
|
||||
let permissionSubject: CurrentValueSubject<LocationPermissionState, Never>
|
||||
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
||||
let sessionFactory: MockRelaySessionFactory
|
||||
let scheduler: MockRelayScheduler
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Nostr
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import CoreGraphics
|
||||
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "6a29e071e2546e255f894c733cad2a53ab20e153bf12e813d16830b195a5970d",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-secp256k1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/21-DOT-DEV/swift-secp256k1",
|
||||
"state" : {
|
||||
"revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2",
|
||||
"version" : "0.21.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// swift-tools-version: 5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Nostr",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "Nostr",
|
||||
targets: ["Nostr"]
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../BitLogger"),
|
||||
.package(path: "../BitFoundation"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Nostr",
|
||||
dependencies: [
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||
.product(name: "P256K", package: "swift-secp256k1"),
|
||||
],
|
||||
path: "Sources"
|
||||
),
|
||||
.testTarget(
|
||||
name: "NostrTests",
|
||||
dependencies: ["Nostr"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -1,38 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Bech32 encoding for Nostr (minimal implementation)
|
||||
enum Bech32 {
|
||||
public enum Bech32 {
|
||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
|
||||
static func encode(hrp: String, data: Data) throws -> String {
|
||||
|
||||
public static func encode(hrp: String, data: Data) throws -> String {
|
||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||
let checksum = createChecksum(hrp: hrp, values: values)
|
||||
let combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map {
|
||||
|
||||
return hrp + "1" + combined.map {
|
||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||
return String(charset[index])
|
||||
}.joined()
|
||||
}
|
||||
|
||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||
// Find the last occurrence of '1'
|
||||
|
||||
public static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||
throw Bech32Error.invalidFormat
|
||||
}
|
||||
|
||||
|
||||
let hrp = String(bech32String[..<separatorIndex])
|
||||
|
||||
|
||||
// Validate HRP contains only ASCII characters
|
||||
for char in hrp {
|
||||
guard char.asciiValue != nil else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||
|
||||
|
||||
// Convert characters to values
|
||||
var values = [UInt8]()
|
||||
for char in dataString {
|
||||
@@ -41,84 +40,84 @@ enum Bech32 {
|
||||
}
|
||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||
}
|
||||
|
||||
|
||||
// Verify checksum
|
||||
guard values.count >= 6 else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
|
||||
let payloadValues = Array(values.dropLast(6))
|
||||
let checksum = Array(values.suffix(6))
|
||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||
|
||||
|
||||
guard checksum == expectedChecksum else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
|
||||
// Convert back to bytes
|
||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||
return (hrp: hrp, data: Data(bytes))
|
||||
}
|
||||
|
||||
|
||||
enum Bech32Error: Error {
|
||||
case invalidFormat
|
||||
case invalidCharacter
|
||||
case invalidChecksum
|
||||
}
|
||||
|
||||
|
||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
var result = [UInt8]()
|
||||
let maxv = (1 << to) - 1
|
||||
|
||||
|
||||
for value in data {
|
||||
acc = (acc << from) | Int(value)
|
||||
bits += from
|
||||
|
||||
|
||||
while bits >= to {
|
||||
bits -= to
|
||||
result.append(UInt8((acc >> bits) & maxv))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if pad && bits > 0 {
|
||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||
let polymod = polymod(checksumValues) ^ 1
|
||||
var checksum = [UInt8]()
|
||||
|
||||
|
||||
for i in 0..<6 {
|
||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||
}
|
||||
|
||||
|
||||
return checksum
|
||||
}
|
||||
|
||||
|
||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||
var result = [UInt8]()
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
return []
|
||||
}
|
||||
result.append(UInt8(asciiValue >> 5))
|
||||
}
|
||||
result.append(0)
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
return []
|
||||
}
|
||||
result.append(UInt8(asciiValue & 31))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
private static func polymod(_ values: [UInt8]) -> Int {
|
||||
var chk = 1
|
||||
for value in values {
|
||||
+130
-130
@@ -1,101 +1,72 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Tor
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// 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
|
||||
}
|
||||
public struct GeoRelayDirectoryDependencies {
|
||||
public var userDefaults: UserDefaults
|
||||
public var notificationCenter: NotificationCenter
|
||||
public var now: () -> Date
|
||||
public var remoteURL: URL
|
||||
public var fetchInterval: TimeInterval
|
||||
public var refreshCheckInterval: TimeInterval
|
||||
public var retryInitialSeconds: TimeInterval
|
||||
public var retryMaxSeconds: TimeInterval
|
||||
public var awaitTorReady: @Sendable () async -> Bool
|
||||
public var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
|
||||
public var readData: (URL) -> Data?
|
||||
public var writeData: (Data, URL) throws -> Void
|
||||
public var cacheURL: () -> URL?
|
||||
public var bundledCSVURLs: () -> [URL]
|
||||
public var currentDirectoryPath: () -> String?
|
||||
public var retrySleep: (TimeInterval) async -> Void
|
||||
public var torReadyNotificationName: Notification.Name?
|
||||
public var activeNotificationName: Notification.Name?
|
||||
public 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
|
||||
)
|
||||
public init(
|
||||
userDefaults: UserDefaults,
|
||||
notificationCenter: NotificationCenter,
|
||||
now: @escaping () -> Date,
|
||||
remoteURL: URL,
|
||||
fetchInterval: TimeInterval,
|
||||
refreshCheckInterval: TimeInterval,
|
||||
retryInitialSeconds: TimeInterval,
|
||||
retryMaxSeconds: TimeInterval,
|
||||
awaitTorReady: @Sendable @escaping () async -> Bool,
|
||||
makeFetchData: @MainActor @Sendable @escaping () -> (@Sendable (URLRequest) async throws -> Data),
|
||||
readData: @escaping (URL) -> Data?,
|
||||
writeData: @escaping (Data, URL) throws -> Void,
|
||||
cacheURL: @escaping () -> URL?,
|
||||
bundledCSVURLs: @escaping () -> [URL],
|
||||
currentDirectoryPath: @escaping () -> String?,
|
||||
retrySleep: @escaping (TimeInterval) async -> Void,
|
||||
torReadyNotificationName: Notification.Name?,
|
||||
activeNotificationName: Notification.Name?,
|
||||
autoStart: Bool
|
||||
) {
|
||||
self.userDefaults = userDefaults
|
||||
self.notificationCenter = notificationCenter
|
||||
self.now = now
|
||||
self.remoteURL = remoteURL
|
||||
self.fetchInterval = fetchInterval
|
||||
self.refreshCheckInterval = refreshCheckInterval
|
||||
self.retryInitialSeconds = retryInitialSeconds
|
||||
self.retryMaxSeconds = retryMaxSeconds
|
||||
self.awaitTorReady = awaitTorReady
|
||||
self.makeFetchData = makeFetchData
|
||||
self.readData = readData
|
||||
self.writeData = writeData
|
||||
self.cacheURL = cacheURL
|
||||
self.bundledCSVURLs = bundledCSVURLs
|
||||
self.currentDirectoryPath = currentDirectoryPath
|
||||
self.retrySleep = retrySleep
|
||||
self.torReadyNotificationName = torReadyNotificationName
|
||||
self.activeNotificationName = activeNotificationName
|
||||
self.autoStart = autoStart
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class GeoRelayDirectory {
|
||||
public final class GeoRelayDirectory {
|
||||
private final class CleanupState {
|
||||
let notificationCenter: NotificationCenter
|
||||
var observers: [NSObjectProtocol] = []
|
||||
@@ -113,10 +84,16 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry: Hashable, Sendable {
|
||||
let host: String
|
||||
let lat: Double
|
||||
let lon: Double
|
||||
public struct Entry: Hashable, Sendable {
|
||||
public let host: String
|
||||
public let lat: Double
|
||||
public let lon: Double
|
||||
|
||||
public init(host: String, lat: Double, lon: Double) {
|
||||
self.host = host
|
||||
self.lat = lat
|
||||
self.lon = lon
|
||||
}
|
||||
}
|
||||
|
||||
private enum DetachedFetchOutcome: Sendable {
|
||||
@@ -126,9 +103,13 @@ final class GeoRelayDirectory {
|
||||
case network(String)
|
||||
}
|
||||
|
||||
static let shared = GeoRelayDirectory()
|
||||
nonisolated(unsafe) public static var shared: GeoRelayDirectory!
|
||||
|
||||
private(set) var entries: [Entry] = []
|
||||
public static func setupShared(dependencies: GeoRelayDirectoryDependencies) {
|
||||
shared = GeoRelayDirectory(dependencies: dependencies)
|
||||
}
|
||||
|
||||
private(set) public var entries: [Entry] = []
|
||||
private let lastFetchKey = "georelay.lastFetchAt"
|
||||
private let dependencies: GeoRelayDirectoryDependencies
|
||||
private let cleanupState: CleanupState
|
||||
@@ -136,18 +117,7 @@ final class GeoRelayDirectory {
|
||||
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) {
|
||||
public init(dependencies: GeoRelayDirectoryDependencies) {
|
||||
self.dependencies = dependencies
|
||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||
entries = loadLocalEntries()
|
||||
@@ -159,13 +129,13 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
/// 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)
|
||||
public func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||
let center = decodeGeohashCenter(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] {
|
||||
public func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||
guard !entries.isEmpty, count > 0 else { return [] }
|
||||
|
||||
if entries.count <= count {
|
||||
@@ -195,7 +165,7 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
// MARK: - Remote Fetch
|
||||
func prefetchIfNeeded(force: Bool = false) {
|
||||
public func prefetchIfNeeded(force: Bool = false) {
|
||||
guard !isFetching else { return }
|
||||
|
||||
let now = dependencies.now()
|
||||
@@ -205,7 +175,6 @@ final class GeoRelayDirectory {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -343,7 +312,6 @@ final class GeoRelayDirectory {
|
||||
|
||||
// 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) {
|
||||
@@ -351,7 +319,6 @@ final class GeoRelayDirectory {
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
let bundleCandidates = dependencies.bundledCSVURLs()
|
||||
|
||||
for url in bundleCandidates {
|
||||
@@ -362,7 +329,6 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -373,7 +339,7 @@ final class GeoRelayDirectory {
|
||||
return []
|
||||
}
|
||||
|
||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||
nonisolated public static func parseCSV(_ text: String) -> [Entry] {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
@@ -397,17 +363,19 @@ final class GeoRelayDirectory {
|
||||
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)
|
||||
if let torReadyName = dependencies.torReadyNotificationName {
|
||||
let torReady = center.addObserver(
|
||||
forName: torReadyName,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
cleanupState.observers.append(torReady)
|
||||
}
|
||||
cleanupState.observers.append(torReady)
|
||||
|
||||
if let activeNotificationName = dependencies.activeNotificationName {
|
||||
let didBecomeActive = center.addObserver(
|
||||
@@ -439,17 +407,49 @@ final class GeoRelayDirectory {
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
var debugRetryAttempt: Int { retryAttempt }
|
||||
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
||||
var debugObserverCount: Int { cleanupState.observers.count }
|
||||
public var debugRetryAttempt: Int { retryAttempt }
|
||||
public var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
||||
public 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 r = 6371.0
|
||||
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
|
||||
}
|
||||
|
||||
// MARK: - Geohash decode (inline to avoid external dependency)
|
||||
|
||||
private let geohashBase32Map: [Character: Int] = {
|
||||
let chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
var map: [Character: Int] = [:]
|
||||
for (i, c) in chars.enumerated() { map[c] = i }
|
||||
return map
|
||||
}()
|
||||
|
||||
private func decodeGeohashCenter(_ 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 = geohashBase32Map[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)
|
||||
}
|
||||
+21
-24
@@ -2,59 +2,56 @@ import Foundation
|
||||
import P256K
|
||||
|
||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||
struct NostrIdentity: Codable {
|
||||
let privateKey: Data
|
||||
let publicKey: Data
|
||||
let npub: String // Bech32-encoded public key
|
||||
let createdAt: Date
|
||||
|
||||
/// Memberwise initializer
|
||||
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
|
||||
public struct NostrIdentity: Codable, Sendable {
|
||||
public let privateKey: Data
|
||||
public let publicKey: Data
|
||||
public let npub: String // Bech32-encoded public key
|
||||
public let createdAt: Date
|
||||
|
||||
public init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
|
||||
self.privateKey = privateKey
|
||||
self.publicKey = publicKey
|
||||
self.npub = npub
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
|
||||
/// Generate a new Nostr identity
|
||||
static func generate() throws -> NostrIdentity {
|
||||
// Generate Schnorr key for Nostr
|
||||
public static func generate() throws -> NostrIdentity {
|
||||
let schnorrKey = try P256K.Schnorr.PrivateKey()
|
||||
let xOnlyPubkey = Data(schnorrKey.xonly.bytes)
|
||||
let npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
||||
|
||||
|
||||
return NostrIdentity(
|
||||
privateKey: schnorrKey.dataRepresentation,
|
||||
publicKey: xOnlyPubkey, // Store x-only public key
|
||||
publicKey: xOnlyPubkey,
|
||||
npub: npub,
|
||||
createdAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/// Initialize from existing private key data
|
||||
init(privateKeyData: Data) throws {
|
||||
public init(privateKeyData: Data) throws {
|
||||
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyData)
|
||||
let xOnlyPubkey = Data(schnorrKey.xonly.bytes)
|
||||
|
||||
|
||||
self.privateKey = privateKeyData
|
||||
self.publicKey = xOnlyPubkey
|
||||
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
||||
self.createdAt = Date()
|
||||
}
|
||||
|
||||
|
||||
/// Get signing key for event signatures
|
||||
func signingKey() throws -> P256K.Signing.PrivateKey {
|
||||
public func signingKey() throws -> P256K.Signing.PrivateKey {
|
||||
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
|
||||
}
|
||||
|
||||
|
||||
/// Get Schnorr signing key for Nostr event signatures
|
||||
func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey {
|
||||
public func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey {
|
||||
try P256K.Schnorr.PrivateKey(dataRepresentation: privateKey)
|
||||
}
|
||||
|
||||
|
||||
/// Get hex-encoded public key (for Nostr events)
|
||||
var publicKeyHex: String {
|
||||
// Public key is already stored as x-only (32 bytes)
|
||||
return publicKey.hexEncodedString()
|
||||
public var publicKeyHex: String {
|
||||
publicKey.hexEncodedString()
|
||||
}
|
||||
}
|
||||
+44
-41
@@ -1,51 +1,54 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Minimal keychain access required by NostrIdentityBridge.
|
||||
public protocol NostrKeychainStoring: Sendable {
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||
func load(key: String, service: String) -> Data?
|
||||
}
|
||||
|
||||
/// Bridge between Noise and Nostr identities
|
||||
final class NostrIdentityBridge {
|
||||
public final class NostrIdentityBridge {
|
||||
private let keychainService = "chat.bitchat.nostr"
|
||||
private let currentIdentityKey = "nostr-current-identity"
|
||||
private let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private var deviceSeedCache: Data?
|
||||
private let deviceSeedCache: NSLock = NSLock()
|
||||
private var _deviceSeedCacheValue: Data?
|
||||
// Cache derived identities to avoid repeated crypto during view rendering
|
||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||
private var _derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||
private let cacheLock = NSLock()
|
||||
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let keychain: any NostrKeychainStoring
|
||||
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||
public init(keychain: any NostrKeychainStoring) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
|
||||
/// Get or create the current Nostr identity
|
||||
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
// Check if we already have a Nostr identity
|
||||
public func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||
return identity
|
||||
}
|
||||
|
||||
// Generate new Nostr identity
|
||||
|
||||
let nostrIdentity = try NostrIdentity.generate()
|
||||
|
||||
// Store it
|
||||
|
||||
let data = try JSONEncoder().encode(nostrIdentity)
|
||||
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
||||
|
||||
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
public func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
public func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
guard let data = keychain.load(key: key, service: keychainService),
|
||||
let pubkey = String(data: data, encoding: .utf8) else {
|
||||
@@ -53,9 +56,9 @@ final class NostrIdentityBridge {
|
||||
}
|
||||
return pubkey
|
||||
}
|
||||
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
func clearAllAssociations() {
|
||||
public func clearAllAssociations() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
@@ -76,42 +79,45 @@ final class NostrIdentityBridge {
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
deviceSeedCache.lock()
|
||||
_deviceSeedCacheValue = nil
|
||||
deviceSeedCache.unlock()
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private func getOrCreateDeviceSeed() -> Data {
|
||||
if let cached = deviceSeedCache { return cached }
|
||||
deviceSeedCache.lock()
|
||||
if let cached = _deviceSeedCacheValue {
|
||||
deviceSeedCache.unlock()
|
||||
return cached
|
||||
}
|
||||
deviceSeedCache.unlock()
|
||||
|
||||
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = existing
|
||||
deviceSeedCache.lock()
|
||||
_deviceSeedCacheValue = existing
|
||||
deviceSeedCache.unlock()
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
deviceSeedCache.lock()
|
||||
_deviceSeedCacheValue = seed
|
||||
deviceSeedCache.unlock()
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
||||
public func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
cacheLock.lock()
|
||||
if let cached = derivedIdentityCache[geohash] {
|
||||
if let cached = _derivedIdentityCache[geohash] {
|
||||
cacheLock.unlock()
|
||||
return cached
|
||||
}
|
||||
@@ -132,24 +138,21 @@ final class NostrIdentityBridge {
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
_derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
|
||||
let fallback = (seed + msg).sha256Hash()
|
||||
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
_derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
|
||||
return identity
|
||||
+78
-187
@@ -1,17 +1,15 @@
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
import Security
|
||||
|
||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||
|
||||
/// NIP-17 Protocol Implementation for Private Direct Messages
|
||||
struct NostrProtocol {
|
||||
|
||||
public struct NostrProtocol {
|
||||
|
||||
/// Nostr event kinds
|
||||
enum EventKind: Int {
|
||||
public enum EventKind: Int, Sendable {
|
||||
case metadata = 0
|
||||
case textNote = 1
|
||||
case dm = 14 // NIP-17 DM rumor kind
|
||||
@@ -20,88 +18,71 @@ struct NostrProtocol {
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
}
|
||||
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
static func createPrivateMessage(
|
||||
public static func createPrivateMessage(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
|
||||
// Creating private message
|
||||
|
||||
// 1. Create the rumor (unsigned event)
|
||||
let rumor = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm, // NIP-17: DM rumor kind 14
|
||||
kind: .dm,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
|
||||
// 2. Create ephemeral key for this message
|
||||
|
||||
let ephemeralKey = try P256K.Schnorr.PrivateKey()
|
||||
// Created ephemeral key for seal
|
||||
|
||||
// 3. Seal the rumor (encrypt to recipient)
|
||||
|
||||
let sealedEvent = try createSeal(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: ephemeralKey
|
||||
)
|
||||
|
||||
// 4. Gift wrap the sealed event (encrypt to recipient again)
|
||||
|
||||
let giftWrap = try createGiftWrap(
|
||||
seal: sealedEvent,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: ephemeralKey
|
||||
)
|
||||
|
||||
// Created gift wrap
|
||||
|
||||
|
||||
return giftWrap
|
||||
}
|
||||
|
||||
|
||||
/// Decrypt a received NIP-17 message
|
||||
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
|
||||
static func decryptPrivateMessage(
|
||||
public static func decryptPrivateMessage(
|
||||
giftWrap: NostrEvent,
|
||||
recipientIdentity: NostrIdentity
|
||||
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
||||
|
||||
// Starting decryption
|
||||
|
||||
// 1. Unwrap the gift wrap
|
||||
let seal: NostrEvent
|
||||
do {
|
||||
seal = try unwrapGiftWrap(
|
||||
giftWrap: giftWrap,
|
||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
||||
)
|
||||
// Successfully unwrapped gift wrap
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
// 2. Open the seal
|
||||
|
||||
let rumor: NostrEvent
|
||||
do {
|
||||
rumor = try openSeal(
|
||||
seal: seal,
|
||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
||||
)
|
||||
// Successfully opened seal
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
||||
}
|
||||
|
||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||
static func createEphemeralGeohashEvent(
|
||||
public static func createEphemeralGeohashEvent(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
@@ -128,7 +109,7 @@ struct NostrProtocol {
|
||||
|
||||
/// Create a geohash presence heartbeat (kind 20001)
|
||||
/// Must contain empty content and NO nickname tag
|
||||
static func createGeohashPresenceEvent(
|
||||
public static func createGeohashPresenceEvent(
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
@@ -145,7 +126,7 @@ struct NostrProtocol {
|
||||
}
|
||||
|
||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||
static func createGeohashTextNote(
|
||||
public static func createGeohashTextNote(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
@@ -165,22 +146,21 @@ struct NostrProtocol {
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
|
||||
private static func createSeal(
|
||||
rumor: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> NostrEvent {
|
||||
|
||||
let rumorJSON = try rumor.jsonString()
|
||||
let encrypted = try encrypt(
|
||||
plaintext: rumorJSON,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: senderKey
|
||||
)
|
||||
|
||||
|
||||
let seal = NostrEvent(
|
||||
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
|
||||
createdAt: randomizedTimestamp(),
|
||||
@@ -188,130 +168,108 @@ struct NostrProtocol {
|
||||
tags: [],
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
// Sign the seal with the sender's Schnorr private key
|
||||
|
||||
return try seal.sign(with: senderKey)
|
||||
}
|
||||
|
||||
|
||||
private static func createGiftWrap(
|
||||
seal: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
|
||||
senderKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> NostrEvent {
|
||||
|
||||
let sealJSON = try seal.jsonString()
|
||||
|
||||
// Create new ephemeral key for gift wrap
|
||||
let wrapKey = try P256K.Schnorr.PrivateKey()
|
||||
// Creating gift wrap with ephemeral key
|
||||
|
||||
// Encrypt the seal with the new ephemeral key (not the seal's key)
|
||||
|
||||
let encrypted = try encrypt(
|
||||
plaintext: sealJSON,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: wrapKey // Use the gift wrap ephemeral key
|
||||
senderKey: wrapKey
|
||||
)
|
||||
|
||||
|
||||
let giftWrap = NostrEvent(
|
||||
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
|
||||
createdAt: randomizedTimestamp(),
|
||||
kind: .giftWrap,
|
||||
tags: [["p", recipientPubkey]], // Tag recipient
|
||||
tags: [["p", recipientPubkey]],
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
// Sign the gift wrap with the wrap Schnorr private key
|
||||
|
||||
return try giftWrap.sign(with: wrapKey)
|
||||
}
|
||||
|
||||
|
||||
private static func unwrapGiftWrap(
|
||||
giftWrap: NostrEvent,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> NostrEvent {
|
||||
|
||||
// Unwrapping gift wrap
|
||||
|
||||
let decrypted = try decrypt(
|
||||
ciphertext: giftWrap.content,
|
||||
senderPubkey: giftWrap.pubkey,
|
||||
recipientKey: recipientKey
|
||||
)
|
||||
|
||||
|
||||
guard let data = decrypted.data(using: .utf8),
|
||||
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
let seal = try NostrEvent(from: sealDict)
|
||||
// Unwrapped seal
|
||||
|
||||
return seal
|
||||
|
||||
return try NostrEvent(from: sealDict)
|
||||
}
|
||||
|
||||
|
||||
private static func openSeal(
|
||||
seal: NostrEvent,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> NostrEvent {
|
||||
|
||||
let decrypted = try decrypt(
|
||||
ciphertext: seal.content,
|
||||
senderPubkey: seal.pubkey,
|
||||
recipientKey: recipientKey
|
||||
)
|
||||
|
||||
|
||||
guard let data = decrypted.data(using: .utf8),
|
||||
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
|
||||
return try NostrEvent(from: rumorDict)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Encryption (NIP-44 v2)
|
||||
|
||||
|
||||
private static func encrypt(
|
||||
plaintext: String,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> String {
|
||||
|
||||
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
||||
throw NostrError.invalidPublicKey
|
||||
}
|
||||
|
||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
||||
|
||||
// Derive shared secret
|
||||
|
||||
let sharedSecret = try deriveSharedSecret(
|
||||
privateKey: senderKey,
|
||||
publicKey: recipientPubkeyData
|
||||
)
|
||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||
|
||||
// 24-byte random nonce for XChaCha20-Poly1305
|
||||
|
||||
var nonce24 = Data(count: 24)
|
||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||
}
|
||||
|
||||
|
||||
let pt = Data(plaintext.utf8)
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||
|
||||
// v2: base64url(nonce24 || ciphertext || tag)
|
||||
|
||||
var combined = Data()
|
||||
combined.append(nonce24)
|
||||
combined.append(sealed.ciphertext)
|
||||
combined.append(sealed.tag)
|
||||
return "v2:" + base64URLEncode(combined)
|
||||
}
|
||||
|
||||
|
||||
private static func decrypt(
|
||||
ciphertext: String,
|
||||
senderPubkey: String,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> String {
|
||||
// Expect NIP-44 v2 format
|
||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||
let encoded = String(ciphertext.dropFirst(3))
|
||||
guard let data = base64URLDecode(encoded),
|
||||
@@ -325,7 +283,6 @@ struct NostrProtocol {
|
||||
let tag = rest.suffix(16)
|
||||
let ct = rest.dropLast(16)
|
||||
|
||||
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
||||
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
||||
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||
let key = try deriveNIP44V2Key(from: ss)
|
||||
@@ -337,7 +294,6 @@ struct NostrProtocol {
|
||||
)
|
||||
}
|
||||
|
||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||
if senderPubkeyData.count == 32 {
|
||||
let even = Data([0x02]) + senderPubkeyData
|
||||
if let pt = try? attemptDecrypt(using: even) {
|
||||
@@ -351,32 +307,23 @@ struct NostrProtocol {
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static func deriveSharedSecret(
|
||||
privateKey: P256K.Schnorr.PrivateKey,
|
||||
publicKey: Data
|
||||
) throws -> Data {
|
||||
// Deriving shared secret
|
||||
|
||||
// Convert Schnorr private key to KeyAgreement private key
|
||||
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
|
||||
dataRepresentation: privateKey.dataRepresentation
|
||||
)
|
||||
|
||||
// Create KeyAgreement public key from the public key data
|
||||
// For ECDH, we need the full 33-byte compressed public key (with 0x02 or 0x03 prefix)
|
||||
|
||||
var fullPublicKey = Data()
|
||||
if publicKey.count == 32 { // X-only key, need to add prefix
|
||||
// For x-only keys in Nostr/Bitcoin, we need to try both possible Y coordinates
|
||||
// First try with even Y (0x02 prefix)
|
||||
if publicKey.count == 32 {
|
||||
fullPublicKey.append(0x02)
|
||||
fullPublicKey.append(publicKey)
|
||||
// Trying with even Y coordinate
|
||||
} else {
|
||||
fullPublicKey = publicKey
|
||||
}
|
||||
|
||||
// Try to create public key, if it fails with even Y, try odd Y
|
||||
|
||||
let keyAgreementPublicKey: P256K.KeyAgreement.PublicKey
|
||||
do {
|
||||
keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
|
||||
@@ -385,8 +332,6 @@ struct NostrProtocol {
|
||||
)
|
||||
} catch {
|
||||
if publicKey.count == 32 {
|
||||
// Try with odd Y (0x03 prefix)
|
||||
// Even Y failed, trying odd Y
|
||||
fullPublicKey = Data()
|
||||
fullPublicKey.append(0x03)
|
||||
fullPublicKey.append(publicKey)
|
||||
@@ -398,85 +343,32 @@ struct NostrProtocol {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Perform ECDH
|
||||
|
||||
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
|
||||
with: keyAgreementPublicKey,
|
||||
format: .compressed
|
||||
)
|
||||
|
||||
// Convert SharedSecret to Data
|
||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||
// ECDH shared secret derived
|
||||
|
||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||
return sharedSecretData
|
||||
|
||||
return sharedSecret.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
// Direct version that doesn't try to add prefixes
|
||||
private static func deriveSharedSecretDirect(
|
||||
privateKey: P256K.Schnorr.PrivateKey,
|
||||
publicKey: Data
|
||||
) throws -> Data {
|
||||
// Direct shared secret calculation
|
||||
|
||||
// Convert Schnorr private key to KeyAgreement private key
|
||||
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
|
||||
dataRepresentation: privateKey.dataRepresentation
|
||||
)
|
||||
|
||||
// Use the public key as-is (should already have prefix)
|
||||
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
|
||||
dataRepresentation: publicKey,
|
||||
format: .compressed
|
||||
)
|
||||
|
||||
// Perform ECDH
|
||||
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
|
||||
with: keyAgreementPublicKey,
|
||||
format: .compressed
|
||||
)
|
||||
|
||||
// Convert SharedSecret to Data
|
||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||
|
||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||
return sharedSecretData
|
||||
}
|
||||
|
||||
|
||||
private static func randomizedTimestamp() -> Date {
|
||||
// Add random offset to current time for privacy
|
||||
// This prevents timing correlation attacks while the actual message timestamp
|
||||
// is preserved in the encrypted rumor
|
||||
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes
|
||||
let now = Date()
|
||||
let randomized = now.addingTimeInterval(offset)
|
||||
|
||||
// Log with explicit UTC and local time for debugging
|
||||
let formatter = DateFormatter()
|
||||
//
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
|
||||
formatter.timeZone = TimeZone.current
|
||||
|
||||
// Timestamp randomized for privacy
|
||||
|
||||
return randomized
|
||||
let offset = TimeInterval.random(in: -900...900)
|
||||
return Date().addingTimeInterval(offset)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nostr Event structure
|
||||
struct NostrEvent: Codable {
|
||||
var id: String
|
||||
let pubkey: String
|
||||
let created_at: Int
|
||||
let kind: Int
|
||||
let tags: [[String]]
|
||||
let content: String
|
||||
var sig: String?
|
||||
|
||||
init(
|
||||
public struct NostrEvent: Codable, Sendable {
|
||||
public var id: String
|
||||
public let pubkey: String
|
||||
public let created_at: Int
|
||||
public let kind: Int
|
||||
public let tags: [[String]]
|
||||
public let content: String
|
||||
public var sig: String?
|
||||
|
||||
public init(
|
||||
pubkey: String,
|
||||
createdAt: Date,
|
||||
kind: NostrProtocol.EventKind,
|
||||
@@ -489,10 +381,10 @@ struct NostrEvent: Codable {
|
||||
self.tags = tags
|
||||
self.content = content
|
||||
self.sig = nil
|
||||
self.id = "" // Will be set during signing
|
||||
self.id = ""
|
||||
}
|
||||
|
||||
init(from dict: [String: Any]) throws {
|
||||
|
||||
public init(from dict: [String: Any]) throws {
|
||||
guard let pubkey = dict["pubkey"] as? String,
|
||||
let createdAt = dict["created_at"] as? Int,
|
||||
let kind = dict["kind"] as? Int,
|
||||
@@ -500,7 +392,7 @@ struct NostrEvent: Codable {
|
||||
let content = dict["content"] as? String else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
|
||||
self.id = dict["id"] as? String ?? ""
|
||||
self.pubkey = pubkey
|
||||
self.created_at = createdAt
|
||||
@@ -509,20 +401,19 @@ struct NostrEvent: Codable {
|
||||
self.content = content
|
||||
self.sig = dict["sig"] as? String
|
||||
}
|
||||
|
||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||
|
||||
public func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||
let (eventId, eventIdHash) = try calculateEventId()
|
||||
|
||||
// Sign with Schnorr (BIP-340)
|
||||
|
||||
var messageBytes = [UInt8](eventIdHash)
|
||||
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||
_ = auxRand.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||
|
||||
|
||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||
|
||||
|
||||
var signed = self
|
||||
signed.id = eventId
|
||||
signed.sig = signatureHex
|
||||
@@ -531,7 +422,7 @@ struct NostrEvent: Codable {
|
||||
|
||||
/// Validate that the event ID and Schnorr signature match the content and pubkey.
|
||||
/// Returns false when the signature is missing, malformed, or does not verify.
|
||||
func isValidSignature() -> Bool {
|
||||
public func isValidSignature() -> Bool {
|
||||
guard let sig = sig,
|
||||
let sigData = Data(hexString: sig),
|
||||
let pubData = Data(hexString: pubkey),
|
||||
@@ -548,7 +439,7 @@ struct NostrEvent: Codable {
|
||||
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
|
||||
return xonly.isValid(signature, for: &messageBytes)
|
||||
}
|
||||
|
||||
|
||||
private func calculateEventId() throws -> (String, Data) {
|
||||
let serialized = [
|
||||
0,
|
||||
@@ -558,12 +449,12 @@ struct NostrEvent: Codable {
|
||||
tags,
|
||||
content
|
||||
] as [Any]
|
||||
|
||||
|
||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||
}
|
||||
|
||||
func jsonString() throws -> String {
|
||||
|
||||
public func jsonString() throws -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.withoutEscapingSlashes]
|
||||
let data = try encoder.encode(self)
|
||||
@@ -571,7 +462,7 @@ struct NostrEvent: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
enum NostrError: Error {
|
||||
public enum NostrError: Error, Sendable {
|
||||
case invalidPublicKey
|
||||
case invalidPrivateKey
|
||||
case invalidEvent
|
||||
+192
-282
@@ -2,9 +2,8 @@ import BitLogger
|
||||
import Foundation
|
||||
import Network
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
protocol NostrRelayConnectionProtocol: AnyObject {
|
||||
public protocol NostrRelayConnectionProtocol: AnyObject {
|
||||
func resume()
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
|
||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)
|
||||
@@ -12,7 +11,7 @@ protocol NostrRelayConnectionProtocol: AnyObject {
|
||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
|
||||
}
|
||||
|
||||
protocol NostrRelaySessionProtocol {
|
||||
public protocol NostrRelaySessionProtocol {
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
|
||||
}
|
||||
|
||||
@@ -52,75 +51,91 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
struct NostrRelayManagerDependencies {
|
||||
var activationAllowed: () -> Bool
|
||||
var userTorEnabled: () -> Bool
|
||||
var hasMutualFavorites: () -> Bool
|
||||
var hasLocationPermission: () -> Bool
|
||||
var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||
var torEnforced: () -> Bool
|
||||
var torIsReady: () -> Bool
|
||||
var torIsForeground: () -> Bool
|
||||
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
||||
var makeSession: () -> NostrRelaySessionProtocol
|
||||
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||
var now: () -> Date
|
||||
public enum LocationPermissionState: Equatable, Sendable {
|
||||
case notDetermined
|
||||
case authorized
|
||||
case denied
|
||||
}
|
||||
|
||||
private extension NostrRelayManagerDependencies {
|
||||
@MainActor
|
||||
static func live() -> Self {
|
||||
Self(
|
||||
activationAllowed: { NetworkActivationService.shared.activationAllowed },
|
||||
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
|
||||
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
|
||||
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
|
||||
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
|
||||
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
||||
torEnforced: { TorManager.shared.torEnforced },
|
||||
torIsReady: { TorManager.shared.isReady },
|
||||
torIsForeground: { TorManager.shared.isForeground() },
|
||||
awaitTorReady: { completion in
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
completion(ready)
|
||||
}
|
||||
}
|
||||
},
|
||||
makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
},
|
||||
now: Date.init
|
||||
)
|
||||
public struct NostrRelayManagerDependencies {
|
||||
public var activationAllowed: () -> Bool
|
||||
public var userTorEnabled: () -> Bool
|
||||
public var hasMutualFavorites: () -> Bool
|
||||
public var hasLocationPermission: () -> Bool
|
||||
public var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||
public var locationPermissionPublisher: AnyPublisher<LocationPermissionState, Never>
|
||||
public var torEnforced: () -> Bool
|
||||
public var torIsReady: () -> Bool
|
||||
public var torIsForeground: () -> Bool
|
||||
public var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
||||
public var makeSession: () -> NostrRelaySessionProtocol
|
||||
public var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||
public var now: () -> Date
|
||||
|
||||
public init(
|
||||
activationAllowed: @escaping () -> Bool,
|
||||
userTorEnabled: @escaping () -> Bool,
|
||||
hasMutualFavorites: @escaping () -> Bool,
|
||||
hasLocationPermission: @escaping () -> Bool,
|
||||
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
|
||||
locationPermissionPublisher: AnyPublisher<LocationPermissionState, Never>,
|
||||
torEnforced: @escaping () -> Bool,
|
||||
torIsReady: @escaping () -> Bool,
|
||||
torIsForeground: @escaping () -> Bool,
|
||||
awaitTorReady: @escaping (@escaping (Bool) -> Void) -> Void,
|
||||
makeSession: @escaping () -> NostrRelaySessionProtocol,
|
||||
scheduleAfter: @Sendable @escaping (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
||||
now: @escaping () -> Date
|
||||
) {
|
||||
self.activationAllowed = activationAllowed
|
||||
self.userTorEnabled = userTorEnabled
|
||||
self.hasMutualFavorites = hasMutualFavorites
|
||||
self.hasLocationPermission = hasLocationPermission
|
||||
self.mutualFavoritesPublisher = mutualFavoritesPublisher
|
||||
self.locationPermissionPublisher = locationPermissionPublisher
|
||||
self.torEnforced = torEnforced
|
||||
self.torIsReady = torIsReady
|
||||
self.torIsForeground = torIsForeground
|
||||
self.awaitTorReady = awaitTorReady
|
||||
self.makeSession = makeSession
|
||||
self.scheduleAfter = scheduleAfter
|
||||
self.now = now
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages WebSocket connections to Nostr relays
|
||||
@MainActor
|
||||
final class NostrRelayManager: ObservableObject {
|
||||
static let shared = NostrRelayManager()
|
||||
public final class NostrRelayManager: ObservableObject {
|
||||
nonisolated(unsafe) public static var shared: NostrRelayManager!
|
||||
|
||||
public static func setupShared(dependencies: NostrRelayManagerDependencies) {
|
||||
shared = NostrRelayManager(dependencies: dependencies)
|
||||
}
|
||||
|
||||
/// Wraps a URLSession into a NostrRelaySessionProtocol for use in live dependencies.
|
||||
public static func makeURLSession(_ session: URLSession) -> NostrRelaySessionProtocol {
|
||||
URLSessionAdapter(base: session)
|
||||
}
|
||||
|
||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
||||
private(set) static var pendingGiftWrapIDs = Set<String>()
|
||||
static func registerPendingGiftWrap(id: String) {
|
||||
private(set) public static var pendingGiftWrapIDs = Set<String>()
|
||||
public static func registerPendingGiftWrap(id: String) {
|
||||
pendingGiftWrapIDs.insert(id)
|
||||
}
|
||||
|
||||
struct Relay: Identifiable {
|
||||
let id = UUID()
|
||||
let url: String
|
||||
var isConnected: Bool = false
|
||||
var lastError: Error?
|
||||
var lastConnectedAt: Date?
|
||||
var messagesSent: Int = 0
|
||||
var messagesReceived: Int = 0
|
||||
var reconnectAttempts: Int = 0
|
||||
var lastDisconnectedAt: Date?
|
||||
var nextReconnectTime: Date?
|
||||
|
||||
public struct Relay: Identifiable {
|
||||
public let id = UUID()
|
||||
public let url: String
|
||||
public var isConnected: Bool = false
|
||||
public var lastError: Error?
|
||||
public var lastConnectedAt: Date?
|
||||
public var messagesSent: Int = 0
|
||||
public var messagesReceived: Int = 0
|
||||
public var reconnectAttempts: Int = 0
|
||||
public var lastDisconnectedAt: Date?
|
||||
public var nextReconnectTime: Date?
|
||||
}
|
||||
|
||||
|
||||
// Default relay list (can be customized)
|
||||
private static let defaultRelays = [
|
||||
"wss://relay.damus.io",
|
||||
@@ -128,35 +143,30 @@ final class NostrRelayManager: ObservableObject {
|
||||
"wss://relay.primal.net",
|
||||
"wss://offchain.pub",
|
||||
"wss://nostr21.com"
|
||||
// For local testing, you can add: "ws://localhost:8080"
|
||||
]
|
||||
private static let defaultRelaySet = Set(defaultRelays)
|
||||
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
|
||||
|
||||
@Published private(set) public var relays: [Relay] = []
|
||||
@Published private(set) public var isConnected = false
|
||||
|
||||
private let dependencies: NostrRelayManagerDependencies
|
||||
private var allowDefaultRelays: Bool = false
|
||||
private var hasMutualFavorites: Bool = false
|
||||
private var hasLocationPermission: Bool = false
|
||||
private var connections: [String: NostrRelayConnectionProtocol] = [:]
|
||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||
private var subscriptions: [String: Set<String>] = [:]
|
||||
private var pendingSubscriptions: [String: [String: String]] = [:]
|
||||
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
||||
// Coalesce duplicate subscribe requests for the same id within a short window
|
||||
private var subscribeCoalesce: [String: Date] = [:]
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// Track EOSE per subscription to signal when initial stored events are done
|
||||
private struct EOSETracker {
|
||||
var pendingRelays: Set<String>
|
||||
var callback: () -> Void
|
||||
var timer: Timer?
|
||||
}
|
||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||
|
||||
// Message queue for reliability
|
||||
// Pending sends held only for relays that are not yet connected.
|
||||
|
||||
private struct PendingSend {
|
||||
var event: NostrEvent
|
||||
var pendingRelays: Set<String>
|
||||
@@ -165,22 +175,20 @@ final class NostrRelayManager: ObservableObject {
|
||||
private let messageQueueLock = NSLock()
|
||||
private let encoder = JSONEncoder()
|
||||
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
||||
|
||||
// Exponential backoff configuration
|
||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||
private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
|
||||
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
||||
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
||||
|
||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||
|
||||
// Exponential backoff configuration (matches TransportConfig values)
|
||||
private let initialBackoffInterval: TimeInterval = 1.0
|
||||
private let maxBackoffInterval: TimeInterval = 300.0
|
||||
private let backoffMultiplier: Double = 2.0
|
||||
private let maxReconnectAttempts = 10
|
||||
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
init() {
|
||||
self.dependencies = .live()
|
||||
|
||||
public init(dependencies: NostrRelayManagerDependencies) {
|
||||
self.dependencies = dependencies
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
hasLocationPermission = dependencies.hasLocationPermission()
|
||||
applyDefaultRelayPolicy(force: true)
|
||||
// Deterministic JSON shape for outbound requests
|
||||
self.encoder.outputFormatting = .sortedKeys
|
||||
dependencies.mutualFavoritesPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
@@ -202,39 +210,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
internal init(dependencies: NostrRelayManagerDependencies) {
|
||||
self.dependencies = dependencies
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
hasLocationPermission = dependencies.hasLocationPermission()
|
||||
applyDefaultRelayPolicy(force: true)
|
||||
// Deterministic JSON shape for outbound requests
|
||||
self.encoder.outputFormatting = .sortedKeys
|
||||
dependencies.mutualFavoritesPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] favorites in
|
||||
guard let self = self else { return }
|
||||
self.hasMutualFavorites = !favorites.isEmpty
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
dependencies.locationPermissionPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
let authorized = (state == .authorized)
|
||||
if authorized == self.hasLocationPermission { return }
|
||||
self.hasLocationPermission = authorized
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Connect to all configured relays
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
public func connect() {
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
if shouldUseTor {
|
||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if !ready {
|
||||
@@ -253,28 +232,26 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Disconnect from all relays
|
||||
func disconnect() {
|
||||
public func disconnect() {
|
||||
connectionGeneration &+= 1
|
||||
|
||||
for (_, task) in connections {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
// Clear known subscriptions and any queued subs since connections are gone
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
func ensureConnections(to relayUrls: [String]) {
|
||||
// Global network policy gate
|
||||
public func ensureConnections(to relayUrls: [String]) {
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
let targets = allowedRelayList(from: relayUrls)
|
||||
guard !targets.isEmpty else { return }
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if ready { self.ensureConnections(to: relayUrls) }
|
||||
@@ -292,11 +269,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
// Global network policy gate
|
||||
public func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
// Defer sends until Tor is ready to avoid premature queueing
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if ready { self.sendEvent(event, to: relayUrls) }
|
||||
@@ -308,7 +283,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
ensureConnections(to: targetRelays)
|
||||
|
||||
// Attempt immediate send to relays with active connections; queue the rest
|
||||
var stillPending = Set<String>()
|
||||
for relayUrl in targetRelays {
|
||||
if let connection = connections[relayUrl] {
|
||||
@@ -324,13 +298,11 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to flush any queued messages for relays that are now connected.
|
||||
private func flushMessageQueue(for relayUrl: String? = nil) {
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
guard !messageQueue.isEmpty else { return }
|
||||
if let target = relayUrl {
|
||||
// Flush only for a specific relay
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[i]
|
||||
if item.pendingRelays.contains(target), let conn = connections[target] {
|
||||
@@ -344,7 +316,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Flush for any relays that now have connections
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[i]
|
||||
for url in item.pendingRelays {
|
||||
@@ -361,18 +332,16 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
||||
func subscribe(
|
||||
public func subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String = UUID().uuidString,
|
||||
relayUrls: [String]? = nil,
|
||||
handler: @escaping (NostrEvent) -> Void,
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||
let now = dependencies.now()
|
||||
if messageHandlers[id] != nil {
|
||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
||||
@@ -381,7 +350,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
subscribeCoalesce[id] = now
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if ready {
|
||||
@@ -391,23 +359,19 @@ final class NostrRelayManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
messageHandlers[id] = handler
|
||||
|
||||
|
||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||
|
||||
|
||||
do {
|
||||
let message = try encoder.encode(req)
|
||||
guard let messageString = String(data: message, encoding: .utf8) else {
|
||||
guard let messageString = String(data: message, encoding: .utf8) else {
|
||||
SecureLogger.error("❌ Failed to encode subscription request", category: .session)
|
||||
return
|
||||
return
|
||||
}
|
||||
|
||||
// SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
|
||||
|
||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||
|
||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||
let urls = allowedRelayList(from: candidateUrls)
|
||||
// Always queue subscriptions; sending happens when a relay reports connected
|
||||
let existingSet = Set(relays.map { $0.url })
|
||||
for url in urls where !existingSet.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
@@ -417,13 +381,11 @@ final class NostrRelayManager: ObservableObject {
|
||||
map[id] = messageString
|
||||
self.pendingSubscriptions[url] = map
|
||||
}
|
||||
// Initialize EOSE tracking if requested
|
||||
if let onEOSE = onEOSE {
|
||||
if urls.isEmpty {
|
||||
onEOSE()
|
||||
} else {
|
||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
@@ -438,9 +400,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||
// Ensure we actually have sockets opening to these relays so queued REQs can flush
|
||||
ensureConnections(to: urls)
|
||||
// If some targets are already connected, flush immediately for them
|
||||
for url in urls {
|
||||
if let r = relays.first(where: { $0.url == url }), r.isConnected {
|
||||
flushPendingSubscriptions(for: url)
|
||||
@@ -500,56 +460,46 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
/// Unsubscribe from a subscription
|
||||
func unsubscribe(id: String) {
|
||||
public func unsubscribe(id: String) {
|
||||
messageHandlers.removeValue(forKey: id)
|
||||
// Allow immediate re-subscription by clearing coalescer timestamp
|
||||
subscribeCoalesce.removeValue(forKey: id)
|
||||
|
||||
|
||||
let req = NostrRequest.close(id: id)
|
||||
let message = try? encoder.encode(req)
|
||||
|
||||
|
||||
guard let messageData = message,
|
||||
let messageString = String(data: messageData, encoding: .utf8) else { return }
|
||||
|
||||
// Send unsubscribe to all relays
|
||||
|
||||
for (relayUrl, connection) in connections {
|
||||
if subscriptions[relayUrl]?.contains(id) == true {
|
||||
subscriptions[relayUrl]?.remove(id)
|
||||
connection.send(.string(messageString)) { _ in
|
||||
// Local state is cleared before sending so callers can re-subscribe immediately.
|
||||
}
|
||||
connection.send(.string(messageString)) { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
|
||||
private func connectToRelay(_ urlString: String) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
guard let url = URL(string: urlString) else {
|
||||
guard let url = URL(string: urlString) else {
|
||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||
return
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if we already have a connection object
|
||||
|
||||
if connections[urlString] != nil {
|
||||
return
|
||||
}
|
||||
if isPermanentlyFailed(urlString) {
|
||||
return
|
||||
}
|
||||
|
||||
// Attempting to connect to Nostr relay via the proxied session
|
||||
|
||||
// If Tor is enforced but not ready, delay connection until it is.
|
||||
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
@@ -558,35 +508,30 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let session = dependencies.makeSession()
|
||||
let task = session.webSocketTask(with: url)
|
||||
|
||||
|
||||
connections[urlString] = task
|
||||
task.resume()
|
||||
|
||||
// Start receiving messages
|
||||
|
||||
receiveMessage(from: task, relayUrl: urlString)
|
||||
|
||||
// Send initial ping to verify connection
|
||||
|
||||
task.sendPing { [weak self] error in
|
||||
DispatchQueue.main.async {
|
||||
if error == nil {
|
||||
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
|
||||
self?.updateRelayStatus(urlString, isConnected: true)
|
||||
// Flush any pending subscriptions for this relay
|
||||
self?.flushPendingSubscriptions(for: urlString)
|
||||
} else {
|
||||
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
|
||||
self?.updateRelayStatus(urlString, isConnected: false, error: error)
|
||||
// Trigger disconnection handler for proper backoff
|
||||
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send any queued subscriptions for a relay that just connected.
|
||||
private func flushPendingSubscriptions(for relayUrl: String) {
|
||||
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
|
||||
guard let connection = connections[relayUrl] else { return }
|
||||
@@ -606,26 +551,24 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
pendingSubscriptions[relayUrl] = nil
|
||||
}
|
||||
|
||||
|
||||
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||
task.receive { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
// Parse off-main to reduce UI jank, then hop back for state updates
|
||||
Task.detached(priority: .utility) {
|
||||
guard let parsed = ParsedInbound(message) else { return }
|
||||
await MainActor.run {
|
||||
self.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue receiving
|
||||
|
||||
Task { @MainActor in
|
||||
self.receiveMessage(from: task, relayUrl: relayUrl)
|
||||
}
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
self.handleDisconnection(relayUrl: relayUrl, error: error)
|
||||
@@ -633,12 +576,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parsed inbound message type (off-main)
|
||||
// Note: declared at file scope below to avoid MainActor isolation inside this class
|
||||
// and keep parsing off the main actor.
|
||||
|
||||
// Handle parsed message on MainActor (state updates and handlers)
|
||||
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||
switch parsed {
|
||||
case .event(let subId, let event):
|
||||
@@ -680,23 +618,21 @@ final class NostrRelayManager: ObservableObject {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||
let req = NostrRequest.event(event)
|
||||
|
||||
|
||||
do {
|
||||
let data = try encoder.encode(req)
|
||||
let message = String(data: data, encoding: .utf8) ?? ""
|
||||
|
||||
|
||||
SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
|
||||
|
||||
connection.send(.string(message)) { [weak self] error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
|
||||
} else {
|
||||
// SecureLogger.debug("✅ Event sent to relay: \(relayUrl)", category: .session)
|
||||
// Update relay stats
|
||||
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
self?.relays[index].messagesSent += 1
|
||||
}
|
||||
@@ -707,32 +643,30 @@ final class NostrRelayManager: ObservableObject {
|
||||
SecureLogger.error("Failed to encode event: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func updateRelayStatus(_ url: String, isConnected: Bool, error: Error? = nil) {
|
||||
if let index = relays.firstIndex(where: { $0.url == url }) {
|
||||
relays[index].isConnected = isConnected
|
||||
relays[index].lastError = error
|
||||
if isConnected {
|
||||
relays[index].lastConnectedAt = dependencies.now()
|
||||
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
||||
relays[index].reconnectAttempts = 0
|
||||
relays[index].nextReconnectTime = nil
|
||||
} else {
|
||||
relays[index].lastDisconnectedAt = dependencies.now()
|
||||
}
|
||||
}
|
||||
updateConnectionStatus()
|
||||
// If we just connected to this relay, flush any queued sends targeting it
|
||||
if isConnected {
|
||||
flushMessageQueue(for: url)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func updateConnectionStatus() {
|
||||
isConnected = relays.contains { $0.isConnected }
|
||||
}
|
||||
|
||||
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
// If networking is disallowed, do not schedule reconnection
|
||||
if !dependencies.activationAllowed() {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
@@ -742,11 +676,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
|
||||
// Check if this is a DNS or handshake error; treat as permanent
|
||||
|
||||
let errorDescription = error.localizedDescription.lowercased()
|
||||
let ns = error as NSError
|
||||
if errorDescription.contains("hostname could not be found") ||
|
||||
if errorDescription.contains("hostname could not be found") ||
|
||||
errorDescription.contains("dns") ||
|
||||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
|
||||
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
|
||||
@@ -760,106 +693,90 @@ final class NostrRelayManager: ObservableObject {
|
||||
pendingSubscriptions[relayUrl] = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Implement exponential backoff for non-DNS errors
|
||||
|
||||
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
|
||||
|
||||
|
||||
relays[index].reconnectAttempts += 1
|
||||
|
||||
// Stop attempting after max attempts
|
||||
|
||||
if relays[index].reconnectAttempts >= maxReconnectAttempts {
|
||||
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate backoff interval
|
||||
|
||||
let backoffInterval = min(
|
||||
initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)),
|
||||
maxBackoffInterval
|
||||
)
|
||||
|
||||
|
||||
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
|
||||
relays[index].nextReconnectTime = nextReconnectTime
|
||||
|
||||
|
||||
// Schedule reconnection with exponential backoff
|
||||
|
||||
let gen = connectionGeneration
|
||||
dependencies.scheduleAfter(backoffInterval) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Ignore stale scheduled reconnects from a previous generation
|
||||
guard gen == self.connectionGeneration else { return }
|
||||
// Check if we should still reconnect (relay might have been removed)
|
||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||
self.connectToRelay(relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Public Utility Methods
|
||||
|
||||
/// Manually retry connection to a specific relay
|
||||
func retryConnection(to relayUrl: String) {
|
||||
|
||||
public func retryConnection(to relayUrl: String) {
|
||||
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
|
||||
|
||||
// Reset reconnection attempts
|
||||
|
||||
relays[index].reconnectAttempts = 0
|
||||
relays[index].nextReconnectTime = nil
|
||||
relays[index].lastError = nil
|
||||
|
||||
// Disconnect if connected
|
||||
|
||||
if let connection = connections[relayUrl] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
}
|
||||
|
||||
// Attempt immediate reconnection
|
||||
|
||||
connectToRelay(relayUrl)
|
||||
}
|
||||
|
||||
/// Get detailed status for all relays
|
||||
func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] {
|
||||
|
||||
public func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] {
|
||||
return relays.map { relay in
|
||||
(url: relay.url,
|
||||
isConnected: relay.isConnected,
|
||||
(url: relay.url,
|
||||
isConnected: relay.isConnected,
|
||||
reconnectAttempts: relay.reconnectAttempts,
|
||||
nextReconnectTime: relay.nextReconnectTime)
|
||||
}
|
||||
}
|
||||
|
||||
var debugPendingMessageQueueCount: Int {
|
||||
public var debugPendingMessageQueueCount: Int {
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
return messageQueue.count
|
||||
}
|
||||
|
||||
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
||||
public func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
||||
pendingSubscriptions[relayUrl]?.count ?? 0
|
||||
}
|
||||
|
||||
func debugFlushMessageQueue() {
|
||||
public func debugFlushMessageQueue() {
|
||||
flushMessageQueue(for: nil)
|
||||
}
|
||||
|
||||
|
||||
/// Reset all relay connections
|
||||
func resetAllConnections() {
|
||||
public func resetAllConnections() {
|
||||
disconnect()
|
||||
// New generation begins now
|
||||
connectionGeneration &+= 1
|
||||
|
||||
// Reset all relay states
|
||||
|
||||
for index in relays.indices {
|
||||
relays[index].reconnectAttempts = 0
|
||||
relays[index].nextReconnectTime = nil
|
||||
relays[index].lastError = nil
|
||||
}
|
||||
|
||||
// Reconnect
|
||||
|
||||
connect()
|
||||
}
|
||||
|
||||
// MARK: - Failure classification
|
||||
private func isPermanentlyFailed(_ url: String) -> Bool {
|
||||
guard let r = relays.first(where: { $0.url == url }) else { return false }
|
||||
if r.reconnectAttempts >= maxReconnectAttempts { return true }
|
||||
@@ -879,7 +796,7 @@ private enum ParsedInbound {
|
||||
case ok(eventId: String, success: Bool, reason: String)
|
||||
case eose(subscriptionId: String)
|
||||
case notice(String)
|
||||
|
||||
|
||||
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||
guard let data = message.data,
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||
@@ -938,26 +855,26 @@ private extension URLSessionWebSocketTask.Message {
|
||||
|
||||
// MARK: - Nostr Protocol Types
|
||||
|
||||
enum NostrRequest: Encodable {
|
||||
public enum NostrRequest: Encodable {
|
||||
case event(NostrEvent)
|
||||
case subscribe(id: String, filters: [NostrFilter])
|
||||
case close(id: String)
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
|
||||
|
||||
switch self {
|
||||
case .event(let event):
|
||||
try container.encode("EVENT")
|
||||
try container.encode(event)
|
||||
|
||||
|
||||
case .subscribe(let id, let filters):
|
||||
try container.encode("REQ")
|
||||
try container.encode(id)
|
||||
for filter in filters {
|
||||
try container.encode(filter)
|
||||
}
|
||||
|
||||
|
||||
case .close(let id):
|
||||
try container.encode("CLOSE")
|
||||
try container.encode(id)
|
||||
@@ -965,57 +882,51 @@ enum NostrRequest: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
struct NostrFilter: Encodable {
|
||||
var ids: [String]?
|
||||
var authors: [String]?
|
||||
var kinds: [Int]?
|
||||
var since: Int?
|
||||
var until: Int?
|
||||
var limit: Int?
|
||||
|
||||
// Tag filters - stored internally but encoded specially
|
||||
public struct NostrFilter: Encodable {
|
||||
public var ids: [String]?
|
||||
public var authors: [String]?
|
||||
public var kinds: [Int]?
|
||||
public var since: Int?
|
||||
public var until: Int?
|
||||
public var limit: Int?
|
||||
|
||||
fileprivate var tagFilters: [String: [String]]?
|
||||
|
||||
init() {
|
||||
// Default initializer
|
||||
}
|
||||
|
||||
// Custom encoding to handle tag filters properly
|
||||
|
||||
public init() {}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case ids, authors, kinds, since, until, limit
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: DynamicCodingKey.self)
|
||||
|
||||
// Encode standard fields
|
||||
|
||||
if let ids = ids { try container.encode(ids, forKey: DynamicCodingKey(stringValue: "ids")) }
|
||||
if let authors = authors { try container.encode(authors, forKey: DynamicCodingKey(stringValue: "authors")) }
|
||||
if let kinds = kinds { try container.encode(kinds, forKey: DynamicCodingKey(stringValue: "kinds")) }
|
||||
if let since = since { try container.encode(since, forKey: DynamicCodingKey(stringValue: "since")) }
|
||||
if let until = until { try container.encode(until, forKey: DynamicCodingKey(stringValue: "until")) }
|
||||
if let limit = limit { try container.encode(limit, forKey: DynamicCodingKey(stringValue: "limit")) }
|
||||
|
||||
// Encode tag filters with # prefix
|
||||
|
||||
if let tagFilters = tagFilters {
|
||||
for (tag, values) in tagFilters {
|
||||
try container.encode(values, forKey: DynamicCodingKey(stringValue: "#\(tag)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For NIP-17 gift wraps
|
||||
static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
|
||||
|
||||
// For NIP-17 gift wraps (limit matches TransportConfig.nostrRelayDefaultFetchLimit)
|
||||
public static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [1059] // Gift wrap kind
|
||||
filter.kinds = [1059]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["p": [pubkey]]
|
||||
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
|
||||
filter.limit = 100
|
||||
return filter
|
||||
}
|
||||
|
||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
|
||||
public static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [20000, 20001]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
@@ -1025,7 +936,7 @@ struct NostrFilter: Encodable {
|
||||
}
|
||||
|
||||
// For location notes: persistent text notes (kind 1) tagged with geohash
|
||||
static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
public static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [1]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
@@ -1035,7 +946,7 @@ struct NostrFilter: Encodable {
|
||||
}
|
||||
|
||||
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
|
||||
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
public static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [1]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
@@ -1045,15 +956,14 @@ struct NostrFilter: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic coding key for tag filters
|
||||
private struct DynamicCodingKey: CodingKey {
|
||||
var stringValue: String
|
||||
var intValue: Int? { nil }
|
||||
|
||||
|
||||
init(stringValue: String) {
|
||||
self.stringValue = stringValue
|
||||
}
|
||||
|
||||
|
||||
init?(intValue: Int) {
|
||||
return nil
|
||||
}
|
||||
-9
@@ -6,7 +6,6 @@ import CryptoKit
|
||||
/// as per XChaCha20 construction.
|
||||
enum XChaCha20Poly1305Compat {
|
||||
|
||||
/// Errors that can occur during XChaCha20-Poly1305 operations
|
||||
enum Error: Swift.Error {
|
||||
case invalidKeyLength(expected: Int, got: Int)
|
||||
case invalidNonceLength(expected: Int, got: Int)
|
||||
@@ -59,7 +58,6 @@ enum XChaCha20Poly1305Compat {
|
||||
}
|
||||
|
||||
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
|
||||
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
@@ -70,28 +68,22 @@ enum XChaCha20Poly1305Compat {
|
||||
// Constants "expand 32-byte k"
|
||||
var state: [UInt32] = [
|
||||
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
|
||||
// key (8 words)
|
||||
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
|
||||
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
|
||||
// nonce (4 words)
|
||||
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
|
||||
]
|
||||
|
||||
// 20 rounds (10 double rounds)
|
||||
for _ in 0..<10 {
|
||||
// Column rounds
|
||||
quarterRound(&state, 0, 4, 8, 12)
|
||||
quarterRound(&state, 1, 5, 9, 13)
|
||||
quarterRound(&state, 2, 6, 10, 14)
|
||||
quarterRound(&state, 3, 7, 11, 15)
|
||||
// Diagonal rounds
|
||||
quarterRound(&state, 0, 5, 10, 15)
|
||||
quarterRound(&state, 1, 6, 11, 12)
|
||||
quarterRound(&state, 2, 7, 8, 13)
|
||||
quarterRound(&state, 3, 4, 9, 14)
|
||||
}
|
||||
|
||||
// Output subkey: state[0..3] and state[12..15]
|
||||
var out = Data(count: 32)
|
||||
out.storeLEWord(state[0], at: 0)
|
||||
out.storeLEWord(state[1], at: 4)
|
||||
@@ -132,4 +124,3 @@ private extension Data {
|
||||
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Nostr
|
||||
|
||||
@Suite("Bech32")
|
||||
struct Bech32Tests {
|
||||
@Test("Round-trip encode/decode")
|
||||
func roundTrip() throws {
|
||||
let original = Data([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe])
|
||||
let encoded = try Bech32.encode(hrp: "test", data: original)
|
||||
let decoded = try Bech32.decode(encoded)
|
||||
#expect(decoded.hrp == "test")
|
||||
#expect(decoded.data == original)
|
||||
}
|
||||
|
||||
@Test("HRP is preserved")
|
||||
func hrpPreserved() throws {
|
||||
let data = Data(repeating: 0x42, count: 32)
|
||||
let encoded = try Bech32.encode(hrp: "npub", data: data)
|
||||
#expect(encoded.hasPrefix("npub1"))
|
||||
let decoded = try Bech32.decode(encoded)
|
||||
#expect(decoded.hrp == "npub")
|
||||
}
|
||||
|
||||
@Test("Decoding invalid checksum throws")
|
||||
func invalidChecksum() throws {
|
||||
var encoded = try Bech32.encode(hrp: "test", data: Data([1, 2, 3]))
|
||||
// Corrupt the last character
|
||||
encoded = String(encoded.dropLast()) + (encoded.last == "q" ? "p" : "q")
|
||||
#expect(throws: Bech32.Bech32Error.self) {
|
||||
try Bech32.decode(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Decoding string without separator throws")
|
||||
func missingSeparator() {
|
||||
#expect(throws: Bech32.Bech32Error.self) {
|
||||
try Bech32.decode("noseparatorhere")
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
import Testing
|
||||
import struct Foundation.Data
|
||||
@testable import bitchat
|
||||
@testable import Nostr
|
||||
|
||||
struct XChaCha20Poly1305CompatTests {
|
||||
|
||||
Reference in New Issue
Block a user