diff --git a/Example/IntegrationTests/Push/NotifyTests.swift b/Example/IntegrationTests/Push/NotifyTests.swift index 1fbb8aaae..18408e9af 100644 --- a/Example/IntegrationTests/Push/NotifyTests.swift +++ b/Example/IntegrationTests/Push/NotifyTests.swift @@ -18,7 +18,7 @@ final class NotifyTests: XCTestCase { let gmDappDomain = InputConfig.gmDappHost - let pk = try! EthereumPrivateKey() + var pk: EthereumPrivateKey! var privateKey: Data { return Data(pk.rawPrivateKey) @@ -93,57 +93,64 @@ final class NotifyTests: XCTestCase { } override func setUp() { + pk = try! EthereumPrivateKey() walletNotifyClientA = makeWalletClient() } - func testWalletCreatesSubscription() async { + func testWalletCreatesSubscription() async throws { let expectation = expectation(description: "expects to create notify subscription") + expectation.assertForOverFulfill = false + + var subscription: NotifySubscription? walletNotifyClientA.subscriptionsPublisher - .sink { [unowned self] subscriptions in - guard let subscription = subscriptions.first else { return } - Task(priority: .high) { - try await walletNotifyClientA.deleteSubscription(topic: subscription.topic) - expectation.fulfill() - } + .sink { subscriptions in + subscription = subscriptions.first + expectation.fulfill() }.store(in: &publishers) - try! await walletNotifyClientA.register(account: account, domain: gmDappDomain, onSign: sign) - try! await walletNotifyClientA.subscribe(appDomain: gmDappDomain, account: account) + try await walletNotifyClientA.register(account: account, domain: gmDappDomain, onSign: sign) + try await walletNotifyClientA.subscribe(appDomain: gmDappDomain, account: account) - wait(for: [expectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [expectation], timeout: InputConfig.defaultTimeout) + + if let subscription { + try await walletNotifyClientA.deleteSubscription(topic: subscription.topic) + } } func testNotifyWatchSubscriptions() async throws { let expectation = expectation(description: "expects client B to receive subscription created by client A") expectation.assertForOverFulfill = false + var subscription: NotifySubscription? + let clientB = makeWalletClient(prefix: "👐🏼 Wallet B: ") clientB.subscriptionsPublisher.sink { subscriptions in - guard let subscription = subscriptions.first else { return } - Task(priority: .high) { - try await clientB.deleteSubscription(topic: subscription.topic) - expectation.fulfill() - } + subscription = subscriptions.first + expectation.fulfill() }.store(in: &publishers) try! await walletNotifyClientA.register(account: account, domain: gmDappDomain, onSign: sign) try! await walletNotifyClientA.subscribe(appDomain: gmDappDomain, account: account) try! await clientB.register(account: account, domain: gmDappDomain, onSign: sign) - wait(for: [expectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [expectation], timeout: InputConfig.defaultTimeout) + + if let subscription { + try await clientB.deleteSubscription(topic: subscription.topic) + } } func testNotifySubscriptionChanged() async throws { let expectation = expectation(description: "expects client B to receive subscription after both clients are registered and client A creates one") expectation.assertForOverFulfill = false - var subscription: NotifySubscription! + var subscription: NotifySubscription? let clientB = makeWalletClient(prefix: "👐🏼 Wallet B: ") clientB.subscriptionsPublisher.sink { subscriptions in - guard let newSubscription = subscriptions.first else { return } - subscription = newSubscription + subscription = subscriptions.first expectation.fulfill() }.store(in: &publishers) @@ -151,46 +158,49 @@ final class NotifyTests: XCTestCase { try! await clientB.register(account: account, domain: gmDappDomain, onSign: sign) try! await walletNotifyClientA.subscribe(appDomain: gmDappDomain, account: account) - wait(for: [expectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [expectation], timeout: InputConfig.defaultTimeout) - try await clientB.deleteSubscription(topic: subscription.topic) + if let subscription { + try await clientB.deleteSubscription(topic: subscription.topic) + } } - func testWalletCreatesAndUpdatesSubscription() async { - let expectation = expectation(description: "expects to create and update notify subscription") - expectation.assertForOverFulfill = false + func testWalletCreatesAndUpdatesSubscription() async throws { + let created = expectation(description: "Subscription created") - var updateScope: Set! - var didUpdate = false - - walletNotifyClientA.subscriptionsPublisher - .sink { [unowned self] subscriptions in - guard - let subscription = subscriptions.first, - let scope = subscription.scope.keys.first - else { return } + let updated = expectation(description: "Subscription Updated") - let updatedScope = Set(subscription.scope.filter { $0.value.enabled == true }.keys) + var isCreated = false + var isUpdated = false + var subscription: NotifySubscription! - if !didUpdate { - updateScope = Set([scope]) - didUpdate = true - Task(priority: .high) { - try await walletNotifyClientA.update(topic: subscription.topic, scope: Set([scope])) - } - } - if updateScope == updatedScope { - Task(priority: .high) { - try await walletNotifyClientA.deleteSubscription(topic: subscription.topic) - expectation.fulfill() - } + walletNotifyClientA.subscriptionsPublisher + .sink { subscriptions in + subscription = subscriptions.first + + if !isCreated { + isCreated = true + created.fulfill() + } else if !isUpdated { + isUpdated = true + updated.fulfill() } }.store(in: &publishers) - try! await walletNotifyClientA.register(account: account, domain: gmDappDomain, onSign: sign) - try! await walletNotifyClientA.subscribe(appDomain: gmDappDomain, account: account) + try await walletNotifyClientA.register(account: account, domain: gmDappDomain, onSign: sign) + try await walletNotifyClientA.subscribe(appDomain: gmDappDomain, account: account) + + await fulfillment(of: [created], timeout: InputConfig.defaultTimeout) + + let updateScope = Set([subscription.scope.keys.first!]) + try await walletNotifyClientA.update(topic: subscription.topic, scope: updateScope) + + await fulfillment(of: [updated], timeout: InputConfig.defaultTimeout) - wait(for: [expectation], timeout: InputConfig.defaultTimeout) + let updatedScope = Set(subscription.scope.filter { $0.value.enabled == true }.keys) + XCTAssertEqual(updatedScope, updateScope) + + try await walletNotifyClientA.deleteSubscription(topic: subscription.topic) } func testNotifyServerSubscribeAndNotifies() async throws { @@ -198,6 +208,7 @@ final class NotifyTests: XCTestCase { let messageExpectation = expectation(description: "receives a notify message") var notifyMessage: NotifyMessage! + var notifyMessageRecord: NotifyMessageRecord? var didNotify = false walletNotifyClientA.subscriptionsPublisher @@ -221,20 +232,22 @@ final class NotifyTests: XCTestCase { } }.store(in: &publishers) - walletNotifyClientA.notifyMessagePublisher - .sink { [unowned self] notifyMessageRecord in - XCTAssertEqual(notifyMessageRecord.message, notifyMessage) - - Task(priority: .high) { - try await walletNotifyClientA.deleteSubscription(topic: notifyMessageRecord.topic) - messageExpectation.fulfill() - } + walletNotifyClientA.messagesPublisher + .sink { messages in + guard let newNotifyMessageRecord = messages.first else { return } + XCTAssertEqual(newNotifyMessageRecord.message, notifyMessage) + notifyMessageRecord = newNotifyMessageRecord + messageExpectation.fulfill() }.store(in: &publishers) try! await walletNotifyClientA.register(account: account, domain: gmDappDomain, onSign: sign) try! await walletNotifyClientA.subscribe(appDomain: gmDappDomain, account: account) - wait(for: [subscribeExpectation, messageExpectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [subscribeExpectation, messageExpectation], timeout: InputConfig.defaultTimeout) + + if let notifyMessageRecord { + try await walletNotifyClientA.deleteSubscription(topic: notifyMessageRecord.topic) + } } } diff --git a/Example/WalletApp/PresentationLayer/Wallet/Notifications/NotificationsInteractor.swift b/Example/WalletApp/PresentationLayer/Wallet/Notifications/NotificationsInteractor.swift index 1a1bd5b8f..a7f6c0c19 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/Notifications/NotificationsInteractor.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/Notifications/NotificationsInteractor.swift @@ -5,7 +5,7 @@ import Combine final class NotificationsInteractor { var subscriptionsPublisher: AnyPublisher<[NotifySubscription], Never> { - return Notify.instance.subscriptionsPublisher + return Notify.instance.subscriptionsPublisher(account: importAccount.account) } private let importAccount: ImportAccount diff --git a/Package.swift b/Package.swift index 0f7ce4e77..dcfa42b87 100644 --- a/Package.swift +++ b/Package.swift @@ -132,7 +132,7 @@ let package = Package( dependencies: ["WalletConnectUtils", "WalletConnectNetworking"]), .target( name: "Database", - dependencies: []), + dependencies: ["WalletConnectUtils"]), .target( name: "WalletConnectModal", dependencies: ["QRCode", "WalletConnectSign"], diff --git a/Sources/Database/DatabaseImports.swift b/Sources/Database/DatabaseImports.swift new file mode 100644 index 000000000..39a0c89b7 --- /dev/null +++ b/Sources/Database/DatabaseImports.swift @@ -0,0 +1,3 @@ +#if !CocoaPods +@_exported import WalletConnectUtils +#endif diff --git a/Sources/Database/DiskSqlite.swift b/Sources/Database/DiskSqlite.swift index 19ffba05e..8d7b77c49 100644 --- a/Sources/Database/DiskSqlite.swift +++ b/Sources/Database/DiskSqlite.swift @@ -7,40 +7,50 @@ public final class DiskSqlite: Sqlite { private var db: OpaquePointer? + private let lock = UnfairLock() + public init(path: String) { self.path = path } public func openDatabase() throws { - guard sqlite3_open_v2(path, &db, SQLITE_OPEN_CREATE|SQLITE_OPEN_READWRITE|SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else { - throw SQLiteError.openDatabase(path: path) + try lock.locked { + guard sqlite3_open_v2(path, &db, SQLITE_OPEN_CREATE|SQLITE_OPEN_READWRITE|SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else { + throw SQLiteError.openDatabase(path: path) + } } } public func query(sql: String) throws -> [Row] { - var queryStatement: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &queryStatement, nil) == SQLITE_OK else { - throw SQLiteError.queryPrepare(statement: sql) - } - var rows: [Row] = [] - while sqlite3_step(queryStatement) == SQLITE_ROW { - let decoder = SqliteRowDecoder(statement: queryStatement) - guard let row = try? Row(decoder: decoder) else { continue } - rows.append(row) + return try lock.locked { + var queryStatement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &queryStatement, nil) == SQLITE_OK else { + throw SQLiteError.queryPrepare(statement: sql) + } + var rows: [Row] = [] + while sqlite3_step(queryStatement) == SQLITE_ROW { + let decoder = SqliteRowDecoder(statement: queryStatement) + guard let row = try? Row(decoder: decoder) else { continue } + rows.append(row) + } + sqlite3_finalize(queryStatement) + return rows } - sqlite3_finalize(queryStatement) - return rows } public func execute(sql: String) throws { - var error: UnsafeMutablePointer? - guard sqlite3_exec(db, sql, nil, nil, &error) == SQLITE_OK else { - let message = error.map { String(cString: $0) } - throw SQLiteError.exec(error: message) + try lock.locked { + var error: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &error) == SQLITE_OK else { + let message = error.map { String(cString: $0) } + throw SQLiteError.exec(error: message) + } } } public func closeConnection() { - sqlite3_close(db) + lock.locked { + sqlite3_close(db) + } } } diff --git a/Sources/WalletConnectModal/Extensions/View+Backport.swift b/Sources/WalletConnectModal/Extensions/View+Backport.swift index b93de0e16..3c40e44ba 100644 --- a/Sources/WalletConnectModal/Extensions/View+Backport.swift +++ b/Sources/WalletConnectModal/Extensions/View+Backport.swift @@ -3,7 +3,7 @@ import SwiftUI extension View { - #if os(iOS) + #if os(iOS) || os(tvOS) /// A backwards compatible wrapper for iOS 14 `onChange` @ViewBuilder @@ -27,4 +27,24 @@ extension View { } #endif + + #if os(iOS) || os(macOS) + + @ViewBuilder + func onTapGestureBackported(count: Int = 1, perform action: @escaping () -> Void) -> some View { + self + } + + #elseif os(tvOS) + + @ViewBuilder + func onTapGestureBackported(count: Int = 1, perform action: @escaping () -> Void) -> some View { + if #available(tvOS 16.0, *) { + self.onTapGesture(count: count, perform: action) + } else { + self + } + } + + #endif } diff --git a/Sources/WalletConnectModal/Modal/ModalContainerView.swift b/Sources/WalletConnectModal/Modal/ModalContainerView.swift index ab24e444b..ba5ffb9cf 100644 --- a/Sources/WalletConnectModal/Modal/ModalContainerView.swift +++ b/Sources/WalletConnectModal/Modal/ModalContainerView.swift @@ -25,14 +25,10 @@ struct ModalContainerView: View { Color.thickOverlay .colorScheme(.light) .opacity(showModal ? 1 : 0) - .transform { - #if os(iOS) - $0.onTapGesture { - withAnimation { - showModal = false - } - } - #endif + .onTapGestureBackported { + withAnimation { + showModal = false + } } ) .edgesIgnoringSafeArea(.all) diff --git a/Sources/WalletConnectModal/Modal/Screens/QRCodeView.swift b/Sources/WalletConnectModal/Modal/Screens/QRCodeView.swift index ab78e24cb..e889fd5c7 100644 --- a/Sources/WalletConnectModal/Modal/Screens/QRCodeView.swift +++ b/Sources/WalletConnectModal/Modal/Screens/QRCodeView.swift @@ -59,9 +59,13 @@ struct QRCodeView: View { ) ) - return doc.imageUI( - size, label: Text("QR code with URI") - )! + if #available(macOS 11, *) { + return doc.imageUI( + size, label: Text("QR code with URI") + )! + } else { + return Image.init(sfSymbolName: "qrcode") + } } } diff --git a/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetail.swift b/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetail.swift index 2fb5f1bff..6ff5a740a 100644 --- a/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetail.swift +++ b/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetail.swift @@ -31,7 +31,7 @@ struct WalletDetail: View { .contentShape(Rectangle()) .padding(.horizontal, 8) .padding(.vertical, 8) - .onTapGesture { + .onTapGestureBackported { withAnimation(.easeInOut(duration: 0.15)) { viewModel.preferredPlatform = item } @@ -185,7 +185,7 @@ struct WalletDetail: View { .foregroundColor(.foreground2) } } - .onTapGesture { + .onTapGestureBackported { viewModel.handle(.didTapAppStore) } } diff --git a/Sources/WalletConnectModal/Modal/Screens/WalletList.swift b/Sources/WalletConnectModal/Modal/Screens/WalletList.swift index 7ea02d286..55ba54c2a 100644 --- a/Sources/WalletConnectModal/Modal/Screens/WalletList.swift +++ b/Sources/WalletConnectModal/Modal/Screens/WalletList.swift @@ -58,14 +58,10 @@ struct WalletList: View { if wallets.count > numberOfColumns * 2 { viewAllItem() - .transform { - #if os(iOS) - $0.onTapGesture { - withAnimation { - navigateTo(.viewAll) - } - } - #endif + .onTapGestureBackported { + withAnimation { + navigateTo(.viewAll) + } } } } @@ -181,19 +177,15 @@ struct WalletList: View { .padding(.horizontal, 12) } .frame(maxWidth: 80, maxHeight: 96) - .transform { - #if os(iOS) - $0.onTapGesture { - withAnimation { - navigateTo(.walletDetail(wallet)) - - // Small delay to let detail screen present before actually deeplinking - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - onListingTap(wallet) - } - } + .onTapGestureBackported { + withAnimation { + navigateTo(.walletDetail(wallet)) + + // Small delay to let detail screen present before actually deeplinking + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + onListingTap(wallet) } - #endif + } } } } diff --git a/Sources/WalletConnectModal/UI/Common/Web3ModalPicker.swift b/Sources/WalletConnectModal/UI/Common/Web3ModalPicker.swift index 31ba098ad..49ce87219 100644 --- a/Sources/WalletConnectModal/UI/Common/Web3ModalPicker.swift +++ b/Sources/WalletConnectModal/UI/Common/Web3ModalPicker.swift @@ -104,7 +104,7 @@ struct PreviewWeb3ModalPicker: View { .contentShape(Rectangle()) .padding(.horizontal, 8) .padding(.vertical, 8) - .onTapGesture { + .onTapGestureBackported { withAnimation(.easeInOut(duration: 0.15)) { selectedItem = item } diff --git a/Sources/WalletConnectNotify/Client/Wallet/NotifyClient.swift b/Sources/WalletConnectNotify/Client/Wallet/NotifyClient.swift index bba5af964..41db7b97e 100644 --- a/Sources/WalletConnectNotify/Client/Wallet/NotifyClient.swift +++ b/Sources/WalletConnectNotify/Client/Wallet/NotifyClient.swift @@ -9,8 +9,8 @@ public class NotifyClient { return notifyStorage.subscriptionsPublisher } - public var notifyMessagePublisher: AnyPublisher { - return notifyMessageSubscriber.notifyMessagePublisher + public var messagesPublisher: AnyPublisher<[NotifyMessageRecord], Never> { + return notifyStorage.messagesPublisher } public var logsPublisher: AnyPublisher { @@ -125,6 +125,10 @@ public class NotifyClient { return identityService.isIdentityRegistered(account: account) } + public func subscriptionsPublisher(account: Account) -> AnyPublisher<[NotifySubscription], Never> { + return notifyStorage.subscriptionsPublisher(account: account) + } + public func messagesPublisher(topic: String) -> AnyPublisher<[NotifyMessageRecord], Never> { return notifyStorage.messagesPublisher(topic: topic) } diff --git a/Sources/WalletConnectNotify/Client/Wallet/NotifyStorage.swift b/Sources/WalletConnectNotify/Client/Wallet/NotifyStorage.swift index 832e92dc3..1e9cf905f 100644 --- a/Sources/WalletConnectNotify/Client/Wallet/NotifyStorage.swift +++ b/Sources/WalletConnectNotify/Client/Wallet/NotifyStorage.swift @@ -40,6 +40,10 @@ final class NotifyStorage: NotifyStoring { return subscriptionsSubject.eraseToAnyPublisher() } + var messagesPublisher: AnyPublisher<[NotifyMessageRecord], Never> { + return messagesSubject.eraseToAnyPublisher() + } + init(database: NotifyDatabase, accountProvider: NotifyAccountProvider) { self.database = database self.accountProvider = accountProvider @@ -89,6 +93,12 @@ final class NotifyStorage: NotifyStoring { updateSubscriptionSubject.send(updated) } + func subscriptionsPublisher(account: Account) -> AnyPublisher<[NotifySubscription], Never> { + return subscriptionsSubject + .map { $0.filter { $0.account == account } } + .eraseToAnyPublisher() + } + // MARK: Messages func messagesPublisher(topic: String) -> AnyPublisher<[NotifyMessageRecord], Never> { diff --git a/Sources/WalletConnectNotify/Client/Wallet/ProtocolEngine/wc_notifyMessage/NotifyMessageSubscriber.swift b/Sources/WalletConnectNotify/Client/Wallet/ProtocolEngine/wc_notifyMessage/NotifyMessageSubscriber.swift index 9b9a2cd8f..4199bfb6f 100644 --- a/Sources/WalletConnectNotify/Client/Wallet/ProtocolEngine/wc_notifyMessage/NotifyMessageSubscriber.swift +++ b/Sources/WalletConnectNotify/Client/Wallet/ProtocolEngine/wc_notifyMessage/NotifyMessageSubscriber.swift @@ -8,11 +8,6 @@ class NotifyMessageSubscriber { private let notifyStorage: NotifyStorage private let crypto: CryptoProvider private let logger: ConsoleLogging - private let notifyMessagePublisherSubject = PassthroughSubject() - - public var notifyMessagePublisher: AnyPublisher { - notifyMessagePublisherSubject.eraseToAnyPublisher() - } init(keyserver: URL, networkingInteractor: NetworkInteracting, identityClient: IdentityClient, notifyStorage: NotifyStorage, crypto: CryptoProvider, logger: ConsoleLogging) { self.keyserver = keyserver @@ -39,7 +34,6 @@ class NotifyMessageSubscriber { let dappPubKey = try DIDKey(did: claims.iss) let record = NotifyMessageRecord(id: payload.id.string, topic: payload.topic, message: messagePayload.message, publishedAt: payload.publishedAt) try notifyStorage.setMessage(record) - notifyMessagePublisherSubject.send(record) let receiptPayload = NotifyMessageReceiptPayload( account: messagePayload.account, diff --git a/Sources/WalletConnectRelay/PackageConfig.json b/Sources/WalletConnectRelay/PackageConfig.json index f828972fc..68198226b 100644 --- a/Sources/WalletConnectRelay/PackageConfig.json +++ b/Sources/WalletConnectRelay/PackageConfig.json @@ -1 +1 @@ -{"version": "1.9.6"} +{"version": "1.9.7"} diff --git a/Sources/WalletConnectUtils/UnfairLock.swift b/Sources/WalletConnectUtils/UnfairLock.swift new file mode 100644 index 000000000..3e22d97d9 --- /dev/null +++ b/Sources/WalletConnectUtils/UnfairLock.swift @@ -0,0 +1,21 @@ +import Foundation + +public final class UnfairLock { + private var lock: UnsafeMutablePointer + + public init() { + lock = UnsafeMutablePointer.allocate(capacity: 1) + lock.initialize(to: os_unfair_lock()) + } + + deinit { + lock.deallocate() + } + + @discardableResult + public func locked(_ f: () throws -> ReturnValue) rethrows -> ReturnValue { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return try f() + } +} diff --git a/WalletConnectSwiftV2.podspec b/WalletConnectSwiftV2.podspec index 29cf98ffe..9cf68e052 100644 --- a/WalletConnectSwiftV2.podspec +++ b/WalletConnectSwiftV2.podspec @@ -194,5 +194,9 @@ Pod::Spec.new do |spec| ss.source_files = 'Sources/WalletConnectModal/**/*.{h,m,swift}' ss.dependency 'WalletConnectSwiftV2/WalletConnectSign' ss.dependency 'DSF_QRCode', '~> 16.1.1' + ss.ios.deployment_target = ios_deployment_target + ss.tvos.deployment_target = tvos_deployment_target + # TODO: Re-add macOS support once + ss.osx.deployment_target = '' end end