Skip to content

Commit 2dbc5ba

Browse files
authored
Merge pull request #987 from synonymdev/fix/pubky-signin-link-kept
fix: keep a pending pubky sign-in link when another link opens
2 parents e96c4a7 + ee3e45f commit 2dbc5ba

10 files changed

Lines changed: 267 additions & 8 deletions

File tree

‎Bitkit/AppScene.swift‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ struct AppScene: View {
535535
.onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) }
536536
.onChange(of: scenePhase, initial: true) { _, newValue in handleScenePhaseChange(newValue) }
537537
.onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) }
538-
.onOpenURL { url in app.retainDeepLink(url) }
538+
.onOpenURL { url in app.retainDeepLink(url, whileLocked: isLockedForDeepLinks) }
539539
// Bridge the vendor managers' device state into the watch-only manager without coupling them:
540540
// each bumps devicesRevision on any device or connection change.
541541
.onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() }
@@ -730,7 +730,7 @@ struct AppScene: View {
730730
}
731731

732732
if let url = DeepLinkRouter.shared.consume() {
733-
app.retainDeepLink(url)
733+
app.retainDeepLink(url, whileLocked: isLockedForDeepLinks)
734734
}
735735

736736
// Listen for quick action notifications
@@ -760,14 +760,18 @@ struct AppScene: View {
760760

761761
private func handleDeepLinkNotification(_ notification: Notification) {
762762
if let retainedURL = DeepLinkRouter.shared.consume() {
763-
app.retainDeepLink(retainedURL)
763+
app.retainDeepLink(retainedURL, whileLocked: isLockedForDeepLinks)
764764
return
765765
}
766766
if let receivedURL = notification.object as? URL {
767-
app.retainDeepLink(receivedURL)
767+
app.retainDeepLink(receivedURL, whileLocked: isLockedForDeepLinks)
768768
}
769769
}
770770

771+
private var isLockedForDeepLinks: Bool {
772+
settings.pinEnabled && !isPinVerified
773+
}
774+
771775
private var mainContent: some View {
772776
ZStack {
773777
if Env.isTrezorEmulatorTesting {

‎Bitkit/MainNavView.swift‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,13 @@ struct MainNavView: View {
440440
guard canHandleDeepLinks, url != nil else { return }
441441
Task { await handlePendingDeepLink() }
442442
}
443+
.onChange(of: sheets.isPubkyAuthApprovalShownOrPending) { _, isShownOrPending in
444+
guard canHandleDeepLinks, !isShownOrPending else { return }
445+
Task {
446+
try? await Task.sleep(for: .seconds(SheetViewModel.replacementDelaySeconds))
447+
await handlePendingDeepLink()
448+
}
449+
}
443450
.task(id: scenePhase == .active) {
444451
guard scenePhase == .active, canHandleDeepLinks,
445452
let url = app.pendingDeepLinkURL, PubkyContactLink.matches(url)

‎Bitkit/Utilities/PaymentNavigationHelper.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ struct PaymentNavigationHelper {
1717
spendStore: QuickPaySpendStore = .shared,
1818
coordinator: QuickPayPaymentCoordinator? = nil
1919
) -> Bool {
20+
guard !app.isRoutingDeepLinkFromLock else { return false }
2021
guard let amountSats = QuickPayLimits.paymentAmountSats(app: app), amountSats > 0 else {
2122
return false
2223
}

‎Bitkit/ViewModels/AppViewModel.swift‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,15 @@ class AppViewModel: ObservableObject {
161161
@Published var lnurlWithdrawData: LnurlWithdrawData?
162162

163163
@Published private(set) var pendingDeepLinkURL: URL?
164+
/// The next link, held while a Pubky sign-in link waits or its approval sheet is open, so it cannot drop the sign-in.
165+
private var deepLinkAfterPubkyAuth: URL?
166+
private var deepLinksRetainedWhileLocked: Set<URL> = []
167+
private var lockedDeepLinkRoutesInFlight = 0
168+
/// True while a deep link that arrived behind the PIN lock routes, so a payment opens for review instead of QuickPay.
169+
var isRoutingDeepLinkFromLock: Bool {
170+
lockedDeepLinkRoutesInFlight > 0
171+
}
172+
164173
/// The last routed deep link. On a cold start the same link reaches the app twice (`onOpenURL` and the scene
165174
/// delegate replay), and the startup gates can route the first delivery before the second arrives.
166175
private var lastRoutedDeepLink: (url: URL, date: Date)?
@@ -230,13 +239,33 @@ class AppViewModel: ObservableObject {
230239
appStatusInit = true
231240
}
232241

233-
func retainDeepLink(_ url: URL) {
242+
func retainDeepLink(_ url: URL, whileLocked: Bool = false) {
234243
if let lastRoutedDeepLink, lastRoutedDeepLink.url == url,
235244
currentDate().timeIntervalSince(lastRoutedDeepLink.date) < Self.duplicateDeepLinkWindow
236245
{
237246
Logger.debug("Ignoring duplicate delivery of a routed deep link", context: "AppViewModel")
238247
return
239248
}
249+
if whileLocked {
250+
deepLinksRetainedWhileLocked.insert(url)
251+
}
252+
let isPubkyAuth = Self.isPubkyAuthDeepLink(url)
253+
if let pending = pendingDeepLinkURL, pending != url {
254+
let pendingIsPubkyAuth = Self.isPubkyAuthDeepLink(pending)
255+
if pendingIsPubkyAuth, !isPubkyAuth {
256+
deepLinkAfterPubkyAuth = url
257+
Logger.debug("Holding a deep link until the pending sign-in link is handled", context: "AppViewModel")
258+
return
259+
}
260+
if !pendingIsPubkyAuth, isPubkyAuth {
261+
deepLinkAfterPubkyAuth = pending
262+
pendingDeepLinkURL = url
263+
return
264+
}
265+
} else if pendingDeepLinkURL == nil, deepLinkAfterPubkyAuth != nil, !isPubkyAuth {
266+
deepLinkAfterPubkyAuth = url
267+
return
268+
}
240269
pendingDeepLinkURL = url
241270
}
242271

@@ -246,7 +275,12 @@ class AppViewModel: ObservableObject {
246275
pubkyContactsAreReady: Bool = true,
247276
handler: (URL) async -> Void
248277
) async {
249-
guard isReady, let url = pendingDeepLinkURL else { return }
278+
guard isReady else { return }
279+
promoteDeepLinkAfterPubkyAuthIfIdle()
280+
guard let url = pendingDeepLinkURL else { return }
281+
if sheetViewModel.isPubkyAuthApprovalShownOrPending, !Self.isPubkyAuthDeepLink(url) {
282+
return
283+
}
250284
if PubkyContactLink.publicKey(from: url) != nil, !pubkyContactsAreReady {
251285
return
252286
}
@@ -255,7 +289,28 @@ class AppViewModel: ObservableObject {
255289
}
256290
pendingDeepLinkURL = nil
257291
lastRoutedDeepLink = (url, currentDate())
292+
let isFromLock = deepLinksRetainedWhileLocked.remove(url) != nil
293+
if isFromLock {
294+
lockedDeepLinkRoutesInFlight += 1
295+
}
258296
await handler(url)
297+
if isFromLock {
298+
lockedDeepLinkRoutesInFlight -= 1
299+
}
300+
deepLinksRetainedWhileLocked.formIntersection([pendingDeepLinkURL, deepLinkAfterPubkyAuth].compactMap { $0 })
301+
promoteDeepLinkAfterPubkyAuthIfIdle()
302+
}
303+
304+
private func promoteDeepLinkAfterPubkyAuthIfIdle() {
305+
guard pendingDeepLinkURL == nil, !sheetViewModel.isPubkyAuthApprovalShownOrPending,
306+
let next = deepLinkAfterPubkyAuth
307+
else { return }
308+
deepLinkAfterPubkyAuth = nil
309+
pendingDeepLinkURL = next
310+
}
311+
312+
private static func isPubkyAuthDeepLink(_ url: URL) -> Bool {
313+
PubkyAuthRequest.isProtocolURL(url.absoluteString.removingLightningSchemes())
259314
}
260315

261316
private static func requiresLightningNode(_ url: URL) -> Bool {

‎Bitkit/ViewModels/SheetViewModel.swift‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,17 @@ struct SheetConfiguration {
3939
}
4040

4141
class SheetViewModel: ObservableObject {
42+
/// How long a closing sheet animates out before the next sheet may be shown.
43+
static let replacementDelaySeconds: TimeInterval = 0.7
44+
4245
@Published var activeSheetConfiguration: SheetConfiguration? = nil
4346
@Published var hardwareConnectHandlesPairing = false
4447
@Published private(set) var isReplacingSheet = false
48+
@Published private(set) var replacingSheetID: SheetID?
49+
50+
var isPubkyAuthApprovalShownOrPending: Bool {
51+
activeSheetConfiguration?.id == .pubkyAuthApproval || replacingSheetID == .pubkyAuthApproval
52+
}
4553

4654
func showSheet(_ id: SheetID, data: Any? = nil) {
4755
if activeSheetConfiguration?.id == .send, id == .receivedTx {
@@ -52,11 +60,15 @@ class SheetViewModel: ObservableObject {
5260
// If any other sheet is open, close it and delay before showing the new sheet
5361
// to prevent the new sheet from closing immediately (bug)
5462
isReplacingSheet = true
63+
replacingSheetID = id
5564
hideSheet()
5665

57-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
66+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.replacementDelaySeconds) { [weak self] in
5867
guard let self else { return }
5968
isReplacingSheet = false
69+
if replacingSheetID == id {
70+
replacingSheetID = nil
71+
}
6072
Logger.debug("Showing sheet \(id.rawValue) after delay", context: "SheetViewModel")
6173
activeSheetConfiguration = SheetConfiguration(id: id, data: data)
6274
playHaptics(for: id)
@@ -109,7 +121,7 @@ class SheetViewModel: ObservableObject {
109121

110122
isReplacingSheet = true
111123
hideSheet(reason: reason)
112-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
124+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.replacementDelaySeconds) { [weak self] in
113125
guard let self else { return }
114126
isReplacingSheet = false
115127
guard activeSheetConfiguration == nil else { return }

‎BitkitTests/PubkyAuthURLSchemeTests.swift‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,4 +235,145 @@ final class PubkyAuthURLSchemeTests: XCTestCase {
235235
}
236236
XCTAssertNil(app.pendingDeepLinkURL)
237237
}
238+
239+
@MainActor
240+
func testPendingSignInLinkRoutesBeforeALaterLinkInEitherOrder() async throws {
241+
let signinURL = try signinLink()
242+
let bitcoinURL = try XCTUnwrap(URL(string: "bitcoin:bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080?amount=0.0001"))
243+
244+
for links in [[signinURL, bitcoinURL], [bitcoinURL, signinURL]] {
245+
let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel())
246+
var routed: [URL] = []
247+
for link in links {
248+
app.retainDeepLink(link)
249+
}
250+
251+
await app.routePendingDeepLinkIfReady(true) { routed.append($0) }
252+
await app.routePendingDeepLinkIfReady(true) { routed.append($0) }
253+
254+
XCTAssertEqual(routed, [signinURL, bitcoinURL])
255+
XCTAssertNil(app.pendingDeepLinkURL)
256+
}
257+
}
258+
259+
@MainActor
260+
func testLinkWaitsUntilTheOpenPubkyAuthSheetCloses() async throws {
261+
let sheets = SheetViewModel()
262+
let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel())
263+
let signinURL = try signinLink()
264+
let olderURL = try XCTUnwrap(URL(string: "bitcoin:bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080?amount=0.0001"))
265+
let newerURL = try XCTUnwrap(URL(string: "bitcoin:bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080?amount=0.0002"))
266+
var routed: [URL] = []
267+
268+
app.retainDeepLink(signinURL)
269+
app.retainDeepLink(olderURL)
270+
await app.routePendingDeepLinkIfReady(true) { url in
271+
routed.append(url)
272+
sheets.showSheet(.pubkyAuthApproval)
273+
}
274+
app.retainDeepLink(newerURL)
275+
await app.routePendingDeepLinkIfReady(true) { routed.append($0) }
276+
XCTAssertEqual(routed, [signinURL])
277+
278+
sheets.hideSheet()
279+
await app.routePendingDeepLinkIfReady(true) { routed.append($0) }
280+
await app.routePendingDeepLinkIfReady(true) { routed.append($0) }
281+
282+
XCTAssertEqual(routed, [signinURL, newerURL])
283+
}
284+
285+
@MainActor
286+
func testNewerSignInLinkReplacesAnOlderPendingOne() async throws {
287+
let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel())
288+
let olderURL = try signinLink(caps: "/pub/older/:rw")
289+
let newerURL = try signinLink(caps: "/pub/newer/:rw")
290+
var routed: [URL] = []
291+
292+
app.retainDeepLink(olderURL)
293+
app.retainDeepLink(newerURL)
294+
await app.routePendingDeepLinkIfReady(true) { routed.append($0) }
295+
await app.routePendingDeepLinkIfReady(true) { routed.append($0) }
296+
297+
XCTAssertEqual(routed, [newerURL])
298+
}
299+
300+
@MainActor
301+
func testPendingPubkyAuthSheetStaysTrackedWhenAnotherSheetShowsDuringItsReplacement() {
302+
let sheets = SheetViewModel()
303+
sheets.showSheet(.send)
304+
sheets.showSheet(.pubkyAuthApproval)
305+
XCTAssertTrue(sheets.isPubkyAuthApprovalShownOrPending)
306+
307+
sheets.showSheet(.receivedTx)
308+
309+
XCTAssertTrue(sheets.isPubkyAuthApprovalShownOrPending)
310+
}
311+
312+
@MainActor
313+
func testLinksThatCrossedThePinLockRouteWithoutQuickpay() async throws {
314+
let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel())
315+
let signinURL = try signinLink()
316+
let lockedURL = try XCTUnwrap(URL(string: "lightning:lnbcrt1lockedpayment"))
317+
let unlockedURL = try XCTUnwrap(URL(string: "lightning:lnbcrt1unlockedpayment"))
318+
var routedFromLock: [URL: Bool] = [:]
319+
320+
app.retainDeepLink(lockedURL, whileLocked: true)
321+
app.retainDeepLink(signinURL, whileLocked: true)
322+
await app.routePendingDeepLinkIfReady(true) { routedFromLock[$0] = app.isRoutingDeepLinkFromLock }
323+
await app.routePendingDeepLinkIfReady(true) { routedFromLock[$0] = app.isRoutingDeepLinkFromLock }
324+
app.retainDeepLink(unlockedURL)
325+
await app.routePendingDeepLinkIfReady(true) { routedFromLock[$0] = app.isRoutingDeepLinkFromLock }
326+
327+
XCTAssertEqual(routedFromLock, [signinURL: true, lockedURL: true, unlockedURL: false])
328+
XCTAssertFalse(app.isRoutingDeepLinkFromLock)
329+
}
330+
331+
@MainActor
332+
func testLockedLinkKeepsTheFromLockFlagWhileAnUnlockedLinkRoutesConcurrently() async throws {
333+
let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel())
334+
let lockedURL = try XCTUnwrap(URL(string: "lightning:lnbcrt1lockedpayment"))
335+
let unlockedURL = try XCTUnwrap(URL(string: "lightning:lnbcrt1unlockedpayment"))
336+
var lockedContinuation: CheckedContinuation<Void, Never>?
337+
var unlockedContinuation: CheckedContinuation<Void, Never>?
338+
var lockedFlagAfterResume: Bool?
339+
var unlockedFlagAfterResume: Bool?
340+
341+
app.retainDeepLink(lockedURL, whileLocked: true)
342+
let lockedRoute = Task { @MainActor in
343+
await app.routePendingDeepLinkIfReady(true) { _ in
344+
XCTAssertTrue(app.isRoutingDeepLinkFromLock, "locked link must start routing from lock")
345+
await withCheckedContinuation { lockedContinuation = $0 }
346+
lockedFlagAfterResume = app.isRoutingDeepLinkFromLock
347+
}
348+
}
349+
while lockedContinuation == nil {
350+
await Task.yield()
351+
}
352+
353+
app.retainDeepLink(unlockedURL)
354+
let unlockedRoute = Task { @MainActor in
355+
await app.routePendingDeepLinkIfReady(true) { _ in
356+
await withCheckedContinuation { unlockedContinuation = $0 }
357+
unlockedFlagAfterResume = app.isRoutingDeepLinkFromLock
358+
}
359+
}
360+
while unlockedContinuation == nil {
361+
await Task.yield()
362+
}
363+
364+
unlockedContinuation?.resume()
365+
await unlockedRoute.value
366+
lockedContinuation?.resume()
367+
await lockedRoute.value
368+
369+
XCTAssertNotNil(unlockedFlagAfterResume)
370+
XCTAssertEqual(lockedFlagAfterResume, true, "locked link lost its from-lock flag to a concurrent unlocked route")
371+
XCTAssertFalse(app.isRoutingDeepLinkFromLock)
372+
}
373+
374+
private func signinLink(caps: String = "/pub/locks.app/:rw") throws -> URL {
375+
try XCTUnwrap(URL(string: "bitkit://pubky-auth/signin?caps=\(caps)" +
376+
"&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" +
377+
"&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s\(grantRequester)"))
378+
}
238379
}

‎changelog.d/next/985.fixed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Opening another link while a Pubky sign-in is pending no longer drops the sign-in; the other link opens after it.

‎changelog.d/next/992.fixed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Payment links opened while Bitkit is locked now open for review instead of being paid with QuickPay.

‎journeys/pubky-auth/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
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.
44
It stops at explicit watch-only consent and never authorizes or exports account material.
55
Bitkit retains links delivered during startup, restoration, or PIN entry and presents consent only after the main wallet UI is available.
6+
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.
67

78
## Preconditions
89

0 commit comments

Comments
 (0)