diff --git a/Clave.xcodeproj/project.pbxproj b/Clave.xcodeproj/project.pbxproj index 9313534..666146e 100644 --- a/Clave.xcodeproj/project.pbxproj +++ b/Clave.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ DE7E10B3DE7E10B3DE7E10B3 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */; }; DE7E10C2DE7E10C2DE7E10C2 /* LogExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */; }; DE7E10C3DE7E10C3DE7E10C3 /* LogExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */; }; + DE7E10C5DE7E10C5DE7E10C5 /* RelayUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10C4DE7E10C4DE7E10C4 /* RelayUtils.swift */; }; EF1963B89F3A6472380F1E0A /* LightRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF3D7A5B2F8BD020005A6545 /* LightRelay.swift */; }; EF251FEC744434349FD7256D /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = EF609A96DAD7E5693CA7EE4D /* P256K */; }; EF304CFAA8D958E7CCDF01AD /* LightCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF3D7A592F8BD020005A6545 /* LightCrypto.swift */; }; @@ -99,6 +100,7 @@ C1A4F2B3C5D6E700000010 /* AccountTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTheme.swift; sourceTree = ""; }; DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExporter.swift; sourceTree = ""; }; + DE7E10C4DE7E10C4DE7E10C4 /* RelayUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayUtils.swift; sourceTree = ""; }; EF3D7A0F2F8BCAE3005A6545 /* Clave.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Clave.app; sourceTree = BUILT_PRODUCTS_DIR; }; EF3D7A1C2F8BCAE6005A6545 /* ClaveTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ClaveTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EF3D7A262F8BCAE6005A6545 /* ClaveUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ClaveUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -237,6 +239,7 @@ EF3D7A5B2F8BD020005A6545 /* LightRelay.swift */, EF3D7A5C2F8BD020005A6545 /* LightSigner.swift */, AE01F2A3B4C5D6E700000004 /* Nip19.swift */, + DE7E10C4DE7E10C4DE7E10C4 /* RelayUtils.swift */, EF3D7A5D2F8BD020005A6545 /* SharedConstants.swift */, EF3D7A5E2F8BD020005A6545 /* SharedKeychain.swift */, AE01F2A3B4C5D6E700000007 /* SharedKeychain+Enumeration.swift */, @@ -447,6 +450,7 @@ F60FCE012F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift in Sources */, B0EBA01E2F90AB01000A0001 /* PendingApprovalBanner.swift in Sources */, DE7E10C2DE7E10C2DE7E10C2 /* LogExporter.swift in Sources */, + DE7E10C5DE7E10C5DE7E10C5 /* RelayUtils.swift in Sources */, AE01F2A3B4C5D6E700000002 /* ActivitySummary.swift in Sources */, AE01F2A3B4C5D6E700000005 /* Nip19.swift in Sources */, AE01F2A3B4C5D6E700000008 /* SharedKeychain+Enumeration.swift in Sources */, diff --git a/Clave/AppState.swift b/Clave/AppState.swift index fd9efdf..16899cb 100644 --- a/Clave/AppState.swift +++ b/Clave/AppState.swift @@ -1582,7 +1582,7 @@ final class AppState { } // Connect to every URI relay in parallel, best-effort. - let connectedRelays = await connectToRelays(urls: parsedURI.relays, timeout: 10.0) + let connectedRelays = await RelayUtils.connectToRelays(urls: parsedURI.relays, timeout: 10.0) defer { for relay in connectedRelays { relay.disconnect() } } @@ -1641,7 +1641,7 @@ final class AppState { if !handshakeComplete, let eventData = connectEvent.toJSON().data(using: .utf8), let eventDict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any] { - let acceptedCount = await publishEventToRelays(connectedRelays, event: eventDict) + let acceptedCount = await RelayUtils.publishEventToRelays(connectedRelays, event: eventDict) if !activityLogged { let success = acceptedCount > 0 @@ -1680,7 +1680,7 @@ final class AppState { "since": now - 10, "limit": 10 ] - let events = await fetchEventsFromRelays(connectedRelays, filter: listenFilter, timeout: 3.0) + let events = await RelayUtils.fetchEventsFromRelays(connectedRelays, filter: listenFilter, timeout: 3.0) for event in events { guard let eventId = event["id"] as? String, seenEventIds.insert(eventId).inserted else { continue } guard let pubkey = event["pubkey"] as? String, @@ -2258,81 +2258,4 @@ final class AppState { }.resume() } - // MARK: - Multi-relay helpers (nostrconnect handshake) - - /// Connect to multiple relays in parallel, best-effort. - /// Returns only the relays that connected successfully within the timeout. - /// Failures are silently dropped so one unreachable relay never blocks the others. - private func connectToRelays(urls: [String], timeout: TimeInterval) async -> [LightRelay] { - await withTaskGroup(of: LightRelay?.self) { group in - for url in urls { - group.addTask { - let relay = LightRelay(url: url) - do { - try await relay.connect(timeout: timeout) - return relay - } catch { - return nil - } - } - } - var connected: [LightRelay] = [] - for await maybe in group { - if let relay = maybe { connected.append(relay) } - } - return connected - } - } - - /// Publish the same event to all connected relays in parallel. - /// Returns the number of relays that returned `OK true`. - private func publishEventToRelays(_ relays: [LightRelay], event: [String: Any]) async -> Int { - await withTaskGroup(of: Bool.self) { group in - for relay in relays { - group.addTask { - (try? await relay.publishEvent(event: event)) ?? false - } - } - var accepted = 0 - for await ok in group { - if ok { accepted += 1 } - } - return accepted - } - } - - /// Fetch events matching the filter from all connected relays in parallel. - /// Aggregates results; duplicates by event id are NOT removed (caller should handle). - private func fetchEventsFromRelays( - _ relays: [LightRelay], - filter: [String: Any], - timeout: TimeInterval - ) async -> [[String: Any]] { - await withTaskGroup(of: [[String: Any]].self) { group in - for relay in relays { - group.addTask { - (try? await relay.fetchEvents(filter: filter, timeout: timeout)) ?? [] - } - } - var all: [[String: Any]] = [] - for await events in group { - all.append(contentsOf: events) - } - return all - } - } - - // MARK: - Test-only shims - - #if DEBUG - func _testOnlyConnectToRelays(urls: [String], timeout: TimeInterval) async -> [LightRelay] { - await connectToRelays(urls: urls, timeout: timeout) - } - func _testOnlyPublishEventToRelays(_ relays: [LightRelay], event: [String: Any]) async -> Int { - await publishEventToRelays(relays, event: event) - } - func _testOnlyFetchEventsFromRelays(_ relays: [LightRelay], filter: [String: Any], timeout: TimeInterval) async -> [[String: Any]] { - await fetchEventsFromRelays(relays, filter: filter, timeout: timeout) - } - #endif } diff --git a/ClaveTests/AppStateMultiRelayHelpersTests.swift b/ClaveTests/RelayUtilsTests.swift similarity index 53% rename from ClaveTests/AppStateMultiRelayHelpersTests.swift rename to ClaveTests/RelayUtilsTests.swift index ee82a31..ab6dd34 100644 --- a/ClaveTests/AppStateMultiRelayHelpersTests.swift +++ b/ClaveTests/RelayUtilsTests.swift @@ -1,17 +1,15 @@ import XCTest @testable import Clave -final class AppStateMultiRelayHelpersTests: XCTestCase { +final class RelayUtilsTests: XCTestCase { func testConnectToRelaysReturnsEmptyForEmptyInput() async { - let appState = AppState() - let result = await appState._testOnlyConnectToRelays(urls: [], timeout: 1.0) + let result = await RelayUtils.connectToRelays(urls: [], timeout: 1.0) XCTAssertEqual(result.count, 0) } func testConnectToRelaysSkipsUnreachableURLs() async { - let appState = AppState() - let result = await appState._testOnlyConnectToRelays( + let result = await RelayUtils.connectToRelays( urls: ["wss://127.0.0.1:1", "wss://not-a-real-relay.invalid.test"], timeout: 1.5 ) @@ -19,14 +17,12 @@ final class AppStateMultiRelayHelpersTests: XCTestCase { } func testPublishEventToRelaysReturnsZeroForEmptyInput() async { - let appState = AppState() - let count = await appState._testOnlyPublishEventToRelays([], event: ["kind": 1]) + let count = await RelayUtils.publishEventToRelays([], event: ["kind": 1]) XCTAssertEqual(count, 0) } func testFetchEventsFromRelaysReturnsEmptyForEmptyInput() async { - let appState = AppState() - let events = await appState._testOnlyFetchEventsFromRelays([], filter: [:], timeout: 1.0) + let events = await RelayUtils.fetchEventsFromRelays([], filter: [:], timeout: 1.0) XCTAssertEqual(events.count, 0) } } diff --git a/Shared/RelayUtils.swift b/Shared/RelayUtils.swift new file mode 100644 index 0000000..712fd10 --- /dev/null +++ b/Shared/RelayUtils.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Pure parallel-fanout helpers for multi-relay operations during the nostrconnect handshake. +/// +/// All three methods are best-effort: failures are silently dropped so one unreachable +/// relay never blocks the others. Callers that need per-relay status reporting should +/// extend this namespace (see BACKLOG: "Better error-message detail in nostrconnect Activity log"). +enum RelayUtils { + + /// Connect to multiple relays in parallel, best-effort. + /// Returns only the relays that connected successfully within the timeout. + /// Failures are silently dropped so one unreachable relay never blocks the others. + static func connectToRelays(urls: [String], timeout: TimeInterval) async -> [LightRelay] { + await withTaskGroup(of: LightRelay?.self) { group in + for url in urls { + group.addTask { + let relay = LightRelay(url: url) + do { + try await relay.connect(timeout: timeout) + return relay + } catch { + return nil + } + } + } + var connected: [LightRelay] = [] + for await maybe in group { + if let relay = maybe { connected.append(relay) } + } + return connected + } + } + + /// Publish the same event to all connected relays in parallel. + /// Returns the number of relays that returned `OK true`. + static func publishEventToRelays(_ relays: [LightRelay], event: [String: Any]) async -> Int { + await withTaskGroup(of: Bool.self) { group in + for relay in relays { + group.addTask { + (try? await relay.publishEvent(event: event)) ?? false + } + } + var accepted = 0 + for await ok in group { + if ok { accepted += 1 } + } + return accepted + } + } + + /// Fetch events matching the filter from all connected relays in parallel. + /// Aggregates results; duplicates by event id are NOT removed (caller should handle). + static func fetchEventsFromRelays( + _ relays: [LightRelay], + filter: [String: Any], + timeout: TimeInterval + ) async -> [[String: Any]] { + await withTaskGroup(of: [[String: Any]].self) { group in + for relay in relays { + group.addTask { + (try? await relay.fetchEvents(filter: filter, timeout: timeout)) ?? [] + } + } + var all: [[String: Any]] = [] + for await events in group { + all.append(contentsOf: events) + } + return all + } + } +}