This commit tries to replace all usage of `String` to represent relay URLs and use `RelayURL` which automatically converts strings to a canonical relay URL format that is more reliable and avoids issues related to trailing slashes. Test 1: Main issue fix ----------------------- PASS Device: iPhone 15 Simulator iOS: 17.4 Damus: This commit Steps: 1. Delete all connected relays 2. Add `wss://relay.damus.io/` (with the trailing slash) to the relay list 3. Try to post. Post should succeed. PASS 4. Try removing this newly added relay. Relay should be removed successfully. PASS Test 2: Persistent relay list after upgrade -------------------------------------------- PASS Device: iPhone 15 Simulator iOS: 17.4 Damus: 1.8 (1) `247f313b` + This commit Steps: 1. Downgrade to old version 2. Add some relays to the list, some without a trailing slash, some with 3. Upgrade to this commit 4. All relays added in step 2 should still be there, and ones with a trailing slash should have been corrected to remove the trailing slash Test 3: Miscellaneous regression tests -------------------------------------- Device: iPhone 15 Simulator iOS: 17.4 Damus: This commit Coverage: 1. Posting works 2. Search works 3. Relay connection status works 4. Adding relays work 5. Removing relays work 6. Adding relay with trailing slashes works (it fixes itself to remove the trailing slash) 7. Adding relays with different paths works (e.g. wss://yabu.me/v1 and wss://yabu.me/v2) 8. Adding duplicate relay (but with trailing slash) gets rejected as expected 9. Relay details page works. All items on that view loads correctly 10. Relay logs work 11. Getting follower counts and seeing follow lists on profiles still work 12. Relay list changes persist after app restart 13. Notifications view still work 14. Copying the user's pubkey and profile link works 15. Share note + copy link button still works 16. Connecting NWC wallet works 17. One-tap zaps work 18. Onboarding works 19. Unit tests all passing Closes: https://github.com/damus-io/damus/issues/2072 Changelog-Fixed: Fix bug that would cause connection issues with relays defined with a trailing slash URL, and an inability to delete them. Signed-off-by: Daniel D’Aquino <daniel@daquino.me> Signed-off-by: William Casarin <jb55@jb55.com>
247 lines
7.2 KiB
Swift
247 lines
7.2 KiB
Swift
//
|
|
// NostrConnection.swift
|
|
// damus
|
|
//
|
|
// Created by William Casarin on 2022-04-02.
|
|
//
|
|
|
|
import Combine
|
|
import Foundation
|
|
|
|
enum NostrConnectionEvent {
|
|
case ws_event(WebSocketEvent)
|
|
case nostr_event(NostrResponse)
|
|
}
|
|
|
|
final class RelayConnection: ObservableObject {
|
|
@Published private(set) var isConnected = false
|
|
@Published private(set) var isConnecting = false
|
|
private var isDisabled = false
|
|
|
|
private(set) var last_connection_attempt: TimeInterval = 0
|
|
private(set) var last_pong: Date? = nil
|
|
private(set) var backoff: TimeInterval = 1.0
|
|
private lazy var socket = WebSocket(relay_url.url)
|
|
private var subscriptionToken: AnyCancellable?
|
|
|
|
private var handleEvent: (NostrConnectionEvent) -> ()
|
|
private var processEvent: (WebSocketEvent) -> ()
|
|
private let relay_url: RelayURL
|
|
var log: RelayLog?
|
|
|
|
init(url: RelayURL,
|
|
handleEvent: @escaping (NostrConnectionEvent) -> (),
|
|
processEvent: @escaping (WebSocketEvent) -> ())
|
|
{
|
|
self.relay_url = url
|
|
self.handleEvent = handleEvent
|
|
self.processEvent = processEvent
|
|
}
|
|
|
|
func ping() {
|
|
socket.ping { [weak self] err in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
if err == nil {
|
|
self.last_pong = .now
|
|
self.log?.add("Successful ping")
|
|
} else {
|
|
print("pong failed, reconnecting \(self.relay_url.id)")
|
|
self.isConnected = false
|
|
self.isConnecting = false
|
|
self.reconnect_with_backoff()
|
|
self.log?.add("Ping failed")
|
|
}
|
|
}
|
|
}
|
|
|
|
func connect(force: Bool = false) {
|
|
if !force && (isConnected || isConnecting) {
|
|
return
|
|
}
|
|
|
|
isConnecting = true
|
|
last_connection_attempt = Date().timeIntervalSince1970
|
|
|
|
subscriptionToken = socket.subject
|
|
.receive(on: DispatchQueue.global(qos: .default))
|
|
.sink { [weak self] completion in
|
|
switch completion {
|
|
case .failure(let error):
|
|
self?.receive(event: .error(error))
|
|
case .finished:
|
|
self?.receive(event: .disconnected(.normalClosure, nil))
|
|
}
|
|
} receiveValue: { [weak self] event in
|
|
self?.receive(event: event)
|
|
}
|
|
|
|
socket.connect()
|
|
}
|
|
|
|
func disconnect() {
|
|
socket.disconnect()
|
|
subscriptionToken = nil
|
|
|
|
isConnected = false
|
|
isConnecting = false
|
|
}
|
|
|
|
func disablePermanently() {
|
|
isDisabled = true
|
|
}
|
|
|
|
func send_raw(_ req: String) {
|
|
socket.send(.string(req))
|
|
}
|
|
|
|
func send(_ req: NostrRequestType, callback: ((String) -> Void)? = nil) {
|
|
switch req {
|
|
case .typical(let req):
|
|
guard let req = make_nostr_req(req) else {
|
|
print("failed to encode nostr req: \(req)")
|
|
return
|
|
}
|
|
send_raw(req)
|
|
callback?(req)
|
|
|
|
case .custom(let req):
|
|
send_raw(req)
|
|
callback?(req)
|
|
}
|
|
}
|
|
|
|
private func receive(event: WebSocketEvent) {
|
|
processEvent(event)
|
|
switch event {
|
|
case .connected:
|
|
DispatchQueue.main.async {
|
|
self.backoff = 1.0
|
|
self.isConnected = true
|
|
self.isConnecting = false
|
|
}
|
|
case .message(let message):
|
|
self.receive(message: message)
|
|
case .disconnected(let closeCode, let reason):
|
|
if closeCode != .normalClosure {
|
|
print("⚠️ Warning: RelayConnection (\(self.relay_url)) closed with code \(closeCode), reason: \(String(describing: reason))")
|
|
}
|
|
DispatchQueue.main.async {
|
|
self.isConnected = false
|
|
self.isConnecting = false
|
|
self.reconnect()
|
|
}
|
|
case .error(let error):
|
|
print("⚠️ Warning: RelayConnection (\(self.relay_url)) error: \(error)")
|
|
let nserr = error as NSError
|
|
if nserr.domain == NSPOSIXErrorDomain && nserr.code == 57 {
|
|
// ignore socket not connected?
|
|
return
|
|
}
|
|
DispatchQueue.main.async {
|
|
self.isConnected = false
|
|
self.isConnecting = false
|
|
self.reconnect_with_backoff()
|
|
}
|
|
}
|
|
DispatchQueue.main.async {
|
|
self.handleEvent(.ws_event(event))
|
|
}
|
|
|
|
if let description = event.description {
|
|
log?.add(description)
|
|
}
|
|
}
|
|
|
|
func reconnect_with_backoff() {
|
|
self.backoff *= 1.5
|
|
self.reconnect_in(after: self.backoff)
|
|
}
|
|
|
|
func reconnect() {
|
|
guard !isConnecting && !isDisabled else {
|
|
return // we're already trying to connect or we're disabled
|
|
}
|
|
disconnect()
|
|
connect()
|
|
log?.add("Reconnecting...")
|
|
}
|
|
|
|
func reconnect_in(after: TimeInterval) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + after) {
|
|
self.reconnect()
|
|
}
|
|
}
|
|
|
|
private func receive(message: URLSessionWebSocketTask.Message) {
|
|
switch message {
|
|
case .string(let messageString):
|
|
if let ev = decode_nostr_event(txt: messageString) {
|
|
DispatchQueue.main.async {
|
|
self.handleEvent(.nostr_event(ev))
|
|
}
|
|
return
|
|
}
|
|
print("failed to decode event \(messageString)")
|
|
case .data(let messageData):
|
|
if let messageString = String(data: messageData, encoding: .utf8) {
|
|
receive(message: .string(messageString))
|
|
}
|
|
@unknown default:
|
|
print("An unexpected URLSessionWebSocketTask.Message was received.")
|
|
}
|
|
}
|
|
}
|
|
|
|
func make_nostr_req(_ req: NostrRequest) -> String? {
|
|
switch req {
|
|
case .subscribe(let sub):
|
|
return make_nostr_subscription_req(sub.filters, sub_id: sub.sub_id)
|
|
case .unsubscribe(let sub_id):
|
|
return make_nostr_unsubscribe_req(sub_id)
|
|
case .event(let ev):
|
|
return make_nostr_push_event(ev: ev)
|
|
case .auth(let ev):
|
|
return make_nostr_auth_event(ev: ev)
|
|
}
|
|
}
|
|
|
|
func make_nostr_auth_event(ev: NostrEvent) -> String? {
|
|
guard let event = encode_json(ev) else {
|
|
return nil
|
|
}
|
|
let encoded = "[\"AUTH\",\(event)]"
|
|
print(encoded)
|
|
return encoded
|
|
}
|
|
|
|
func make_nostr_push_event(ev: NostrEvent) -> String? {
|
|
guard let event = encode_json(ev) else {
|
|
return nil
|
|
}
|
|
let encoded = "[\"EVENT\",\(event)]"
|
|
print(encoded)
|
|
return encoded
|
|
}
|
|
|
|
func make_nostr_unsubscribe_req(_ sub_id: String) -> String? {
|
|
"[\"CLOSE\",\"\(sub_id)\"]"
|
|
}
|
|
|
|
func make_nostr_subscription_req(_ filters: [NostrFilter], sub_id: String) -> String? {
|
|
let encoder = JSONEncoder()
|
|
var req = "[\"REQ\",\"\(sub_id)\""
|
|
for filter in filters {
|
|
req += ","
|
|
guard let filter_json = try? encoder.encode(filter) else {
|
|
return nil
|
|
}
|
|
let filter_json_str = String(decoding: filter_json, as: UTF8.self)
|
|
req += filter_json_str
|
|
}
|
|
req += "]"
|
|
return req
|
|
}
|