Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Clave.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -99,6 +100,7 @@
C1A4F2B3C5D6E700000010 /* AccountTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTheme.swift; sourceTree = "<group>"; };
DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = "<group>"; };
DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExporter.swift; sourceTree = "<group>"; };
DE7E10C4DE7E10C4DE7E10C4 /* RelayUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayUtils.swift; sourceTree = "<group>"; };
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; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
83 changes: 3 additions & 80 deletions Clave/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,28 @@
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
)
XCTAssertEqual(result.count, 0)
}

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)
}
}
71 changes: 71 additions & 0 deletions Shared/RelayUtils.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}