Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9de1073
fix: release the navigation lock in on broadcast error
jvsena42 Sep 7, 2026
3fcf1e1
fix: display specific error message for broadcast error
jvsena42 Sep 7, 2026
0ddab5b
test: broadcast error tests
jvsena42 Sep 7, 2026
94690d5
chore: add changelog fragment
jvsena42 Sep 7, 2026
1b9ffc0
chore: rename changelog fragment
jvsena42 Sep 7, 2026
505da8d
fix: keep the payment proof for an unresolved broadcast
jvsena42 Sep 7, 2026
60e75e1
Merge pull request #729 from synonymdev/fix/hw-send-broadcast-lockout
jvsena42 Sep 8, 2026
ae4b584
feat: open Pubky auth links
ovitrif Sep 2, 2026
9dac17b
fix: preserve Pubky Ring handoff
ovitrif Sep 2, 2026
d24cb40
chore: rename changelog fragment
ovitrif Sep 2, 2026
6ce8c65
fix: retain deep links through startup
ovitrif Sep 2, 2026
816ad38
fix: target Bitkit Pubky auth links uniquely
ovitrif Sep 2, 2026
b1bd510
fix: reject malformed Pubky handoff fragments
ovitrif Sep 2, 2026
d78e3d1
fix: preserve Pubky wrapper validation
ovitrif Sep 2, 2026
2687f89
fix: ungated Pubky links; reject dupes
ovitrif Sep 2, 2026
a801819
fix: classify setup routes before node
ovitrif Sep 3, 2026
4b07f97
fix: ungated BIP21 and BOLT11 links
ovitrif Sep 3, 2026
69025cd
fix: route gifts and identify auth relay
ovitrif Sep 5, 2026
02eb8d4
fix: retain scene-delivered deep links
ovitrif Sep 5, 2026
8925916
fix: forward app-delivered deep links
ovitrif Sep 5, 2026
4d0a0b6
chore: format deep-link files
ovitrif Sep 8, 2026
bcc0725
feat: stack marketplace auth on signup
ovitrif Sep 8, 2026
c2de3f0
docs: clarify pubky link contract
ovitrif Sep 8, 2026
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
22 changes: 22 additions & 0 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,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) }
// Bridge Trezor device state into the watch-only manager without coupling the two:
// TrezorManager bumps devicesRevision on any device/connection change.
.onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() }
Expand Down Expand Up @@ -286,6 +287,10 @@ struct AppScene: View {
isPinVerified = true
}

if let url = DeepLinkRouter.shared.consume() {
app.retainDeepLink(url)
}

// Listen for quick action notifications
NotificationCenter.default.addObserver(
forName: .quickActionSelected,
Expand All @@ -294,6 +299,13 @@ struct AppScene: View {
) { notification in
handleQuickAction(notification)
}
NotificationCenter.default.addObserver(
forName: .deepLinkReceived,
object: nil,
queue: .main
) { notification in
handleDeepLinkNotification(notification)
}
}
.onReceive(BackupService.shared.backupFailurePublisher) { intervalMinutes in
handleBackupFailure(intervalMinutes: intervalMinutes)
Expand All @@ -304,6 +316,16 @@ struct AppScene: View {
}
}

private func handleDeepLinkNotification(_ notification: Notification) {
if let retainedURL = DeepLinkRouter.shared.consume() {
app.retainDeepLink(retainedURL)
return
}
if let receivedURL = notification.object as? URL {
app.retainDeepLink(receivedURL)
}
}

private var mainContent: some View {
ZStack {
if Env.isTrezorEmulatorTesting {
Expand Down
10 changes: 10 additions & 0 deletions Bitkit/BitkitApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import SwiftUI
/// Communication bridge between delegates and SwiftUI views
extension Notification.Name {
static let quickActionSelected = Notification.Name("quickActionSelected")
static let deepLinkReceived = Notification.Name("deepLinkReceived")
}

class AppDelegate: NSObject, UIApplicationDelegate {
Expand Down Expand Up @@ -39,6 +40,15 @@ class AppDelegate: NSObject, UIApplicationDelegate {
return config
}

func application(
_ application: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
DeepLinkRouter.shared.forward(url)
return true
}

// MARK: - App Termination

func applicationWillTerminate(_ application: UIApplication) {
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<string>$(TREZOR_ELECTRUM_URL)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>pubkyauth</string>
<string>pubkyring</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
Expand Down
220 changes: 145 additions & 75 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ func resolvePendingProfileSetupResumeState(
}

struct MainNavView: View {
private let canHandleDeepLinks: Bool

@AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false

@EnvironmentObject private var app: AppViewModel
Expand All @@ -52,6 +54,10 @@ struct MainNavView: View {
@State private var clipboardUri: String?
@State private var didResumePendingPubkyProfileSetup = false

init(canHandleDeepLinks: Bool = true) {
self.canHandleDeepLinks = canHandleDeepLinks
}

private var isPaykitUIActive: Bool {
PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled
}
Expand Down Expand Up @@ -363,69 +369,13 @@ struct MainNavView: View {
notificationManager.unregister()
}
}
.onOpenURL { url in
Task {
Logger.info("Received deeplink: \(sanitizedDeeplinkDescription(url))")

// Web URLs from widgets (e.g. news article tap) bypass payment handling
if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
await UIApplication.shared.open(url)
return
}

if let callback = PubkyRingAuthCallback.parse(url: url) {
guard isPaykitUIActive else {
app.toast(
type: .error,
title: t("profile__auth_error_title"),
description: t("other__qr_error_text")
)
return
}

let handlingResult = await pubkyProfile.handleAuthCallback(callback)

switch handlingResult {
case let .trustedError(message):
app.toast(
type: .error,
title: t("profile__auth_error_title"),
description: message ?? t("other__qr_error_text")
)
case .untrustedError:
app.toast(
type: .error,
title: t("profile__auth_error_title")
)
case .handled, .ignored:
break
}

return
}

do {
try await app.handleScannedData(
url.absoluteString,
alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats
)
if shouldOpenPaymentSheet(for: url.absoluteString) {
PaymentNavigationHelper.openPaymentSheet(
app: app,
currency: currency,
settings: settings,
sheetViewModel: sheets
)
}
} catch {
Logger.error(error, context: "Failed to handle deeplink")
app.toast(
type: .error,
title: t("other__qr_error_header"),
description: t("other__qr_error_text")
)
}
}
.task(id: [canHandleDeepLinks, wallet.nodeLifecycleState == .running]) {
guard canHandleDeepLinks else { return }
await handlePendingDeepLink()
}
.onChange(of: app.pendingDeepLinkURL) { _, url in
guard canHandleDeepLinks, url != nil else { return }
Task { await handlePendingDeepLink() }
}
.alert(
t("other__clipboard_redirect_title"),
Expand Down Expand Up @@ -532,13 +482,29 @@ struct MainNavView: View {
ContactsIntroView()
}
case .contactsIntro:
if isPaykitUIActive { ContactsIntroView() } else { ComingSoonScreen() }
if isPaykitUIActive {
ContactsIntroView()
} else {
ComingSoonScreen()
}
case let .contactDetail(publicKey):
if isPaykitUIActive { ContactDetailView(publicKey: publicKey) } else { paykitDisabledRedirectView }
if isPaykitUIActive {
ContactDetailView(publicKey: publicKey)
} else {
paykitDisabledRedirectView
}
case let .contactSaved(publicKey):
if isPaykitUIActive { ContactDetailView(publicKey: publicKey, showsDeleteAction: true) } else { paykitDisabledRedirectView }
if isPaykitUIActive {
ContactDetailView(publicKey: publicKey, showsDeleteAction: true)
} else {
paykitDisabledRedirectView
}
case let .contactActivity(publicKey):
if isPaykitUIActive { ContactActivityView(publicKey: publicKey) } else { paykitDisabledRedirectView }
if isPaykitUIActive {
ContactActivityView(publicKey: publicKey)
} else {
paykitDisabledRedirectView
}
case let .assignActivityContact(activityId, walletId):
if isPaykitUIActive {
AssignActivityContactView(activityId: activityId, walletId: walletId)
Expand Down Expand Up @@ -567,9 +533,17 @@ struct MainNavView: View {
ContactImportSelectView(contacts: contactsManager.pendingImportContacts)
}
case let .addContact(publicKey):
if isPaykitUIActive { AddContactView(publicKey: publicKey) } else { paykitDisabledRedirectView }
if isPaykitUIActive {
AddContactView(publicKey: publicKey)
} else {
paykitDisabledRedirectView
}
case let .editContact(publicKey):
if isPaykitUIActive { EditContactView(publicKey: publicKey) } else { paykitDisabledRedirectView }
if isPaykitUIActive {
EditContactView(publicKey: publicKey)
} else {
paykitDisabledRedirectView
}
case .profile:
if !isPaykitUIActive {
ComingSoonScreen()
Expand All @@ -585,17 +559,41 @@ struct MainNavView: View {
ProfileIntroView()
}
case .profileIntro:
if isPaykitUIActive { ProfileIntroView() } else { ComingSoonScreen() }
if isPaykitUIActive {
ProfileIntroView()
} else {
ComingSoonScreen()
}
case .pubkyChoice:
if isPaykitUIActive { PubkyChoiceView() } else { paykitDisabledRedirectView }
if isPaykitUIActive {
PubkyChoiceView()
} else {
paykitDisabledRedirectView
}
case .createProfile:
if isPaykitUIActive { CreateProfileView() } else { paykitDisabledRedirectView }
if isPaykitUIActive {
CreateProfileView()
} else {
paykitDisabledRedirectView
}
case .editProfile:
if isPaykitUIActive { EditProfileView() } else { paykitDisabledRedirectView }
if isPaykitUIActive {
EditProfileView()
} else {
paykitDisabledRedirectView
}
case .payContacts:
if isPaykitUIActive { PayContactsView() } else { paykitDisabledRedirectView }
if isPaykitUIActive {
PayContactsView()
} else {
paykitDisabledRedirectView
}
case .paymentRequests:
if isPaykitUIActive { PaymentRequestsView() } else { paykitDisabledRedirectView }
if isPaykitUIActive {
PaymentRequestsView()
} else {
paykitDisabledRedirectView
}

// Shop
case .shopIntro: ShopIntro()
Expand Down Expand Up @@ -744,6 +742,78 @@ struct MainNavView: View {
!SamRockSetupRequest.isProtocolURL(uri) && !PubkyAuthRequest.isProtocolURL(uri)
}

private func handlePendingDeepLink() async {
await app.routePendingDeepLinkIfReady(
canHandleDeepLinks,
nodeIsRunning: wallet.nodeLifecycleState == .running
) { url in
await handleDeepLink(url)
}
}

private func handleDeepLink(_ url: URL) async {
Logger.info("Received deeplink: \(sanitizedDeeplinkDescription(url))")

// Web URLs from widgets (e.g. news article tap) bypass payment handling
if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
await UIApplication.shared.open(url)
return
}

if let callback = PubkyRingAuthCallback.parse(url: url) {
guard isPaykitUIActive else {
app.toast(
type: .error,
title: t("profile__auth_error_title"),
description: t("other__qr_error_text")
)
return
}

let handlingResult = await pubkyProfile.handleAuthCallback(callback)

switch handlingResult {
case let .trustedError(message):
app.toast(
type: .error,
title: t("profile__auth_error_title"),
description: message ?? t("other__qr_error_text")
)
case .untrustedError:
app.toast(
type: .error,
title: t("profile__auth_error_title")
)
case .handled, .ignored:
break
}

return
}

do {
try await app.handleScannedData(
url.absoluteString,
alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats
)
if shouldOpenPaymentSheet(for: url.absoluteString) {
PaymentNavigationHelper.openPaymentSheet(
app: app,
currency: currency,
settings: settings,
sheetViewModel: sheets
)
}
} catch {
Logger.error(error, context: "Failed to handle deeplink")
app.toast(
type: .error,
title: t("other__qr_error_header"),
description: t("other__qr_error_text")
)
}
}

private func sanitizedDeeplinkDescription(_ url: URL) -> String {
if let description = SamRockSetupRequest.sanitizedDescription(url.absoluteString) {
return description
Expand Down
15 changes: 13 additions & 2 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ enum PubkyRingAuthURLBuilder {
return components.url?.absoluteString
}

static func ringHandoffURL(from authUrl: String) -> URL? {
guard var components = URLComponents(string: authUrl), components.scheme?.lowercased() == "pubkyauth" else {
return nil
}

components.scheme = "pubkyring"
components.host = "signin"
components.path = ""
return components.url
}

private static func callbackUrl(_ baseUrl: String, nonce: UUID?) -> String {
guard let nonce else {
return baseUrl
Expand Down Expand Up @@ -500,7 +511,7 @@ class PubkyProfileManager: ObservableObject {
}

static func isRingAvailable() -> Bool {
guard let url = URL(string: "pubkyauth://check") else {
guard let url = URL(string: "pubkyring://check") else {
return false
}

Expand Down Expand Up @@ -588,7 +599,7 @@ class PubkyProfileManager: ObservableObject {

let callbackAuthUrl = PubkyRingAuthURLBuilder.addingCallbacks(to: authUrl, nonce: attemptID) ?? authUrl

guard let url = URL(string: callbackAuthUrl) else {
guard let url = PubkyRingAuthURLBuilder.ringHandoffURL(from: callbackAuthUrl) else {
await cancelPendingAuthSetup()
activeAuthAttemptID = nil
restoreAuthStateAfterAuthFlow()
Expand Down
Loading
Loading