From e4bb1a8d024cc3d4b57a213c39d1ff3a2034aae5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 10:44:40 +0300 Subject: [PATCH 1/5] feat: support pubky contact deep links --- Bitkit/MainNavView.swift | 32 +++++++++++- Bitkit/Models/PubkyContactLink.swift | 20 ++++++++ Bitkit/ViewModels/AppViewModel.swift | 13 ++++- BitkitTests/PubkyContactLinkTests.swift | 65 +++++++++++++++++++++++++ README.md | 8 +++ changelog.d/next/768.added.md | 1 + journeys/README.md | 5 +- journeys/deeplinks/pubky-contact.xml | 20 ++++++++ 8 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 Bitkit/Models/PubkyContactLink.swift create mode 100644 BitkitTests/PubkyContactLinkTests.swift create mode 100644 changelog.d/next/768.added.md create mode 100644 journeys/deeplinks/pubky-contact.xml diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 0415b928b..688b179ac 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -42,6 +42,7 @@ struct MainNavView: View { @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var notificationManager: PushNotificationManager @EnvironmentObject private var pubkyProfile: PubkyProfileManager + @EnvironmentObject private var scannerManager: ScannerManager @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var wallet: WalletViewModel @@ -62,6 +63,12 @@ struct MainNavView: View { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled } + private var isContactDeepLinkReady: Bool { + guard isPaykitUIActive else { return true } + guard pubkyProfile.isInitialized || pubkyProfile.initializationErrorMessage != nil else { return false } + return pubkyProfile.publicKey == nil || contactsManager.hasLoaded || contactsManager.loadErrorMessage != nil + } + private var pendingProfileSetupResumeState: PendingProfileSetupResumeState { resolvePendingProfileSetupResumeState( isProfileSetupPending: pubkyProfile.isProfileSetupPending, @@ -378,7 +385,7 @@ struct MainNavView: View { notificationManager.unregister() } } - .task(id: [canHandleDeepLinks, wallet.nodeLifecycleState == .running]) { + .task(id: [canHandleDeepLinks, wallet.nodeLifecycleState == .running, isContactDeepLinkReady]) { guard canHandleDeepLinks else { return } await handlePendingDeepLink() } @@ -766,7 +773,8 @@ struct MainNavView: View { private func handlePendingDeepLink() async { await app.routePendingDeepLinkIfReady( canHandleDeepLinks, - nodeIsRunning: wallet.nodeLifecycleState == .running + nodeIsRunning: wallet.nodeLifecycleState == .running, + pubkyContactsAreReady: isContactDeepLinkReady ) { url in await handleDeepLink(url) } @@ -813,6 +821,26 @@ struct MainNavView: View { } do { + if PubkyContactLink.matches(url) { + guard isPaykitUIActive, pubkyProfile.initializationErrorMessage == nil, + pubkyProfile.publicKey == nil || contactsManager.hasLoaded, + let publicKey = PubkyContactLink.publicKey(from: url) + else { throw ContactsManagerError.invalidPublicKey } + + scannerManager.configure( + app: app, + contactsManager: contactsManager, + currency: currency, + settings: settings, + navigation: navigation, + pubkyProfile: pubkyProfile, + sheets: sheets, + wallet: wallet, + hwWalletManager: hwWalletManager + ) + await scannerManager.handleScan(publicKey, context: .main) + return + } try await app.handleScannedData( url.absoluteString, alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats diff --git a/Bitkit/Models/PubkyContactLink.swift b/Bitkit/Models/PubkyContactLink.swift new file mode 100644 index 000000000..9d03083ce --- /dev/null +++ b/Bitkit/Models/PubkyContactLink.swift @@ -0,0 +1,20 @@ +import Foundation + +enum PubkyContactLink { + static func matches(_ url: URL) -> Bool { + url.scheme?.lowercased() == "bitkit" && url.host?.lowercased() == "contact" + } + + static func publicKey(from url: URL) -> String? { + guard matches(url), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.user == nil, components.password == nil, components.port == nil, + components.path.isEmpty, components.fragment == nil, + let items = components.queryItems, items.count == 1, + items[0].name == "pubky", let key = items[0].value, + key.count <= PubkyPublicKeyFormat.maximumInputLength + else { return nil } + + return PubkyPublicKeyFormat.normalized(key) + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 32ff7ac06..cd33c82d4 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -155,8 +155,16 @@ class AppViewModel: ObservableObject { pendingDeepLinkURL = url } - func routePendingDeepLinkIfReady(_ isReady: Bool, nodeIsRunning: Bool = false, handler: (URL) async -> Void) async { + func routePendingDeepLinkIfReady( + _ isReady: Bool, + nodeIsRunning: Bool = false, + pubkyContactsAreReady: Bool = true, + handler: (URL) async -> Void + ) async { guard isReady, let url = pendingDeepLinkURL else { return } + if PubkyContactLink.matches(url), !pubkyContactsAreReady { + return + } if Self.requiresLightningNode(url), !nodeIsRunning { return } @@ -171,6 +179,9 @@ class AppViewModel: ObservableObject { if PubkyRingAuthCallback.parse(url: url) != nil { return false } + if PubkyContactLink.matches(url) { + return false + } if url.scheme?.lowercased() == "bitkit", url.host?.lowercased() == "pubky-auth", url.path == "/setup" diff --git a/BitkitTests/PubkyContactLinkTests.swift b/BitkitTests/PubkyContactLinkTests.swift new file mode 100644 index 000000000..0d2be8da1 --- /dev/null +++ b/BitkitTests/PubkyContactLinkTests.swift @@ -0,0 +1,65 @@ +@testable import Bitkit +import XCTest + +final class PubkyContactLinkTests: XCTestCase { + private let key = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + func testAcceptsRawPrefixedAndEncodedKeys() throws { + for value in [String(key.dropFirst(5)), key, key.uppercased(), key.replacingOccurrences(of: "pubky", with: "%70ubky")] { + let url = try XCTUnwrap(URL(string: "bitkit://contact?pubky=\(value)")) + XCTAssertEqual(PubkyContactLink.publicKey(from: url), key) + } + } + + func testRejectsMalformedLinksAndNonKeyPayloads() throws { + for link in [ + "https://contact?pubky=\(key)", + "bitkit://other?pubky=\(key)", + "bitkit://user@contact?pubky=\(key)", + "bitkit://contact:123?pubky=\(key)", + "bitkit://contact/path?pubky=\(key)", + "bitkit://contact?pubky=\(key)#fragment", + "bitkit://contact", + "bitkit://contact?pubky=", + "bitkit://contact?pubky=\(key)&pubky=\(key)", + "bitkit://contact?pubky=\(key)&other=value", + "bitkit://contact?pubky=\(key)extra", + "bitkit://contact?pubky=invalid", + "bitkit://contact?pubky=bitcoin%3Abc1example", + "bitkit://contact?pubky=pubkyauth%3A%2F%2Fsignin_grant", + ] { + XCTAssertNil(try PubkyContactLink.publicKey(from: XCTUnwrap(URL(string: link))), link) + } + } + + @MainActor + func testContactLinkWaitsForUnlockAndContactsButNotLightningNodeAndUsesScannerRouting() async throws { + let previous = UserDefaults.standard.object(forKey: PaykitFeatureFlags.uiEnabledKey) + UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + defer { UserDefaults.standard.set(previous, forKey: PaykitFeatureFlags.uiEnabledKey) } + let navigation = NavigationViewModel() + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: navigation) + let scanner = ScannerManager() + scanner.configure(app: app, navigation: navigation) + let url = try XCTUnwrap(URL(string: "bitkit://contact?pubky=\(key)")) + + app.retainDeepLink(url) + await app.routePendingDeepLinkIfReady(false, nodeIsRunning: false) { _ in + XCTFail("Must wait for wallet readiness and unlock") + } + XCTAssertEqual(app.pendingDeepLinkURL, url) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: true, pubkyContactsAreReady: false) { _ in + XCTFail("Must retain contact links until identity and contacts finish loading, even when LDK starts") + } + XCTAssertEqual(app.pendingDeepLinkURL, url) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { url in + guard let publicKey = PubkyContactLink.publicKey(from: url) else { + return XCTFail("Expected a contact key") + } + await scanner.handleScan(publicKey, context: .main) + } + XCTAssertNil(app.pendingDeepLinkURL) + XCTAssertEqual(navigation.currentRoute, .addContact(publicKey: key)) + await app.routePendingDeepLinkIfReady(true) { _ in XCTFail("Must not route twice") } + } +} diff --git a/README.md b/README.md index 5f0aee648..130501564 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,14 @@ This repository contains the **native iOS app** for Bitkit. +## Contact deep links + +Use `bitkit://contact?pubky=` to open the same flow as scanning a Pubky key. +The key can be the raw 52-character public key or include its `pubky` prefix; URL-encode the value. +Unknown keys open Add Contact, saved contacts open Contact Detail, and your own key opens Profile. +This requires an existing wallet with Paykit enabled and respects the wallet's unlock flow. +Opening the link does not save a contact or initiate a payment. + ## How to build 1. Open Bitkit.xcodeproj in XCode diff --git a/changelog.d/next/768.added.md b/changelog.d/next/768.added.md new file mode 100644 index 000000000..6892e6e0b --- /dev/null +++ b/changelog.d/next/768.added.md @@ -0,0 +1 @@ +Open Pubky contact links directly in Bitkit to add a contact or view an existing one. diff --git a/journeys/README.md b/journeys/README.md index 67db5dee3..9845c33d7 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -147,13 +147,14 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* | [payment-requests](payment-requests) | 2 | Linked issuer interoperability plus the ported Android resolution-failure journey | | [pubky-marketplace](pubky-marketplace) | 1 | Adapted — two-wallet Paykit marketplace payment on regtest; integration fixture required | | [pubky-auth](pubky-auth) | 1 | Bitkit-specific OS handoff into watch-only consent; local Pubky identity required | +| [deeplinks](deeplinks) | 1 | Pubky contact handoff; existing wallet, Pubky identity, saved contact and PIN required | | [subscriptions](subscriptions) | 4 | Create, review, cancel/delete and the Payments tab; two linked Bitkit instances required. Discover is excluded — it is unimplemented on iOS | ## Not ported -**`deeplinks` (2 journeys).** The Android journeys exercise `bitkit://screen/...` routing with a +**`deeplinks/screen-deeplink.xml` and `sheet-deeplink.xml`.** These Android journeys exercise `bitkit://screen/...` routing with a dev-mode gate and a cold-start replay. iOS registers the `bitkit` URL scheme (`Bitkit/Info.plist`) -and retains external URLs in `AppScene`, but `MainNavView` only routes web URLs, Pubky auth requests and callbacks, +and retains external URLs in `AppScene`, but `MainNavView` only routes web URLs, Pubky contacts, auth requests and callbacks, and payment URIs — there is no screen or sheet deeplink router, and no dev-mode gate to test. These journeys are blocked on the feature existing, not on the harness. diff --git a/journeys/deeplinks/pubky-contact.xml b/journeys/deeplinks/pubky-contact.xml new file mode 100644 index 000000000..ef9a763e2 --- /dev/null +++ b/journeys/deeplinks/pubky-contact.xml @@ -0,0 +1,20 @@ + + Precondition: an onboarded wallet with Paykit enabled, a Pubky profile, one saved contact and PIN enabled. Have the saved contact's key, your own key and a valid unsaved key ready. Open each link with xcrun simctl openurl using the test simulator UDID. No payment or new contact save is needed. + + Open bitkit://contact?pubky=<unsaved-public-key> + Verify Add Contact opens with the supplied key prefilled and no contact saved + Navigate back to the wallet + Open bitkit://contact?pubky=<saved-contact-public-key> + Verify Contact Detail opens for that contact, not Add Contact or a payment sheet + Navigate back to the wallet + Open bitkit://contact?pubky=<own-public-key> + Verify Profile opens without adding yourself as a contact + Navigate back to the wallet, then terminate the app + Open bitkit://contact?pubky=<saved-contact-public-key> + Verify the PIN screen appears before any contact screen + Unlock the wallet and verify Contact Detail opens for the saved contact exactly once + Navigate back to the wallet + Open bitkit://contact?pubky=invalid + Verify no contact, payment or authorization flow opens + + From 92935754ccc45c4e6073ba55495b775d711b0a3e Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 11:00:37 +0300 Subject: [PATCH 2/5] fix: retain contact links through startup errors --- Bitkit/MainNavView.swift | 18 +++++++-- Bitkit/ViewModels/AppViewModel.swift | 2 +- BitkitTests/PubkyContactLinkTests.swift | 53 +++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 688b179ac..2683b9104 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -1,5 +1,14 @@ import SwiftUI +func canRoutePubkyContactLink( + isPaykitUIActive: Bool, + isPubkyInitialized: Bool, + hasPubkyIdentity: Bool, + hasLoadedContacts: Bool +) -> Bool { + !isPaykitUIActive || (isPubkyInitialized && (!hasPubkyIdentity || hasLoadedContacts)) +} + enum PendingProfileSetupResumeState { case inactive case waiting @@ -64,9 +73,12 @@ struct MainNavView: View { } private var isContactDeepLinkReady: Bool { - guard isPaykitUIActive else { return true } - guard pubkyProfile.isInitialized || pubkyProfile.initializationErrorMessage != nil else { return false } - return pubkyProfile.publicKey == nil || contactsManager.hasLoaded || contactsManager.loadErrorMessage != nil + canRoutePubkyContactLink( + isPaykitUIActive: isPaykitUIActive, + isPubkyInitialized: pubkyProfile.isInitialized, + hasPubkyIdentity: pubkyProfile.publicKey != nil, + hasLoadedContacts: contactsManager.hasLoaded + ) } private var pendingProfileSetupResumeState: PendingProfileSetupResumeState { diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index cd33c82d4..d60344933 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -162,7 +162,7 @@ class AppViewModel: ObservableObject { handler: (URL) async -> Void ) async { guard isReady, let url = pendingDeepLinkURL else { return } - if PubkyContactLink.matches(url), !pubkyContactsAreReady { + if PubkyContactLink.publicKey(from: url) != nil, !pubkyContactsAreReady { return } if Self.requiresLightningNode(url), !nodeIsRunning { diff --git a/BitkitTests/PubkyContactLinkTests.swift b/BitkitTests/PubkyContactLinkTests.swift index 0d2be8da1..43391c0b4 100644 --- a/BitkitTests/PubkyContactLinkTests.swift +++ b/BitkitTests/PubkyContactLinkTests.swift @@ -32,6 +32,59 @@ final class PubkyContactLinkTests: XCTestCase { } } + @MainActor + func testMalformedContactLinkDoesNotWaitForPubkyReadiness() async throws { + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + let url = try XCTUnwrap(URL(string: "bitkit://contact?pubky=invalid")) + app.retainDeepLink(url) + var handled = false + await app.routePendingDeepLinkIfReady(true, pubkyContactsAreReady: false) { routedURL in + handled = true + XCTAssertNil(PubkyContactLink.publicKey(from: routedURL)) + } + XCTAssertTrue(handled) + XCTAssertNil(app.pendingDeepLinkURL) + } + + @MainActor + func testContactLinkSurvivesStartupAndContactLoadingErrorsUntilRecovery() async throws { + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + let profile = PubkyProfileManager() + let contacts = ContactsManager() + let url = try XCTUnwrap(URL(string: "bitkit://contact?pubky=\(key)")) + app.retainDeepLink(url) + + func routePendingLink() async { + await app.routePendingDeepLinkIfReady( + true, + pubkyContactsAreReady: canRoutePubkyContactLink( + isPaykitUIActive: true, + isPubkyInitialized: profile.isInitialized, + hasPubkyIdentity: profile.publicKey != nil, + hasLoadedContacts: contacts.hasLoaded + ) + ) { routedURL in + XCTAssertEqual(routedURL, url) + } + } + + profile.initializationErrorMessage = "Network unavailable" + await routePendingLink() + XCTAssertEqual(app.pendingDeepLinkURL, url) + + profile.initializationErrorMessage = nil + profile.isInitialized = true + profile.publicKey = key + contacts.loadErrorMessage = "Storage unavailable" + await routePendingLink() + XCTAssertEqual(app.pendingDeepLinkURL, url) + + contacts.loadErrorMessage = nil + contacts.hasLoaded = true + await routePendingLink() + XCTAssertNil(app.pendingDeepLinkURL) + } + @MainActor func testContactLinkWaitsForUnlockAndContactsButNotLightningNodeAndUsesScannerRouting() async throws { let previous = UserDefaults.standard.object(forKey: PaykitFeatureFlags.uiEnabledKey) From 2e58180a1894e888c9fa05655993cef2314b0bab Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 11:24:56 +0100 Subject: [PATCH 3/5] fix: handle contact links without side effects --- Bitkit/MainNavView.swift | 20 ++++++++++++++++---- BitkitTests/PubkyContactLinkTests.swift | 5 +++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 2683b9104..ee81f2011 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -9,6 +9,14 @@ func canRoutePubkyContactLink( !isPaykitUIActive || (isPubkyInitialized && (!hasPubkyIdentity || hasLoadedContacts)) } +func pubkyContactPublicKeyForRouting(from url: URL, isPaykitUIActive: Bool) throws -> String? { + guard isPaykitUIActive else { return nil } + guard let publicKey = PubkyContactLink.publicKey(from: url) else { + throw ContactsManagerError.invalidPublicKey + } + return publicKey +} + enum PendingProfileSetupResumeState { case inactive case waiting @@ -397,7 +405,7 @@ struct MainNavView: View { notificationManager.unregister() } } - .task(id: [canHandleDeepLinks, wallet.nodeLifecycleState == .running, isContactDeepLinkReady]) { + .task(id: [canHandleDeepLinks, wallet.nodeLifecycleState == .running]) { guard canHandleDeepLinks else { return } await handlePendingDeepLink() } @@ -405,6 +413,10 @@ struct MainNavView: View { guard canHandleDeepLinks, url != nil else { return } Task { await handlePendingDeepLink() } } + .onChange(of: isContactDeepLinkReady) { _, isReady in + guard canHandleDeepLinks, isReady else { return } + Task { await handlePendingDeepLink() } + } .alert( t("other__clipboard_redirect_title"), isPresented: $showClipboardAlert @@ -834,9 +846,9 @@ struct MainNavView: View { do { if PubkyContactLink.matches(url) { - guard isPaykitUIActive, pubkyProfile.initializationErrorMessage == nil, - pubkyProfile.publicKey == nil || contactsManager.hasLoaded, - let publicKey = PubkyContactLink.publicKey(from: url) + guard let publicKey = try pubkyContactPublicKeyForRouting(from: url, isPaykitUIActive: isPaykitUIActive) else { return } + guard pubkyProfile.initializationErrorMessage == nil, + pubkyProfile.publicKey == nil || contactsManager.hasLoaded else { throw ContactsManagerError.invalidPublicKey } scannerManager.configure( diff --git a/BitkitTests/PubkyContactLinkTests.swift b/BitkitTests/PubkyContactLinkTests.swift index 43391c0b4..317aae1d3 100644 --- a/BitkitTests/PubkyContactLinkTests.swift +++ b/BitkitTests/PubkyContactLinkTests.swift @@ -32,6 +32,11 @@ final class PubkyContactLinkTests: XCTestCase { } } + func testDisabledPaykitIgnoresContactLinks() throws { + let url = try XCTUnwrap(URL(string: "bitkit://contact?pubky=\(key)")) + XCTAssertNil(try pubkyContactPublicKeyForRouting(from: url, isPaykitUIActive: false)) + } + @MainActor func testMalformedContactLinkDoesNotWaitForPubkyReadiness() async throws { let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) From 9bf0252be3ab5b917e083dcff6c314058ee51aaa Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 23 Sep 2026 00:52:36 +0100 Subject: [PATCH 4/5] fix: wait for saved contacts before routing links --- Bitkit/MainNavView.swift | 32 +++++++++++++++++++++---- BitkitTests/PubkyContactLinkTests.swift | 29 +++++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index ee81f2011..d30f6f806 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -3,10 +3,9 @@ import SwiftUI func canRoutePubkyContactLink( isPaykitUIActive: Bool, isPubkyInitialized: Bool, - hasPubkyIdentity: Bool, hasLoadedContacts: Bool ) -> Bool { - !isPaykitUIActive || (isPubkyInitialized && (!hasPubkyIdentity || hasLoadedContacts)) + !isPaykitUIActive || (isPubkyInitialized && hasLoadedContacts) } func pubkyContactPublicKeyForRouting(from url: URL, isPaykitUIActive: Bool) throws -> String? { @@ -84,11 +83,14 @@ struct MainNavView: View { canRoutePubkyContactLink( isPaykitUIActive: isPaykitUIActive, isPubkyInitialized: pubkyProfile.isInitialized, - hasPubkyIdentity: pubkyProfile.publicKey != nil, hasLoadedContacts: contactsManager.hasLoaded ) } + private var canPrepareContactDeepLink: Bool { + !isPaykitUIActive || pubkyProfile.isInitialized + } + private var pendingProfileSetupResumeState: PendingProfileSetupResumeState { resolvePendingProfileSetupResumeState( isProfileSetupPending: pubkyProfile.isProfileSetupPending, @@ -413,6 +415,10 @@ struct MainNavView: View { guard canHandleDeepLinks, url != nil else { return } Task { await handlePendingDeepLink() } } + .onChange(of: canPrepareContactDeepLink) { _, canPrepare in + guard canHandleDeepLinks, canPrepare else { return } + Task { await handlePendingDeepLink() } + } .onChange(of: isContactDeepLinkReady) { _, isReady in guard canHandleDeepLinks, isReady else { return } Task { await handlePendingDeepLink() } @@ -795,6 +801,7 @@ struct MainNavView: View { } private func handlePendingDeepLink() async { + await loadContactsForPendingDeepLinkIfNeeded() await app.routePendingDeepLinkIfReady( canHandleDeepLinks, nodeIsRunning: wallet.nodeLifecycleState == .running, @@ -804,6 +811,23 @@ struct MainNavView: View { } } + private func loadContactsForPendingDeepLinkIfNeeded() async { + guard isPaykitUIActive, + pubkyProfile.isInitialized, + pubkyProfile.publicKey == nil, + !contactsManager.hasLoaded, + !contactsManager.isLoading, + let url = app.pendingDeepLinkURL, + let publicKey = PubkyContactLink.publicKey(from: url) + else { return } + + do { + try await contactsManager.loadContacts(for: publicKey) + } catch { + Logger.warn("Failed to load contacts before routing contact link: \(error)", context: "MainNavView") + } + } + private func handleDeepLink(_ url: URL) async { Logger.info("Received deeplink: \(sanitizedDeeplinkDescription(url))") @@ -848,7 +872,7 @@ struct MainNavView: View { if PubkyContactLink.matches(url) { guard let publicKey = try pubkyContactPublicKeyForRouting(from: url, isPaykitUIActive: isPaykitUIActive) else { return } guard pubkyProfile.initializationErrorMessage == nil, - pubkyProfile.publicKey == nil || contactsManager.hasLoaded + contactsManager.hasLoaded else { throw ContactsManagerError.invalidPublicKey } scannerManager.configure( diff --git a/BitkitTests/PubkyContactLinkTests.swift b/BitkitTests/PubkyContactLinkTests.swift index 317aae1d3..70750e849 100644 --- a/BitkitTests/PubkyContactLinkTests.swift +++ b/BitkitTests/PubkyContactLinkTests.swift @@ -65,7 +65,6 @@ final class PubkyContactLinkTests: XCTestCase { pubkyContactsAreReady: canRoutePubkyContactLink( isPaykitUIActive: true, isPubkyInitialized: profile.isInitialized, - hasPubkyIdentity: profile.publicKey != nil, hasLoadedContacts: contacts.hasLoaded ) ) { routedURL in @@ -90,6 +89,34 @@ final class PubkyContactLinkTests: XCTestCase { XCTAssertNil(app.pendingDeepLinkURL) } + @MainActor + func testContactLinkWaitsForSavedContactsWithoutPubkyIdentity() async throws { + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + let profile = PubkyProfileManager() + let contacts = ContactsManager() + let url = try XCTUnwrap(URL(string: "bitkit://contact?pubky=\(key)")) + app.retainDeepLink(url) + profile.isInitialized = true + + func routePendingLink() async { + await app.routePendingDeepLinkIfReady( + true, + pubkyContactsAreReady: canRoutePubkyContactLink( + isPaykitUIActive: true, + isPubkyInitialized: profile.isInitialized, + hasLoadedContacts: contacts.hasLoaded + ) + ) { _ in } + } + + await routePendingLink() + XCTAssertEqual(app.pendingDeepLinkURL, url) + + contacts.hasLoaded = true + await routePendingLink() + XCTAssertNil(app.pendingDeepLinkURL) + } + @MainActor func testContactLinkWaitsForUnlockAndContactsButNotLightningNodeAndUsesScannerRouting() async throws { let previous = UserDefaults.standard.object(forKey: PaykitFeatureFlags.uiEnabledKey) From a0a768425729f254f4c361dafd4a69be142d8d06 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 23 Sep 2026 02:11:59 +0100 Subject: [PATCH 5/5] fix: retry contact preload after failure --- Bitkit/MainNavView.swift | 51 ++++++++++++---- Bitkit/Managers/ContactsManager.swift | 8 ++- BitkitTests/PubkyContactLinkTests.swift | 79 +++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 14 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index d30f6f806..3f3443130 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -16,6 +16,16 @@ func pubkyContactPublicKeyForRouting(from url: URL, isPaykitUIActive: Bool) thro return publicKey } +@MainActor +func prepareAndRoutePendingDeepLink( + preparation: () async -> Void, + routing: () async -> Void +) async { + await preparation() + guard !Task.isCancelled else { return } + await routing() +} + enum PendingProfileSetupResumeState { case inactive case waiting @@ -71,6 +81,7 @@ struct MainNavView: View { @State private var showClipboardAlert = false @State private var clipboardUri: String? @State private var didResumePendingPubkyProfileSetup = false + @State private var isPreparingPendingContactDeepLink = false init(canHandleDeepLinks: Bool = true) { self.canHandleDeepLinks = canHandleDeepLinks } @@ -407,7 +418,7 @@ struct MainNavView: View { notificationManager.unregister() } } - .task(id: [canHandleDeepLinks, wallet.nodeLifecycleState == .running]) { + .task(id: canHandleDeepLinks) { guard canHandleDeepLinks else { return } await handlePendingDeepLink() } @@ -415,6 +426,14 @@ struct MainNavView: View { guard canHandleDeepLinks, url != nil else { return } Task { await handlePendingDeepLink() } } + .task(id: wallet.nodeLifecycleState == .running) { + guard canHandleDeepLinks, + wallet.nodeLifecycleState == .running, + let url = app.pendingDeepLinkURL, + !PubkyContactLink.matches(url) + else { return } + await handlePendingDeepLink() + } .onChange(of: canPrepareContactDeepLink) { _, canPrepare in guard canHandleDeepLinks, canPrepare else { return } Task { await handlePendingDeepLink() } @@ -801,28 +820,36 @@ struct MainNavView: View { } private func handlePendingDeepLink() async { - await loadContactsForPendingDeepLinkIfNeeded() - await app.routePendingDeepLinkIfReady( - canHandleDeepLinks, - nodeIsRunning: wallet.nodeLifecycleState == .running, - pubkyContactsAreReady: isContactDeepLinkReady - ) { url in - await handleDeepLink(url) + await prepareAndRoutePendingDeepLink { + await loadContactsForPendingDeepLinkIfNeeded() + } routing: { + await app.routePendingDeepLinkIfReady( + canHandleDeepLinks, + nodeIsRunning: wallet.nodeLifecycleState == .running, + pubkyContactsAreReady: isContactDeepLinkReady + ) { url in + await handleDeepLink(url) + } } } private func loadContactsForPendingDeepLinkIfNeeded() async { guard isPaykitUIActive, pubkyProfile.isInitialized, - pubkyProfile.publicKey == nil, !contactsManager.hasLoaded, - !contactsManager.isLoading, + !isPreparingPendingContactDeepLink, let url = app.pendingDeepLinkURL, - let publicKey = PubkyContactLink.publicKey(from: url) + let contactPublicKey = PubkyContactLink.publicKey(from: url) else { return } + let contactsOwnerPublicKey = pubkyProfile.publicKey ?? contactPublicKey + isPreparingPendingContactDeepLink = true + defer { isPreparingPendingContactDeepLink = false } + do { - try await contactsManager.loadContacts(for: publicKey) + try await contactsManager.loadContactsIfNeeded(for: contactsOwnerPublicKey) + } catch is CancellationError { + return } catch { Logger.warn("Failed to load contacts before routing contact link: \(error)", context: "MainNavView") } diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 266fce497..edde0e68c 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -153,9 +153,13 @@ class ContactsManager: ObservableObject { func loadContactsIfNeeded(for publicKey: String) async throws { while !hasLoaded { + try Task.checkCancellation() if isLoading { - for await isLoading in $isLoading.values where !isLoading { - break + for await isLoading in $isLoading.values { + try Task.checkCancellation() + if !isLoading { + break + } } } else { try await loadContacts(for: publicKey) diff --git a/BitkitTests/PubkyContactLinkTests.swift b/BitkitTests/PubkyContactLinkTests.swift index 70750e849..5fd86ee99 100644 --- a/BitkitTests/PubkyContactLinkTests.swift +++ b/BitkitTests/PubkyContactLinkTests.swift @@ -4,6 +4,17 @@ import XCTest final class PubkyContactLinkTests: XCTestCase { private let key = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + @MainActor + private final class RecoveringContactsManager: ContactsManager { + private(set) var loadAttempts = 0 + + override func loadContacts(for publicKey: String) async throws { + loadAttempts += 1 + loadErrorMessage = nil + hasLoaded = true + } + } + func testAcceptsRawPrefixedAndEncodedKeys() throws { for value in [String(key.dropFirst(5)), key, key.uppercased(), key.replacingOccurrences(of: "pubky", with: "%70ubky")] { let url = try XCTUnwrap(URL(string: "bitkit://contact?pubky=\(value)")) @@ -117,6 +128,74 @@ final class PubkyContactLinkTests: XCTestCase { XCTAssertNil(app.pendingDeepLinkURL) } + @MainActor + func testContactPreloadRetriesAfterInFlightLoadFails() async throws { + let contacts = RecoveringContactsManager() + contacts.isLoading = true + + let loadTask = Task { + try await contacts.loadContactsIfNeeded(for: key) + } + + await Task.yield() + XCTAssertEqual(contacts.loadAttempts, 0) + + contacts.loadErrorMessage = "Initial load failed" + contacts.isLoading = false + + try await loadTask.value + XCTAssertEqual(contacts.loadAttempts, 1) + XCTAssertTrue(contacts.hasLoaded) + XCTAssertNil(contacts.loadErrorMessage) + } + + @MainActor + func testContactPreloadStopsWaitingWhenTaskIsCancelled() async { + let contacts = RecoveringContactsManager() + contacts.isLoading = true + + let loadTask = Task { + try await contacts.loadContactsIfNeeded(for: key) + } + + await Task.yield() + loadTask.cancel() + + do { + try await loadTask.value + XCTFail("Expected contact preload to stop after cancellation") + } catch is CancellationError { + XCTAssertEqual(contacts.loadAttempts, 0) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + @MainActor + func testCancelledContactPreparationDoesNotRoutePendingLink() async { + var finishPreparation: CheckedContinuation? + var didRoute = false + + let handlingTask = Task { + await prepareAndRoutePendingDeepLink { + await withCheckedContinuation { continuation in + finishPreparation = continuation + } + } routing: { + didRoute = true + } + } + + while finishPreparation == nil { + await Task.yield() + } + handlingTask.cancel() + finishPreparation?.resume() + await handlingTask.value + + XCTAssertFalse(didRoute) + } + @MainActor func testContactLinkWaitsForUnlockAndContactsButNotLightningNodeAndUsesScannerRouting() async throws { let previous = UserDefaults.standard.object(forKey: PaykitFeatureFlags.uiEnabledKey)