diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 41a77410c..2ee374363 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -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() } @@ -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, @@ -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) @@ -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 { diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index be9e09e1a..268d511bd 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -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 { @@ -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) { diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 020553009..5e1652411 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -37,7 +37,7 @@ $(TREZOR_ELECTRUM_URL) LSApplicationQueriesSchemes - pubkyauth + pubkyring NSAppTransportSecurity diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index c66cf2fcf..ad889f3ad 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -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 @@ -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 } @@ -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"), @@ -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) @@ -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() @@ -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() @@ -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 diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 6f507d27d..53a91e253 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -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 @@ -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 } @@ -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() diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 2758c7bf8..79bd1d72c 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -27,6 +27,8 @@ enum PubkyAuthRequestError: Error, Equatable { case invalidUrl case missingBitkitClaim case duplicateBitkitClaim + case duplicateRelay + case duplicateSecret case unsupportedBitkitClaim(String) case invalidBitkitClaimCapabilities } @@ -43,8 +45,12 @@ struct PubkyAuthPermission { var displayAccess: String { var levels: [String] = [] - if accessLevel.contains("r") { levels.append("READ") } - if accessLevel.contains("w") { levels.append("WRITE") } + if accessLevel.contains("r") { + levels.append("READ") + } + if accessLevel.contains("w") { + levels.append("WRITE") + } return levels.joined(separator: ", ") } } @@ -52,6 +58,9 @@ struct PubkyAuthPermission { // MARK: - PubkyAuth Request struct PubkyAuthRequest { + private static let bitkitSetupHost = "pubky-auth" + private static let bitkitSetupPath = "/setup" + let rawUrl: String let kind: Paykit.PubkyAuthRequestKind let clientID: String @@ -68,8 +77,24 @@ struct PubkyAuthRequest { Self.isSignupURL(rawUrl) } + /// The network origin that receives the authorization. This is a delivery destination, not a service identity. + var relayOrigin: String? { + guard let components = URLComponents(string: relay), + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = components.host?.lowercased(), + !host.isEmpty + else { + return nil + } + + let port = components.port.map { ":\($0)" } ?? "" + return "\(scheme)://\(host)\(port)" + } + static func isProtocolURL(_ value: String) -> Bool { - guard let components = URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines)) else { + let normalizedURL = normalizedProtocolURL(value) + guard let components = URLComponents(string: normalizedURL.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false } @@ -83,22 +108,47 @@ struct PubkyAuthRequest { } } + /// Normalizes Bitkit's unique iOS handoff because the OS cannot deterministically route a custom scheme shared with Pubky Ring. + static func normalizedProtocolURL(_ value: String) -> String { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard isBitkitSetupHandoff(trimmedValue), + let queryDelimiter = trimmedValue.firstIndex(of: "?") + else { + return value + } + + let queryStart = trimmedValue.index(after: queryDelimiter) + return "pubkyauth://signin_grant?\(trimmedValue[queryStart...])" + } + static func parse(url: String) throws -> PubkyAuthRequest { - if let components = URLComponents(string: url), isSignupURL(components) { - return try parseSignup(url: url, components: components) + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + let requiresBitkitClaim = isBitkitSetupHandoff(trimmedURL) + let normalizedURL = normalizedProtocolURL(trimmedURL) + try rejectDuplicateRelayAndSecret(in: normalizedURL) + + if requiresBitkitClaim { + let capabilities = URLComponents(string: normalizedURL)?.queryItems? + .first { $0.name == "caps" }?.value ?? "" + _ = try parseBitkitClaim(url: normalizedURL, capabilities: capabilities, requiresBitkitClaim: true) } - let details = try Paykit.parsePubkyAuthUrl(authUrl: url) + if let components = URLComponents(string: normalizedURL), isSignupURL(components) { + return try parseSignup(url: normalizedURL, components: components) + } + + let details = try Paykit.parsePubkyAuthUrl(authUrl: normalizedURL) let capabilities = details.capabilities return try makeRequest( - url: url, + url: normalizedURL, kind: details.kind, clientID: details.clientId, relay: details.relayUrl, capabilities: capabilities, homeserverPublicKey: nil, signupToken: nil, - authorizationUrl: url + authorizationUrl: normalizedURL, + requiresBitkitClaim: requiresBitkitClaim ) } @@ -165,14 +215,15 @@ struct PubkyAuthRequest { capabilities: String, homeserverPublicKey: String?, signupToken: String?, - authorizationUrl: String? + authorizationUrl: String?, + requiresBitkitClaim: Bool = false ) throws -> PubkyAuthRequest { let permissions = parseCapabilities(capabilities) var seenServiceNames = Set() let serviceNames = permissions .compactMap { extractServiceName($0.path) } .filter { seenServiceNames.insert($0).inserted } - let bitkitClaim = try parseBitkitClaim(url: url, capabilities: capabilities) + let bitkitClaim = try parseBitkitClaim(url: url, capabilities: capabilities, requiresBitkitClaim: requiresBitkitClaim) return PubkyAuthRequest( rawUrl: url, kind: kind, @@ -219,7 +270,17 @@ struct PubkyAuthRequest { return items.first?.value.flatMap { $0.isEmpty ? nil : $0 } } - static func parseBitkitClaim(url: String, capabilities: String) throws -> PubkyAuthClaim? { + private static func rejectDuplicateRelayAndSecret(in url: String) throws { + guard let items = URLComponents(string: url)?.queryItems else { return } + if items.filter({ $0.name == "relay" }).count > 1 { + throw PubkyAuthRequestError.duplicateRelay + } + if items.filter({ $0.name == "secret" }).count > 1 { + throw PubkyAuthRequestError.duplicateSecret + } + } + + static func parseBitkitClaim(url: String, capabilities: String, requiresBitkitClaim: Bool = false) throws -> PubkyAuthClaim? { guard let components = URLComponents(string: url) else { throw PubkyAuthRequestError.invalidUrl } @@ -232,7 +293,7 @@ struct PubkyAuthRequest { throw PubkyAuthRequestError.duplicateBitkitClaim } guard let claimValue = claimValues.first else { - if PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) { + if requiresBitkitClaim || PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) { throw PubkyAuthRequestError.missingBitkitClaim } return nil @@ -247,6 +308,25 @@ struct PubkyAuthRequest { return claim } + private static func isBitkitSetupHandoff(_ value: String) -> Bool { + guard let components = URLComponents(string: value), + components.scheme?.lowercased() == "bitkit", + components.host?.lowercased() == bitkitSetupHost, + components.path == bitkitSetupPath, + components.user == nil, + components.password == nil, + components.port == nil, + components.fragment == nil, + let query = components.percentEncodedQuery, + !query.isEmpty, + !query.hasPrefix("?") + else { + return false + } + + return true + } + static func parseCapabilities(_ caps: String) -> [PubkyAuthPermission] { caps .split(separator: ",") diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 8871ecc61..bce04c4b5 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -81,6 +81,8 @@ "hardware__remove_dialog_keep" = "Back up name and tags"; "hardware__remove_error" = "Could not remove the hardware wallet. Please try again."; "hardware__remove_keep_error" = "Could not keep this wallet's tags in your backup. Try again, or remove it without keeping them."; +"hardware__send_broadcast_failed_text" = "Check your connection and try again."; +"hardware__send_broadcast_failed_title" = "Payment not confirmed"; "hardware__send_confirm_address" = "To address (confirm on device)"; "hardware__send_open_connect" = "Open Trezor Connect"; "hardware__send_sign_title" = "Sign With Device"; @@ -700,10 +702,12 @@ "pubky_auth__watch_only_account_name_error" = "Enter an account name between 1 and 64 characters."; "pubky_auth__watch_only_intro_approve" = "Approve"; "pubky_auth__watch_only_intro_description" = "To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds."; +"pubky_auth__watch_only_intro_relay" = "Your authorization will be delivered to {relay}."; "pubky_auth__watch_only_intro_nav_title" = "Earn"; "pubky_auth__watch_only_intro_title" = "EARN BITCOIN\nFROM YOUR\nCONTENT"; "pubky_auth__watch_only_account_xpub_error" = "Bitkit could not create a valid account xpub."; "pubky_auth__trust_warning" = "Make sure you trust the service, browser, or device before authorizing with your pubky."; +"pubky_auth__authorization_relay" = "AUTHORIZATION RELAY"; "pubky_auth__authorizing" = "Authorizing..."; "pubky_auth__success_title" = "Authorization Successful"; "pubky_auth__success_prefix" = "You authorized with pubky "; diff --git a/Bitkit/SceneDelegate.swift b/Bitkit/SceneDelegate.swift index 51e36c932..570d4e637 100644 --- a/Bitkit/SceneDelegate.swift +++ b/Bitkit/SceneDelegate.swift @@ -1,6 +1,26 @@ import SwiftUI import UIKit +final class DeepLinkRouter { + static let shared = DeepLinkRouter() + + private var pendingURL: URL? + + func retain(_ url: URL) { + pendingURL = url + } + + func forward(_ url: URL) { + retain(url) + NotificationCenter.default.post(name: .deepLinkReceived, object: url) + } + + func consume() -> URL? { + defer { pendingURL = nil } + return pendingURL + } +} + // MARK: - Scene Delegate for Quick Actions /// Handles scene lifecycle and quick actions for SwiftUI apps @@ -8,6 +28,7 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { // MARK: - Quick Action State var savedShortCutItem: UIApplicationShortcutItem? + var savedDeepLinkURL: URL? // MARK: - Scene Connection @@ -16,6 +37,7 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { if let shortcutItem = connectionOptions.shortcutItem { savedShortCutItem = shortcutItem } + savedDeepLinkURL = connectionOptions.urlContexts.first?.url } // MARK: - Scene Activation @@ -26,6 +48,10 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { handleQuickAction(shortcutItem) savedShortCutItem = nil } + if let url = savedDeepLinkURL { + forwardDeepLink(url) + savedDeepLinkURL = nil + } } // MARK: - Quick Action Handling (App Running) @@ -40,6 +66,12 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { completionHandler(true) } + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + for context in URLContexts { + forwardDeepLink(context.url) + } + } + // MARK: - Quick Action Processing /// Process quick action and notify SwiftUI views @@ -47,4 +79,8 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { let userInfo = ["shortcutType": shortcutItem.type] NotificationCenter.default.post(name: .quickActionSelected, object: nil, userInfo: userInfo) } + + func forwardDeepLink(_ url: URL) { + DeepLinkRouter.shared.forward(url) + } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 14764c2f3..c015ea3b9 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -67,6 +67,8 @@ class AppViewModel: ObservableObject { @Published var lnurlPayData: LnurlPayData? @Published var lnurlWithdrawData: LnurlWithdrawData? + @Published private(set) var pendingDeepLinkURL: URL? + // Onboarding @AppStorage("hasDismissedWidgetsOnboardingHint") var hasDismissedWidgetsOnboardingHint: Bool = false @AppStorage("hasSeenContactsIntro") var hasSeenContactsIntro: Bool = false @@ -115,6 +117,54 @@ class AppViewModel: ObservableObject { appStatusInit = true } + func retainDeepLink(_ url: URL) { + pendingDeepLinkURL = url + } + + func routePendingDeepLinkIfReady(_ isReady: Bool, nodeIsRunning: Bool = false, handler: (URL) async -> Void) async { + guard isReady, let url = pendingDeepLinkURL else { return } + if Self.requiresLightningNode(url), !nodeIsRunning { + return + } + pendingDeepLinkURL = nil + await handler(url) + } + + private static func requiresLightningNode(_ url: URL) -> Bool { + if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { + return false + } + if PubkyRingAuthCallback.parse(url: url) != nil { + return false + } + if url.scheme?.lowercased() == "bitkit", + url.host?.lowercased() == "pubky-auth", + url.path == "/setup" + { + return false + } + if SamRockSetupRequest.isProtocolURL(url.absoluteString) { + return false + } + if url.scheme?.lowercased() == "bitcoin" { + return false + } + if isBolt11Invoice(url) { + return false + } + if url.scheme?.lowercased() == "bitkit", + url.host?.lowercased().hasPrefix("gift-") == true + { + return false + } + return !PubkyAuthRequest.isProtocolURL(url.absoluteString) + } + + private static func isBolt11Invoice(_ url: URL) -> Bool { + let invoice = url.absoluteString.removingLightningSchemes().trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return invoice.hasPrefix("lnbc") || invoice.hasPrefix("lntb") + } + private let lightningService: LightningService private let coreService: CoreService private let sheetViewModel: SheetViewModel @@ -347,8 +397,8 @@ extension AppViewModel { case .broadcastConnectivity: toast( type: .warning, - title: t("other__connection_issue"), - description: t("other__connection_issue_explain") + title: t("hardware__send_broadcast_failed_title"), + description: t("hardware__send_broadcast_failed_text") ) case .deviceBusy: toast(type: .info, title: t("hardware__device_busy")) @@ -439,8 +489,9 @@ extension AppViewModel { } } - let uri = uri.removingLightningSchemes() - if let claimedContactPaymentContext, PubkyAuthRequest.isProtocolURL(uri) { + let sourceURI = uri.removingLightningSchemes() + let uri = PubkyAuthRequest.normalizedProtocolURL(sourceURI) + if let claimedContactPaymentContext, PubkyAuthRequest.isProtocolURL(sourceURI) { releaseContactPaymentContext(claimedContactPaymentContext) throw ScanHandlingError.pubkyAuthRequest } @@ -508,7 +559,7 @@ extension AppViewModel { ) return } - await handlePubkyAuthApproval(uri) + await handlePubkyAuthApproval(sourceURI) return } @@ -676,7 +727,7 @@ extension AppViewModel { } handleNodeUri(url) - case let .pubkyAuth(data: authUrl): + case .pubkyAuth: guard PaykitFeatureFlags.isUIEnabled else { toast( type: .error, @@ -686,7 +737,7 @@ extension AppViewModel { ) return } - await handlePubkyAuthApproval(authUrl) + await handlePubkyAuthApproval(sourceURI) case let .gift(code, amount): sheetViewModel.showSheet(.gift, data: GiftConfig(code: code, amount: Int(amount))) default: @@ -821,7 +872,11 @@ extension AppViewModel { } catch { Logger.error("Failed to parse pubky auth URL: \(error)", context: "AppViewModel") sheetViewModel.hideSheetIfActive(.scanner, reason: "Invalid Pubky auth request") - toast(type: .error, title: t("pubky_auth__invalid_request")) + toast( + type: .error, + title: t("pubky_auth__invalid_request"), + accessibilityIdentifier: "PubkyAuthInvalidRequestToast" + ) return } diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 2e69de97e..fd8d4e0f6 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -490,10 +490,10 @@ final class HwSendCoordinator { await afterBroadcast(result) return result } catch { + isBroadcastUnresolved = false let outcomeIsUncertain = (error as? HwTransferError) == .broadcastUncertain if !outcomeIsUncertain, !error.isBroadcastConnectivityFailure() { pendingPayment = nil - isBroadcastUnresolved = false } throw error } diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 17c6179c4..7ecfede2b 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -129,7 +129,7 @@ struct PubkyAuthApprovalSheet: View { SheetIntro( navTitle: t("pubky_auth__watch_only_intro_nav_title"), title: t("pubky_auth__watch_only_intro_title"), - description: t("pubky_auth__watch_only_intro_description"), + description: watchOnlyConsentDescription, image: "coin-stack", continueText: t("pubky_auth__watch_only_intro_approve"), cancelText: t("common__cancel"), @@ -245,6 +245,11 @@ struct PubkyAuthApprovalSheet: View { Spacer().frame(height: 24) } + if let relayOrigin = config.request.relayOrigin { + relayOriginSection(relayOrigin) + .padding(.bottom, 24) + } + if !config.request.permissions.isEmpty { permissionsSection } @@ -291,6 +296,25 @@ struct PubkyAuthApprovalSheet: View { .lineSpacing(4) } + private var watchOnlyConsentDescription: String { + let description = t("pubky_auth__watch_only_intro_description") + guard let relayOrigin = config.request.relayOrigin else { return description } + + return description + "\n\n" + t( + "pubky_auth__watch_only_intro_relay", + variables: ["relay": relayOrigin] + ) + } + + private func relayOriginSection(_ relayOrigin: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("pubky_auth__authorization_relay"), textColor: .white64) + BodySSBText(relayOrigin) + .accessibilityIdentifier("PubkyAuthRelayOrigin") + CustomDivider(color: .white10) + } + } + private var successDescriptionText: some View { BodyMText( t("pubky_auth__success_prefix") + "" + truncatedPublicKey + "" diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 1b72c89ff..818decb0b 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -126,23 +126,23 @@ struct HwSendSignView: View { hwSend.completeBroadcast() navigationPath.append(.success(paymentId: result.txId, walletId: walletId)) } catch is CancellationError { - await cancelContactPaymentIfBroadcastIsResolved() + await cancelContactPaymentIfBroadcastIsRetryable() return } catch is HwPassphraseError { - await cancelContactPaymentIfBroadcastIsResolved() + await cancelContactPaymentIfBroadcastIsRetryable() hwSend.requestPassphrase() } catch let error as HwTransferError { - await cancelContactPaymentIfBroadcastIsResolved() + await cancelContactPaymentIfBroadcastIsRetryable() app.toast(error) } catch { - await cancelContactPaymentIfBroadcastIsResolved() + await cancelContactPaymentIfBroadcastIsRetryable() showHardwareError(error) } } } - private func cancelContactPaymentIfBroadcastIsResolved() async { - guard !hwSend.isBroadcastUnresolved else { return } + private func cancelContactPaymentIfBroadcastIsRetryable() async { + guard !hwSend.hasPendingBroadcast else { return } await cancelContactPayment() } diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 22cc81415..633254a66 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -187,6 +187,50 @@ final class HwFundingSignerTests: XCTestCase { ) } + func testCoordinatorCancelDropsSignedPaymentAfterFailedBroadcast() async throws { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let manager = HwWalletManager() + let coordinator = HwSendCoordinator( + walletId: "trezor:wallet", + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, + connecting: connecting, + feeRate: satsPerVByte, + address: address + ) + } + ) + funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "offline") + + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2 + ) + } + + XCTAssertTrue(coordinator.hasPendingBroadcast) + + coordinator.cancel() + + XCTAssertFalse(coordinator.hasPendingBroadcast) + + funding.broadcastError = nil + _ = try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2 + ) + + XCTAssertEqual(funding.signCalls, 2) + XCTAssertEqual(funding.broadcastCalls, 2) + } + private func assertCoordinatorRetryReusesSignedPayment(error: Error) async throws { let funding = MockHwFunding() let connecting = MockHwConnecting() @@ -218,7 +262,8 @@ final class HwFundingSignerTests: XCTestCase { } XCTAssertTrue(coordinator.hasPendingBroadcast) - XCTAssertTrue(coordinator.isBroadcastUnresolved) + XCTAssertFalse(coordinator.isBroadcastUnresolved) + XCTAssertFalse(coordinator.isSigning) funding.broadcastError = nil _ = try await coordinator.signAndBroadcast( diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 11fab1bb8..4dd3ca1a7 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -16,6 +16,114 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertFalse(PubkyAuthRequest.isProtocolURL("lightning:lnbc1example")) } + func testProtocolUrlNormalizesBitkitSpecificSetupHandoff() throws { + let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=\(relay)&secret=\(secret)&cid=paykit.test&cpk=\(publicKey)&x-bitkit-claim=watch-only-account-v1" + + XCTAssertTrue(PubkyAuthRequest.isProtocolURL(url)) + + let request = try PubkyAuthRequest.parse(url: url) + + XCTAssertTrue(request.rawUrl.hasPrefix("pubkyauth://signin_grant?")) + XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) + XCTAssertEqual(request.capabilities, PubkyAuthClaim.watchOnlyAccountCapabilities) + } + + func testRelayOriginShowsOnlyTheAuthorizationDestination() throws { + let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=https%3A%2F%2FRelay.Example%3A8443%2Finbox%2F&secret=\(secret)" + + "&cid=paykit.test&cpk=\(publicKey)&x-bitkit-claim=watch-only-account-v1" + + let request = try PubkyAuthRequest.parse(url: url) + + XCTAssertEqual(request.relayOrigin, "https://relay.example:8443") + } + + func testProtocolUrlRejectsBitkitSpecificSetupHandoffWithoutClaimMarker() { + let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=\(relay)&secret=\(secret)&cid=paykit.test&cpk=\(publicKey)" + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .missingBitkitClaim) + } + } + + func testProtocolUrlRejectsGenericBitkitSetupHandoffWithoutClaimMarker() { + let url = "bitkit://pubky-auth/setup?caps=/pub/locks.app/:rw&relay=\(relay)&secret=\(secret)" + + "&cid=paykit.test&cpk=\(publicKey)" + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .missingBitkitClaim) + } + } + + func testProtocolUrlDoesNotTreatPubkyRingCallbackAsSetupHandoff() { + let url = "bitkit://pubky-auth/success?nonce=123" + + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) + } + + func testProtocolUrlRejectsSetupHandoffWithUserInfoOrPort() { + let query = "caps=&relay=https%3A%2F%2Fx&secret=first" + let urls = [ + "bitkit://user@pubky-auth/setup?\(query)", + "bitkit://pubky-auth:123/setup?\(query)", + ] + + for url in urls { + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) + } + } + + func testProtocolUrlRejectsFragment() { + let query = "caps=a%2Fb&relay=https%3A%2F%2Fx&secret=first&secret=second" + let url = "bitkit://pubky-auth/setup?\(query)#ignored" + + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) + } + + func testProtocolUrlDoesNotReserializeRawOrEncodedQueryBytes() { + let query = "caps=%23encoded&relay=https%3A%2F%2Fx&secret=first&secret=second" + + XCTAssertEqual( + PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)"), + "pubkyauth://signin_grant?\(query)" + ) + } + + func testProtocolUrlRejectsBitkitSetupHandoffWithoutQuery() { + let url = "bitkit://pubky-auth/setup" + + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) + } + + func testProtocolUrlRejectsEmptyOrDuplicateQueryDelimiter() { + let urls = [ + "bitkit://pubky-auth/setup?", + "bitkit://pubky-auth/setup??secret=first", + ] + + for url in urls { + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) + } + } + + func testProtocolUrlDoesNotTreatFragmentQuestionMarkAsQuery() { + let url = "bitkit://pubky-auth/setup#ignored?caps=" + + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) + } + func testParseAuthorizedSignup() throws { for scheme in ["pubkyring", "pubkyauth"] { let request = try PubkyAuthRequest.parse(url: ringSignupUrl(signupToken: "invite code", scheme: scheme)) @@ -143,6 +251,22 @@ final class PubkyAuthRequestTests: XCTestCase { } } + func testParseUrlRejectsDuplicateRelay() { + let url = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://a&relay=https://b&secret=\(secret)" + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .duplicateRelay) + } + } + + func testParseUrlRejectsDuplicateSecret() { + let url = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://a&secret=first&secret=second" + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .duplicateSecret) + } + } + func testParseUrlRejectsUnknownBitkitClaim() { let url = authUrl(capabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, claimValues: ["unknown-v1"]) diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift new file mode 100644 index 000000000..666a0da1f --- /dev/null +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -0,0 +1,188 @@ +@testable import Bitkit +import XCTest + +final class PubkyAuthURLSchemeTests: XCTestCase { + private let grantRequester = "&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + + func testAppUsesUniqueBitkitSchemeInsteadOfSharedPubkyAuthScheme() throws { + let urlTypes = try XCTUnwrap(Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]]) + let schemes = urlTypes.flatMap { $0["CFBundleURLSchemes"] as? [String] ?? [] } + + XCTAssertTrue(schemes.contains("bitkit")) + XCTAssertFalse(schemes.contains("pubkyauth")) + } + + func testAppQueriesPubkyRingSpecificOutboundURLScheme() throws { + let schemes = try XCTUnwrap(Bundle.main.object(forInfoDictionaryKey: "LSApplicationQueriesSchemes") as? [String]) + + XCTAssertTrue(schemes.contains("pubkyring")) + } + + @MainActor + func testAppDefersGatedPubkyAuthURLAndRoutesWatchOnlyConsentExactlyOnce() async throws { + let hadPreviousPaykitUIValue = UserDefaults.standard.object(forKey: PaykitFeatureFlags.uiEnabledKey) != nil + let previousPaykitUIValue = UserDefaults.standard.bool(forKey: PaykitFeatureFlags.uiEnabledKey) + let previousSession = try? Keychain.loadString(key: .paykitSession) + let previousSecretKey = try? Keychain.loadString(key: .pubkySecretKey) + try Keychain.delete(key: .paykitSession) + try Keychain.delete(key: .pubkySecretKey) + try Keychain.saveString(key: .paykitSession, str: "test-session") + try Keychain.saveString(key: .pubkySecretKey, str: "test-secret-key") + UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + addTeardownBlock { + try? Keychain.delete(key: .paykitSession) + try? Keychain.delete(key: .pubkySecretKey) + if let previousSession { + try? Keychain.saveString(key: .paykitSession, str: previousSession) + } + if let previousSecretKey { + try? Keychain.saveString(key: .pubkySecretKey, str: previousSecretKey) + } + if hadPreviousPaykitUIValue { + UserDefaults.standard.set(previousPaykitUIValue, forKey: PaykitFeatureFlags.uiEnabledKey) + } else { + UserDefaults.standard.removeObject(forKey: PaykitFeatureFlags.uiEnabledKey) + } + } + + let sheets = SheetViewModel() + let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel()) + let url = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s\(grantRequester)&x-bitkit-claim=watch-only-account-v1")) + var routeCount = 0 + + app.retainDeepLink(url) + for gate in ["startup", "restoration", "PIN"] { + await app.routePendingDeepLinkIfReady(false) { _ in + XCTFail("The \(gate) gate must retain the URL while main navigation is hidden") + } + XCTAssertEqual(app.pendingDeepLinkURL, url) + } + + await app.routePendingDeepLinkIfReady(true) { routedURL in + routeCount += 1 + do { + try await app.handleScannedData(routedURL.absoluteString) + } catch { + XCTFail("The retained URL must route through the production scanner: \(error)") + } + } + await app.routePendingDeepLinkIfReady(true) { _ in + routeCount += 1 + } + + XCTAssertEqual(routeCount, 1) + XCTAssertNil(app.pendingDeepLinkURL) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .pubkyAuthApproval) + let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? PubkyAuthApprovalConfig) + XCTAssertEqual(config.request.bitkitClaim, .watchOnlyAccountV1) + XCTAssertTrue(config.request.rawUrl.hasPrefix("pubkyauth://signin_grant?")) + + sheets.hideSheet() + let markerlessURL = "bitkit://pubky-auth/setup?caps=/pub/locks.app/:rw" + + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s\(grantRequester)" + try await app.handleScannedData(markerlessURL) + + XCTAssertNil(sheets.activeSheetConfiguration) + + let duplicateRelayURL = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=https%3A%2F%2Fa&relay=https%3A%2F%2Fb" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s\(grantRequester)&x-bitkit-claim=watch-only-account-v1" + try await app.handleScannedData(duplicateRelayURL) + XCTAssertNil(sheets.activeSheetConfiguration) + + let duplicateSecretURL = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + + "&secret=first&secret=second\(grantRequester)&x-bitkit-claim=watch-only-account-v1" + try await app.handleScannedData(duplicateSecretURL) + XCTAssertNil(sheets.activeSheetConfiguration) + } + + @MainActor + func testNonNodeDeepLinksReleaseAfterStartupGatesWithoutWaitingForLDK() async throws { + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + let pubkyURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s\(grantRequester)&x-bitkit-claim=watch-only-account-v1")) + let httpURL = try XCTUnwrap(URL(string: "https://example.com/article")) + let ringURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/success")) + let malformedPubkyURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup")) + let lightningSamRockURL = try XCTUnwrap( + URL(string: "lightning:https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123") + ) + let lnurlSamRockURL = try XCTUnwrap( + URL(string: "lnurl:https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123") + ) + let bitcoinURL = try XCTUnwrap(URL(string: "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.001")) + let bolt11URL = try XCTUnwrap(URL(string: "lightning:lnbc1example")) + let giftURL = try XCTUnwrap(URL(string: "bitkit://gift-code-1000")) + let lnurlURL = try XCTUnwrap(URL(string: "lnurl:lnurl1example")) + + app.retainDeepLink(pubkyURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, pubkyURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(httpURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, httpURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(ringURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, ringURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(malformedPubkyURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, malformedPubkyURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(lightningSamRockURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, lightningSamRockURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(lnurlSamRockURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, lnurlSamRockURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(bitcoinURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, bitcoinURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(bolt11URL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, bolt11URL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(giftURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, giftURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(lnurlURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { _ in + XCTFail("URLs that need the node must stay pending until LDK is running") + } + XCTAssertEqual(app.pendingDeepLinkURL, lnurlURL) + + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: true) { routedURL in + XCTAssertEqual(routedURL, lnurlURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + } +} diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 6727ac3cd..ffbf92741 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -15,12 +15,16 @@ final class PubkyProfileManagerTests: XCTestCase { func complete() async throws { try await PubkyProfileManager.completeIdentityCreation( loadStoredSecretKey: { - if shouldFail, failedStep == "load" { throw failure } + if shouldFail, failedStep == "load" { + throw failure + } return storedKey }, signIn: { XCTAssertEqual($0, "existing-key") - if shouldFail, failedStep == "signIn" { throw failure } + if shouldFail, failedStep == "signIn" { + throw failure + } return "pubky_existing" }, signUp: { @@ -28,7 +32,9 @@ final class PubkyProfileManagerTests: XCTestCase { return "pubky_new" }, createProfile: { - if shouldFail, failedStep == "profile" { throw failure } + if shouldFail, failedStep == "profile" { + throw failure + } profilePublicKey = $0 }, discardSessionAccess: { @@ -76,7 +82,9 @@ final class PubkyProfileManagerTests: XCTestCase { return "pubky_new" }, createProfile: { - if failsToSaveProfile { throw PubkyServiceError.authFailed("profile") } + if failsToSaveProfile { + throw PubkyServiceError.authFailed("profile") + } profilePublicKey = $0 }, discardSessionAccess: { didDiscard = true } @@ -129,7 +137,9 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertFalse(manager.isProfileSetupPending) XCTAssertNil(manager.publicKey) events.append(step) - if step == failingStep { throw PubkyServiceError.authFailed(step) } + if step == failingStep { + throw PubkyServiceError.authFailed(step) + } } do { @@ -203,6 +213,44 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertEqual(queryItems["x-error"], "bitkit://pubky-auth/error?nonce=12345678-1234-1234-1234-123456789ABC") } + func testPubkyRingAuthURLBuilderCreatesRingSpecificHandoff() throws { + let authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw&relay=https%3A%2F%2Frelay.example&secret=test" + let callbackAuthUrl = try XCTUnwrap(PubkyRingAuthURLBuilder.addingCallbacks(to: authUrl)) + let ringUrl = try XCTUnwrap(PubkyRingAuthURLBuilder.ringHandoffURL(from: callbackAuthUrl)) + let components = try XCTUnwrap(URLComponents(url: ringUrl, resolvingAgainstBaseURL: false)) + let queryItems = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) + + XCTAssertEqual(components.scheme, "pubkyring") + XCTAssertEqual(components.host, "signin") + XCTAssertEqual(components.path, "") + XCTAssertEqual(queryItems["caps"], "/pub/bitkit.to/:rw") + XCTAssertEqual(queryItems["relay"], "https://relay.example") + XCTAssertEqual(queryItems["secret"], "test") + XCTAssertEqual(queryItems["x-success"], PubkyRingAuthURLBuilder.successCallback) + XCTAssertEqual(queryItems["x-cancel"], PubkyRingAuthURLBuilder.cancelCallback) + XCTAssertEqual(queryItems["x-error"], PubkyRingAuthURLBuilder.errorCallback) + XCTAssertEqual(queryItems["x-source"], PubkyRingAuthURLBuilder.source) + } + + func testPubkyRingAuthURLBuilderCreatesRingSpecificHandoffFromLegacyRootURL() throws { + let ringUrl = try XCTUnwrap( + PubkyRingAuthURLBuilder.ringHandoffURL( + from: "pubkyauth:///?caps=/pub/bitkit.to/:rw&relay=https%3A%2F%2Frelay.example&secret=test" + ) + ) + let components = try XCTUnwrap(URLComponents(url: ringUrl, resolvingAgainstBaseURL: false)) + + XCTAssertEqual(components.scheme, "pubkyring") + XCTAssertEqual(components.host, "signin") + XCTAssertEqual(components.path, "") + } + + func testPubkyRingAuthURLBuilderRejectsOtherSchemes() { + XCTAssertNil(PubkyRingAuthURLBuilder.ringHandoffURL(from: "bitkit://pubky-auth/success")) + } + func testPubkyRingAuthCallbackParsesNonce() throws { XCTAssertEqual( try PubkyRingAuthCallback.parse(url: XCTUnwrap(URL(string: "bitkit://pubky-auth/error?nonce=abc&errorMessage=Denied"))), diff --git a/BitkitTests/SceneDelegateTests.swift b/BitkitTests/SceneDelegateTests.swift new file mode 100644 index 000000000..7a18676e8 --- /dev/null +++ b/BitkitTests/SceneDelegateTests.swift @@ -0,0 +1,25 @@ +@testable import Bitkit +import XCTest + +final class SceneDelegateTests: XCTestCase { + func testForwardsDeepLinksToSwiftUIRetentionPath() throws { + let delegate = SceneDelegate() + let url = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=example")) + _ = DeepLinkRouter.shared.consume() + let forwarded = expectation(description: "deep link forwarded") + let observer = NotificationCenter.default.addObserver( + forName: .deepLinkReceived, + object: nil, + queue: nil + ) { notification in + XCTAssertEqual(notification.object as? URL, url) + forwarded.fulfill() + } + defer { NotificationCenter.default.removeObserver(observer) } + + delegate.forwardDeepLink(url) + + wait(for: [forwarded], timeout: 1) + XCTAssertEqual(DeepLinkRouter.shared.consume(), url) + } +} diff --git a/changelog.d/next/722.added.md b/changelog.d/next/722.added.md new file mode 100644 index 000000000..1a656a8d9 --- /dev/null +++ b/changelog.d/next/722.added.md @@ -0,0 +1 @@ +Bitkit now opens Pubky marketplace setup links directly into explicit watch-only account consent. diff --git a/changelog.d/next/729.fixed.md b/changelog.d/next/729.fixed.md new file mode 100644 index 000000000..d3c0ba9e2 --- /dev/null +++ b/changelog.d/next/729.fixed.md @@ -0,0 +1 @@ +A hardware-wallet payment that fails to broadcast now tells you the payment was not confirmed and lets you retry or leave, instead of locking the send screen. diff --git a/journeys/README.md b/journeys/README.md index 905af7d60..f40e90d50 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -135,13 +135,14 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [cjit-notifications](cjit-notifications) | 3 | Adapted — iOS notification copy differs from Android | | [hardware-wallet](hardware-wallet) | 15 | Trezor over Bridge; see `Docs/AI_DEVICE_TESTS.md` | +| [pubky-auth](pubky-auth) | 1 | Bitkit-specific OS handoff into watch-only consent; local Pubky identity required | ## Not ported **`deeplinks` (2 journeys).** The 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`) -but `onOpenURL` in `Bitkit/MainNavView.swift` only handles web URLs, Pubky auth callbacks and -payment URIs — there is no screen or sheet deeplink router, and no dev-mode gate to test. These +and retains external URLs in `AppScene`, but `MainNavView` only routes web URLs, Pubky 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. ## Porting from Android diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md new file mode 100644 index 000000000..dca0d0bd0 --- /dev/null +++ b/journeys/pubky-auth/README.md @@ -0,0 +1,14 @@ +# Pubky auth + +This suite covers the uniquely targetable `bitkit://pubky-auth/setup` OS handoff into Bitkit. The wrapper carries the Paykit grant-auth requester fields, normalizes to `pubkyauth://signin_grant`, and is the only link form that receives Bitkit claim validation. `lightning:`/`lnurl*:`-prefixed raw `pubkyauth://` auth and signup requests are also accepted from OS links, matching scanner and clipboard-paste behavior. +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. + +## Preconditions + +- Build and run Bitkit with `E2E_BUILD`. +- Complete wallet onboarding. +- Enable Paykit UI in developer settings. +- Create a Pubky profile in Bitkit so the wallet has a local identity secret. + +The journey uses a syntactically valid dummy request and does not contact its relay unless the authorization flow is completed. diff --git a/journeys/pubky-auth/open-watch-only-link.xml b/journeys/pubky-auth/open-watch-only-link.xml new file mode 100644 index 000000000..38272993d --- /dev/null +++ b/journeys/pubky-auth/open-watch-only-link.xml @@ -0,0 +1,14 @@ + + Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Bitkit-generated Pubky identity. This journey launches the terminated app with a local-only dummy setup request and cancels before account material is exported. + + Run `xcrun simctl terminate <UDID> to.bitkit` + Run `xcrun simctl openurl <UDID> "bitkit://pubky-auth/setup?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo&x-bitkit-claim=watch-only-account-v1"` + If the simulator asks to open the link in Bitkit, tap Open + Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is visible + Tap Cancel (id "PubkyAuthWatchOnlyCancel") + Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is no longer visible + Run `xcrun simctl openurl <UDID> "bitkit://pubky-auth/setup?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo&x-bitkit-claim=unsupported-v1"` + Verify the invalid request toast (id "PubkyAuthInvalidRequestToast") is visible + Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is not visible + +