Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,13 @@ struct MainNavView: View {
guard canHandleDeepLinks, url != nil else { return }
Task { await handlePendingDeepLink() }
}
.onChange(of: sheets.isPubkyAuthApprovalShownOrPending) { _, isShownOrPending in
Comment thread
ovitrif marked this conversation as resolved.
guard canHandleDeepLinks, !isShownOrPending else { return }
Task {
try? await Task.sleep(for: .seconds(SheetViewModel.replacementDelaySeconds))
await handlePendingDeepLink()
}
}
.task(id: scenePhase == .active) {
guard scenePhase == .active, canHandleDeepLinks,
let url = app.pendingDeepLinkURL, PubkyContactLink.matches(url)
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Utilities/PaymentNavigationHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
59 changes: 57 additions & 2 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ 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?
private var deepLinksRetainedWhileLocked: Set<URL> = []
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.
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)?
Expand Down Expand Up @@ -230,13 +239,33 @@ 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)
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
}

Expand All @@ -246,7 +275,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
}
Expand All @@ -255,7 +289,28 @@ class AppViewModel: ObservableObject {
}
pendingDeepLinkURL = nil
lastRoutedDeepLink = (url, currentDate())
let isFromLock = deepLinksRetainedWhileLocked.remove(url) != nil
if isFromLock {
lockedDeepLinkRoutesInFlight += 1
}
await handler(url)
if isFromLock {
lockedDeepLinkRoutesInFlight -= 1
}
deepLinksRetainedWhileLocked.formIntersection([pendingDeepLinkURL, deepLinkAfterPubkyAuth].compactMap { $0 })
promoteDeepLinkAfterPubkyAuthIfIdle()
}

private func promoteDeepLinkAfterPubkyAuthIfIdle() {
guard pendingDeepLinkURL == nil, !sheetViewModel.isPubkyAuthApprovalShownOrPending,
let next = deepLinkAfterPubkyAuth
else { return }
deepLinkAfterPubkyAuth = nil
pendingDeepLinkURL = next
Comment thread
ovitrif marked this conversation as resolved.
}

private static func isPubkyAuthDeepLink(_ url: URL) -> Bool {
PubkyAuthRequest.isProtocolURL(url.absoluteString.removingLightningSchemes())
}

private static func requiresLightningNode(_ url: URL) -> Bool {
Expand Down
16 changes: 14 additions & 2 deletions Bitkit/ViewModels/SheetViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,17 @@ 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
@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 {
Expand All @@ -52,11 +60,15 @@ 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
DispatchQueue.main.asyncAfter(deadline: .now() + Self.replacementDelaySeconds) { [weak self] in
guard let self else { return }
isReplacingSheet = false
if replacingSheetID == id {
replacingSheetID = nil
}
Logger.debug("Showing sheet \(id.rawValue) after delay", context: "SheetViewModel")
activeSheetConfiguration = SheetConfiguration(id: id, data: data)
playHaptics(for: id)
Expand Down Expand Up @@ -109,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 }
Expand Down
141 changes: 141 additions & 0 deletions BitkitTests/PubkyAuthURLSchemeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -235,4 +235,145 @@ 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])
}

@MainActor
func testPendingPubkyAuthSheetStaysTrackedWhenAnotherSheetShowsDuringItsReplacement() {
let sheets = SheetViewModel()
sheets.showSheet(.send)
sheets.showSheet(.pubkyAuthApproval)
XCTAssertTrue(sheets.isPubkyAuthApprovalShownOrPending)

sheets.showSheet(.receivedTx)

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)
}

@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<Void, Never>?
var unlockedContinuation: CheckedContinuation<Void, Never>?
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" +
"&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s\(grantRequester)"))
}
}
1 change: 1 addition & 0 deletions changelog.d/next/985.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/next/992.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Payment links opened while Bitkit is locked now open for review instead of being paid with QuickPay.
1 change: 1 addition & 0 deletions journeys/pubky-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading