From 13d02ef3cf720d44a4d219446fada943f6435c9b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 16 Oct 2026 20:39:38 +0200 Subject: [PATCH 1/4] fix: keep a pending pubky sign-in link when another link opens --- Bitkit/MainNavView.swift | 4 ++ Bitkit/ViewModels/AppViewModel.swift | 39 ++++++++++- Bitkit/ViewModels/SheetViewModel.swift | 8 +++ BitkitTests/PubkyAuthURLSchemeTests.swift | 67 +++++++++++++++++++ changelog.d/next/985.fixed.md | 1 + journeys/pubky-auth/README.md | 1 + .../pubky-auth/signin-with-second-link.xml | 36 ++++++++++ 7 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 changelog.d/next/985.fixed.md create mode 100644 journeys/pubky-auth/signin-with-second-link.xml diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index ee14d5e17..6834e816f 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -440,6 +440,10 @@ struct MainNavView: View { guard canHandleDeepLinks, url != nil else { return } Task { await handlePendingDeepLink() } } + .onChange(of: sheets.isPubkyAuthApprovalShownOrPending) { _, isShownOrPending in + guard canHandleDeepLinks, !isShownOrPending else { return } + Task { await handlePendingDeepLink() } + } .task(id: scenePhase == .active) { guard scenePhase == .active, canHandleDeepLinks, let url = app.pendingDeepLinkURL, PubkyContactLink.matches(url) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 677a39507..7af099286 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -161,6 +161,8 @@ class AppViewModel: ObservableObject { @Published var lnurlWithdrawData: LnurlWithdrawData? @Published private(set) var pendingDeepLinkURL: URL? + /// The next link, held while a Pubky sign-in link waits or its approval sheet is open, so it cannot drop the sign-in. + private var deepLinkAfterPubkyAuth: URL? /// The last routed deep link. On a cold start the same link reaches the app twice (`onOpenURL` and the scene /// delegate replay), and the startup gates can route the first delivery before the second arrives. private var lastRoutedDeepLink: (url: URL, date: Date)? @@ -237,6 +239,23 @@ class AppViewModel: ObservableObject { Logger.debug("Ignoring duplicate delivery of a routed deep link", context: "AppViewModel") return } + let isPubkyAuth = Self.isPubkyAuthDeepLink(url) + if let pending = pendingDeepLinkURL, pending != url { + let pendingIsPubkyAuth = Self.isPubkyAuthDeepLink(pending) + if pendingIsPubkyAuth, !isPubkyAuth { + deepLinkAfterPubkyAuth = url + Logger.debug("Holding a deep link until the pending sign-in link is handled", context: "AppViewModel") + return + } + if !pendingIsPubkyAuth, isPubkyAuth { + deepLinkAfterPubkyAuth = pending + pendingDeepLinkURL = url + return + } + } else if pendingDeepLinkURL == nil, deepLinkAfterPubkyAuth != nil, !isPubkyAuth { + deepLinkAfterPubkyAuth = url + return + } pendingDeepLinkURL = url } @@ -246,7 +265,12 @@ class AppViewModel: ObservableObject { pubkyContactsAreReady: Bool = true, handler: (URL) async -> Void ) async { - guard isReady, let url = pendingDeepLinkURL else { return } + guard isReady else { return } + promoteDeepLinkAfterPubkyAuthIfIdle() + guard let url = pendingDeepLinkURL else { return } + if sheetViewModel.isPubkyAuthApprovalShownOrPending, !Self.isPubkyAuthDeepLink(url) { + return + } if PubkyContactLink.publicKey(from: url) != nil, !pubkyContactsAreReady { return } @@ -256,6 +280,19 @@ class AppViewModel: ObservableObject { pendingDeepLinkURL = nil lastRoutedDeepLink = (url, currentDate()) await handler(url) + promoteDeepLinkAfterPubkyAuthIfIdle() + } + + private func promoteDeepLinkAfterPubkyAuthIfIdle() { + guard pendingDeepLinkURL == nil, !sheetViewModel.isPubkyAuthApprovalShownOrPending, + let next = deepLinkAfterPubkyAuth + else { return } + deepLinkAfterPubkyAuth = nil + pendingDeepLinkURL = next + } + + private static func isPubkyAuthDeepLink(_ url: URL) -> Bool { + PubkyAuthRequest.isProtocolURL(url.absoluteString.removingLightningSchemes()) } private static func requiresLightningNode(_ url: URL) -> Bool { diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index cefcd2fa2..4f313ea07 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -42,6 +42,11 @@ class SheetViewModel: ObservableObject { @Published var activeSheetConfiguration: SheetConfiguration? = nil @Published var hardwareConnectHandlesPairing = false @Published private(set) var isReplacingSheet = false + @Published private(set) var replacingSheetID: SheetID? + + var isPubkyAuthApprovalShownOrPending: Bool { + activeSheetConfiguration?.id == .pubkyAuthApproval || replacingSheetID == .pubkyAuthApproval + } func showSheet(_ id: SheetID, data: Any? = nil) { if activeSheetConfiguration?.id == .send, id == .receivedTx { @@ -52,11 +57,13 @@ class SheetViewModel: ObservableObject { // If any other sheet is open, close it and delay before showing the new sheet // to prevent the new sheet from closing immediately (bug) isReplacingSheet = true + replacingSheetID = id hideSheet() DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in guard let self else { return } isReplacingSheet = false + replacingSheetID = nil Logger.debug("Showing sheet \(id.rawValue) after delay", context: "SheetViewModel") activeSheetConfiguration = SheetConfiguration(id: id, data: data) playHaptics(for: id) @@ -68,6 +75,7 @@ class SheetViewModel: ObservableObject { } } else { isReplacingSheet = false + replacingSheetID = nil // If no sheet is open, show the new sheet immediately Logger.debug("Showing sheet \(id.rawValue)", context: "SheetViewModel") activeSheetConfiguration = SheetConfiguration(id: id, data: data) diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 0fe6d6b13..770969733 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -235,4 +235,71 @@ final class PubkyAuthURLSchemeTests: XCTestCase { } XCTAssertNil(app.pendingDeepLinkURL) } + + @MainActor + func testPendingSignInLinkRoutesBeforeALaterLinkInEitherOrder() async throws { + let signinURL = try signinLink() + let bitcoinURL = try XCTUnwrap(URL(string: "bitcoin:bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080?amount=0.0001")) + + for links in [[signinURL, bitcoinURL], [bitcoinURL, signinURL]] { + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + var routed: [URL] = [] + for link in links { + app.retainDeepLink(link) + } + + await app.routePendingDeepLinkIfReady(true) { routed.append($0) } + await app.routePendingDeepLinkIfReady(true) { routed.append($0) } + + XCTAssertEqual(routed, [signinURL, bitcoinURL]) + XCTAssertNil(app.pendingDeepLinkURL) + } + } + + @MainActor + func testLinkWaitsUntilTheOpenPubkyAuthSheetCloses() async throws { + let sheets = SheetViewModel() + let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel()) + let signinURL = try signinLink() + let olderURL = try XCTUnwrap(URL(string: "bitcoin:bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080?amount=0.0001")) + let newerURL = try XCTUnwrap(URL(string: "bitcoin:bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080?amount=0.0002")) + var routed: [URL] = [] + + app.retainDeepLink(signinURL) + app.retainDeepLink(olderURL) + await app.routePendingDeepLinkIfReady(true) { url in + routed.append(url) + sheets.showSheet(.pubkyAuthApproval) + } + app.retainDeepLink(newerURL) + await app.routePendingDeepLinkIfReady(true) { routed.append($0) } + XCTAssertEqual(routed, [signinURL]) + + sheets.hideSheet() + await app.routePendingDeepLinkIfReady(true) { routed.append($0) } + await app.routePendingDeepLinkIfReady(true) { routed.append($0) } + + XCTAssertEqual(routed, [signinURL, newerURL]) + } + + @MainActor + func testNewerSignInLinkReplacesAnOlderPendingOne() async throws { + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + let olderURL = try signinLink(caps: "/pub/older/:rw") + let newerURL = try signinLink(caps: "/pub/newer/:rw") + var routed: [URL] = [] + + app.retainDeepLink(olderURL) + app.retainDeepLink(newerURL) + await app.routePendingDeepLinkIfReady(true) { routed.append($0) } + await app.routePendingDeepLinkIfReady(true) { routed.append($0) } + + XCTAssertEqual(routed, [newerURL]) + } + + private func signinLink(caps: String = "/pub/locks.app/:rw") throws -> URL { + try XCTUnwrap(URL(string: "bitkit://pubky-auth/signin?caps=\(caps)" + + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s\(grantRequester)")) + } } diff --git a/changelog.d/next/985.fixed.md b/changelog.d/next/985.fixed.md new file mode 100644 index 000000000..0b65b52f5 --- /dev/null +++ b/changelog.d/next/985.fixed.md @@ -0,0 +1 @@ +Opening another link while a Pubky sign-in is pending no longer drops the sign-in; the other link opens after it. diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md index 61495179c..b715cd24f 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -3,6 +3,7 @@ This suite covers the uniquely targetable `bitkit://pubky-auth/setup` and `bitkit://pubky-auth/signin` OS handoffs into Bitkit. Both carry the grant-auth requester fields and normalize to `pubkyauth://signin_grant`; `/setup` is Paykit setup and requires a Bitkit claim, `/signin` is a plain grant sign-in (`open-signin-link.xml`). They are the only Pubky requests accepted from an OS link. `lightning:`/`lnurl*:`-prefixed raw Pubky auth and signup requests are rejected before authorization. It stops at explicit watch-only consent and never authorizes or exports account material. Bitkit retains links delivered during startup, restoration, or PIN entry and presents consent only after the main wallet UI is available. +A sign-in link keeps its place when another link arrives: `signin-with-second-link.xml` checks that a payment link opened on the PIN screen in either order, or over the open authorization sheet, opens only after that sheet closes. ## Preconditions diff --git a/journeys/pubky-auth/signin-with-second-link.xml b/journeys/pubky-auth/signin-with-second-link.xml new file mode 100644 index 000000000..5f49ccb93 --- /dev/null +++ b/journeys/pubky-auth/signin-with-second-link.xml @@ -0,0 +1,36 @@ + + + Verifies that a payment link opened while a sign-in link is pending does not drop the sign-in: + the authorization sheet opens first, and the payment opens once the sheet closes. Covers a + payment link opened on the PIN screen in both orders, and one opened while the authorization + sheet is open. Precondition: an onboarded wallet with savings funded, the PIN on, Paykit enabled + and a Pubky profile created in Bitkit. Adapted from Android: the sign-in link is + bitkit://pubky-auth/signin, opened with xcrun simctl openurl using the test simulator UDID. + + + Run xcrun simctl terminate <UDID> to.bitkit + Run xcrun simctl openurl <UDID> "bitkit://pubky-auth/signin?caps=/pub/locks.app/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&cid=locks.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + If the simulator asks to open the link in Bitkit, tap Open + Verify the PIN screen is visible + Run xcrun simctl openurl <UDID> "bitcoin:bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080?amount=0.0001" + Enter the PIN with the number pad keys + Verify the authorize button (id "PubkyAuthAuthorize") is visible and no Send screen is shown + Tap Cancel (id "PubkyAuthCancel") + Verify the Send flow opens for 10 000 sats, then close it until the wallet home screen is visible + Run xcrun simctl terminate <UDID> to.bitkit + Run xcrun simctl openurl <UDID> "bitcoin:bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080?amount=0.0001" + If the simulator asks to open the link in Bitkit, tap Open + Verify the PIN screen is visible + Run xcrun simctl openurl <UDID> "bitkit://pubky-auth/signin?caps=/pub/locks.app/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&cid=locks.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + Enter the PIN with the number pad keys + Verify the authorize button (id "PubkyAuthAuthorize") is visible and no Send screen is shown + Tap Cancel (id "PubkyAuthCancel") + Verify the Send flow opens for 10 000 sats, then close it until the wallet home screen is visible + Run xcrun simctl openurl <UDID> "bitkit://pubky-auth/signin?caps=/pub/locks.app/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&cid=locks.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + Verify the authorize button (id "PubkyAuthAuthorize") is visible + Run xcrun simctl openurl <UDID> "bitcoin:bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080?amount=0.0001" + Verify the authorize button (id "PubkyAuthAuthorize") is still visible and no Send screen is shown + Tap Cancel (id "PubkyAuthCancel") + Verify the Send flow opens for 10 000 sats + + From 1b4fad8988fa8635ec26830d1a05b1c9ee35f70b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 16 Oct 2026 21:20:42 +0200 Subject: [PATCH 2/4] fix: keep links held until the pubky sheet has shown or closed --- Bitkit/MainNavView.swift | 5 ++++- Bitkit/ViewModels/SheetViewModel.swift | 12 ++++++++---- BitkitTests/PubkyAuthURLSchemeTests.swift | 12 ++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 6834e816f..e4053c446 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -442,7 +442,10 @@ struct MainNavView: View { } .onChange(of: sheets.isPubkyAuthApprovalShownOrPending) { _, isShownOrPending in guard canHandleDeepLinks, !isShownOrPending else { return } - Task { await handlePendingDeepLink() } + Task { + try? await Task.sleep(for: .seconds(SheetViewModel.replacementDelaySeconds)) + await handlePendingDeepLink() + } } .task(id: scenePhase == .active) { guard scenePhase == .active, canHandleDeepLinks, diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 4f313ea07..2e004b88d 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -39,6 +39,9 @@ struct SheetConfiguration { } class SheetViewModel: ObservableObject { + /// How long a closing sheet animates out before the next sheet may be shown. + static let replacementDelaySeconds: TimeInterval = 0.7 + @Published var activeSheetConfiguration: SheetConfiguration? = nil @Published var hardwareConnectHandlesPairing = false @Published private(set) var isReplacingSheet = false @@ -60,10 +63,12 @@ class SheetViewModel: ObservableObject { replacingSheetID = id hideSheet() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + Self.replacementDelaySeconds) { [weak self] in guard let self else { return } isReplacingSheet = false - replacingSheetID = nil + if replacingSheetID == id { + replacingSheetID = nil + } Logger.debug("Showing sheet \(id.rawValue) after delay", context: "SheetViewModel") activeSheetConfiguration = SheetConfiguration(id: id, data: data) playHaptics(for: id) @@ -75,7 +80,6 @@ class SheetViewModel: ObservableObject { } } else { isReplacingSheet = false - replacingSheetID = nil // If no sheet is open, show the new sheet immediately Logger.debug("Showing sheet \(id.rawValue)", context: "SheetViewModel") activeSheetConfiguration = SheetConfiguration(id: id, data: data) @@ -117,7 +121,7 @@ class SheetViewModel: ObservableObject { isReplacingSheet = true hideSheet(reason: reason) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + Self.replacementDelaySeconds) { [weak self] in guard let self else { return } isReplacingSheet = false guard activeSheetConfiguration == nil else { return } diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 770969733..af611f600 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -297,6 +297,18 @@ final class PubkyAuthURLSchemeTests: XCTestCase { XCTAssertEqual(routed, [newerURL]) } + @MainActor + func testPendingPubkyAuthSheetStaysTrackedWhenAnotherSheetShowsDuringItsReplacement() { + let sheets = SheetViewModel() + sheets.showSheet(.send) + sheets.showSheet(.pubkyAuthApproval) + XCTAssertTrue(sheets.isPubkyAuthApprovalShownOrPending) + + sheets.showSheet(.receivedTx) + + XCTAssertTrue(sheets.isPubkyAuthApprovalShownOrPending) + } + private func signinLink(caps: String = "/pub/locks.app/:rw") throws -> URL { try XCTUnwrap(URL(string: "bitkit://pubky-auth/signin?caps=\(caps)" + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + From 28be26420962168ea85aab1581dffcc44f2c1750 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 17 Oct 2026 00:02:22 +0200 Subject: [PATCH 3/4] fix: open locked payment links for review instead of quickpay --- Bitkit/AppScene.swift | 12 ++++++++---- .../Utilities/PaymentNavigationHelper.swift | 1 + Bitkit/ViewModels/AppViewModel.swift | 11 ++++++++++- BitkitTests/PubkyAuthURLSchemeTests.swift | 19 +++++++++++++++++++ changelog.d/next/992.fixed.md | 1 + 5 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 changelog.d/next/992.fixed.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ee71aaa9e..5c0eb41f1 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -535,7 +535,7 @@ struct AppScene: View { .onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) } .onChange(of: scenePhase, initial: true) { _, newValue in handleScenePhaseChange(newValue) } .onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) } - .onOpenURL { url in app.retainDeepLink(url) } + .onOpenURL { url in app.retainDeepLink(url, whileLocked: isLockedForDeepLinks) } // Bridge the vendor managers' device state into the watch-only manager without coupling them: // each bumps devicesRevision on any device or connection change. .onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() } @@ -730,7 +730,7 @@ struct AppScene: View { } if let url = DeepLinkRouter.shared.consume() { - app.retainDeepLink(url) + app.retainDeepLink(url, whileLocked: isLockedForDeepLinks) } // Listen for quick action notifications @@ -760,14 +760,18 @@ struct AppScene: View { private func handleDeepLinkNotification(_ notification: Notification) { if let retainedURL = DeepLinkRouter.shared.consume() { - app.retainDeepLink(retainedURL) + app.retainDeepLink(retainedURL, whileLocked: isLockedForDeepLinks) return } if let receivedURL = notification.object as? URL { - app.retainDeepLink(receivedURL) + app.retainDeepLink(receivedURL, whileLocked: isLockedForDeepLinks) } } + private var isLockedForDeepLinks: Bool { + settings.pinEnabled && !isPinVerified + } + private var mainContent: some View { ZStack { if Env.isTrezorEmulatorTesting { diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index 9e837a9fd..ddb33d204 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -17,6 +17,7 @@ struct PaymentNavigationHelper { spendStore: QuickPaySpendStore = .shared, coordinator: QuickPayPaymentCoordinator? = nil ) -> Bool { + guard !app.isRoutingDeepLinkFromLock else { return false } guard let amountSats = QuickPayLimits.paymentAmountSats(app: app), amountSats > 0 else { return false } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 7af099286..81d5030fe 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -163,6 +163,9 @@ class AppViewModel: ObservableObject { @Published private(set) var pendingDeepLinkURL: URL? /// The next link, held while a Pubky sign-in link waits or its approval sheet is open, so it cannot drop the sign-in. private var deepLinkAfterPubkyAuth: URL? + private var deepLinksRetainedWhileLocked: Set = [] + /// True while a deep link that arrived behind the PIN lock routes, so a payment opens for review instead of QuickPay. + private(set) var isRoutingDeepLinkFromLock = false /// The last routed deep link. On a cold start the same link reaches the app twice (`onOpenURL` and the scene /// delegate replay), and the startup gates can route the first delivery before the second arrives. private var lastRoutedDeepLink: (url: URL, date: Date)? @@ -232,13 +235,16 @@ class AppViewModel: ObservableObject { appStatusInit = true } - func retainDeepLink(_ url: URL) { + func retainDeepLink(_ url: URL, whileLocked: Bool = false) { if let lastRoutedDeepLink, lastRoutedDeepLink.url == url, currentDate().timeIntervalSince(lastRoutedDeepLink.date) < Self.duplicateDeepLinkWindow { Logger.debug("Ignoring duplicate delivery of a routed deep link", context: "AppViewModel") return } + if whileLocked { + deepLinksRetainedWhileLocked.insert(url) + } let isPubkyAuth = Self.isPubkyAuthDeepLink(url) if let pending = pendingDeepLinkURL, pending != url { let pendingIsPubkyAuth = Self.isPubkyAuthDeepLink(pending) @@ -279,7 +285,10 @@ class AppViewModel: ObservableObject { } pendingDeepLinkURL = nil lastRoutedDeepLink = (url, currentDate()) + isRoutingDeepLinkFromLock = deepLinksRetainedWhileLocked.remove(url) != nil await handler(url) + isRoutingDeepLinkFromLock = false + deepLinksRetainedWhileLocked.formIntersection([pendingDeepLinkURL, deepLinkAfterPubkyAuth].compactMap { $0 }) promoteDeepLinkAfterPubkyAuthIfIdle() } diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index af611f600..473388ab5 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -309,6 +309,25 @@ final class PubkyAuthURLSchemeTests: XCTestCase { XCTAssertTrue(sheets.isPubkyAuthApprovalShownOrPending) } + @MainActor + func testLinksThatCrossedThePinLockRouteWithoutQuickpay() async throws { + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + let signinURL = try signinLink() + let lockedURL = try XCTUnwrap(URL(string: "lightning:lnbcrt1lockedpayment")) + let unlockedURL = try XCTUnwrap(URL(string: "lightning:lnbcrt1unlockedpayment")) + var routedFromLock: [URL: Bool] = [:] + + app.retainDeepLink(lockedURL, whileLocked: true) + app.retainDeepLink(signinURL, whileLocked: true) + await app.routePendingDeepLinkIfReady(true) { routedFromLock[$0] = app.isRoutingDeepLinkFromLock } + await app.routePendingDeepLinkIfReady(true) { routedFromLock[$0] = app.isRoutingDeepLinkFromLock } + app.retainDeepLink(unlockedURL) + await app.routePendingDeepLinkIfReady(true) { routedFromLock[$0] = app.isRoutingDeepLinkFromLock } + + XCTAssertEqual(routedFromLock, [signinURL: true, lockedURL: true, unlockedURL: false]) + XCTAssertFalse(app.isRoutingDeepLinkFromLock) + } + private func signinLink(caps: String = "/pub/locks.app/:rw") throws -> URL { try XCTUnwrap(URL(string: "bitkit://pubky-auth/signin?caps=\(caps)" + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + diff --git a/changelog.d/next/992.fixed.md b/changelog.d/next/992.fixed.md new file mode 100644 index 000000000..e6a22eb70 --- /dev/null +++ b/changelog.d/next/992.fixed.md @@ -0,0 +1 @@ +Payment links opened while Bitkit is locked now open for review instead of being paid with QuickPay. From 4891907742849bc452d4ba8e46c3994ba8ae53f8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 17 Oct 2026 01:05:26 +0200 Subject: [PATCH 4/4] fix: keep the locked flag per route when links route concurrently --- Bitkit/ViewModels/AppViewModel.swift | 15 ++++++-- BitkitTests/PubkyAuthURLSchemeTests.swift | 43 +++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 81d5030fe..d7e225225 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -164,8 +164,12 @@ class AppViewModel: ObservableObject { /// The next link, held while a Pubky sign-in link waits or its approval sheet is open, so it cannot drop the sign-in. private var deepLinkAfterPubkyAuth: URL? private var deepLinksRetainedWhileLocked: Set = [] + private var lockedDeepLinkRoutesInFlight = 0 /// True while a deep link that arrived behind the PIN lock routes, so a payment opens for review instead of QuickPay. - private(set) var isRoutingDeepLinkFromLock = false + var isRoutingDeepLinkFromLock: Bool { + lockedDeepLinkRoutesInFlight > 0 + } + /// The last routed deep link. On a cold start the same link reaches the app twice (`onOpenURL` and the scene /// delegate replay), and the startup gates can route the first delivery before the second arrives. private var lastRoutedDeepLink: (url: URL, date: Date)? @@ -285,9 +289,14 @@ class AppViewModel: ObservableObject { } pendingDeepLinkURL = nil lastRoutedDeepLink = (url, currentDate()) - isRoutingDeepLinkFromLock = deepLinksRetainedWhileLocked.remove(url) != nil + let isFromLock = deepLinksRetainedWhileLocked.remove(url) != nil + if isFromLock { + lockedDeepLinkRoutesInFlight += 1 + } await handler(url) - isRoutingDeepLinkFromLock = false + if isFromLock { + lockedDeepLinkRoutesInFlight -= 1 + } deepLinksRetainedWhileLocked.formIntersection([pendingDeepLinkURL, deepLinkAfterPubkyAuth].compactMap { $0 }) promoteDeepLinkAfterPubkyAuthIfIdle() } diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 473388ab5..bd4d4c081 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -328,6 +328,49 @@ final class PubkyAuthURLSchemeTests: XCTestCase { XCTAssertFalse(app.isRoutingDeepLinkFromLock) } + @MainActor + func testLockedLinkKeepsTheFromLockFlagWhileAnUnlockedLinkRoutesConcurrently() async throws { + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + let lockedURL = try XCTUnwrap(URL(string: "lightning:lnbcrt1lockedpayment")) + let unlockedURL = try XCTUnwrap(URL(string: "lightning:lnbcrt1unlockedpayment")) + var lockedContinuation: CheckedContinuation? + var unlockedContinuation: CheckedContinuation? + var lockedFlagAfterResume: Bool? + var unlockedFlagAfterResume: Bool? + + app.retainDeepLink(lockedURL, whileLocked: true) + let lockedRoute = Task { @MainActor in + await app.routePendingDeepLinkIfReady(true) { _ in + XCTAssertTrue(app.isRoutingDeepLinkFromLock, "locked link must start routing from lock") + await withCheckedContinuation { lockedContinuation = $0 } + lockedFlagAfterResume = app.isRoutingDeepLinkFromLock + } + } + while lockedContinuation == nil { + await Task.yield() + } + + app.retainDeepLink(unlockedURL) + let unlockedRoute = Task { @MainActor in + await app.routePendingDeepLinkIfReady(true) { _ in + await withCheckedContinuation { unlockedContinuation = $0 } + unlockedFlagAfterResume = app.isRoutingDeepLinkFromLock + } + } + while unlockedContinuation == nil { + await Task.yield() + } + + unlockedContinuation?.resume() + await unlockedRoute.value + lockedContinuation?.resume() + await lockedRoute.value + + XCTAssertNotNil(unlockedFlagAfterResume) + XCTAssertEqual(lockedFlagAfterResume, true, "locked link lost its from-lock flag to a concurrent unlocked route") + XCTAssertFalse(app.isRoutingDeepLinkFromLock) + } + private func signinLink(caps: String = "/pub/locks.app/:rw") throws -> URL { try XCTUnwrap(URL(string: "bitkit://pubky-auth/signin?caps=\(caps)" + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" +