Files
damus/damus/Nostr/RelayConnection.swift
Daniel D’Aquino e951370a76 Fix relay URL trailing slash issues
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>
2024-03-25 09:24:17 +01:00

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
}