diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ab8f05a7f..790311d36 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -146,6 +146,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() } @@ -283,6 +284,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, @@ -291,6 +296,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) @@ -301,6 +313,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 a1b201357..da674bfa3 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -1,6 +1,8 @@ import SwiftUI struct MainNavView: View { + private let canHandleDeepLinks: Bool + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @EnvironmentObject private var app: AppViewModel @@ -21,6 +23,10 @@ struct MainNavView: View { @State private var showClipboardAlert = false @State private var clipboardUri: String? + init(canHandleDeepLinks: Bool = true) { + self.canHandleDeepLinks = canHandleDeepLinks + } + private var isPaykitUIActive: Bool { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled } @@ -317,69 +323,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"), @@ -698,6 +648,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 b3da17c3d..05efad323 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -95,6 +95,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 @@ -389,7 +400,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 } @@ -477,7 +488,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 8b06f15c1..a63d00bf3 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -26,6 +26,8 @@ enum PubkyAuthRequestError: Error, Equatable { case invalidUrl case missingBitkitClaim case duplicateBitkitClaim + case duplicateRelay + case duplicateSecret case unsupportedBitkitClaim(String) case invalidBitkitClaimCapabilities } @@ -51,6 +53,9 @@ struct PubkyAuthPermission { // MARK: - PubkyAuth Request (parsed from pubkyauth:// URL) struct PubkyAuthRequest { + private static let bitkitSetupHost = "pubky-auth" + private static let bitkitSetupPath = "/setup" + let rawUrl: String let kind: Paykit.PubkyAuthRequestKind let relay: String @@ -59,21 +64,56 @@ struct PubkyAuthRequest { let serviceNames: [String] let bitkitClaim: PubkyAuthClaim? + /// 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 { - URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() == "pubkyauth" + URLComponents(string: normalizedProtocolURL(value).trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() == "pubkyauth" + } + + /// 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?\(trimmedValue[queryStart...])" } static func parse(url: String) throws -> PubkyAuthRequest { - let details = try Paykit.parsePubkyAuthUrl(authUrl: url) + let requiresBitkitClaim = isBitkitSetupHandoff(url.trimmingCharacters(in: .whitespacesAndNewlines)) + let normalizedURL = normalizedProtocolURL(url) + try rejectDuplicateRelayAndSecret(in: normalizedURL) + let details = try Paykit.parsePubkyAuthUrl(authUrl: normalizedURL) let capabilities = details.capabilities ?? "" 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: normalizedURL, + capabilities: capabilities, + requiresBitkitClaim: requiresBitkitClaim + ) return PubkyAuthRequest( - rawUrl: url, + rawUrl: normalizedURL, kind: details.kind, relay: details.relayUrl ?? "", capabilities: capabilities, @@ -83,7 +123,17 @@ struct PubkyAuthRequest { ) } - 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 } @@ -96,7 +146,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 @@ -111,6 +161,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 634feaa4b..c5821ce38 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -699,10 +699,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 4fb599759..f122d5a3d 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 @@ -436,7 +486,8 @@ extension AppViewModel { } } - let uri = uri.removingLightningSchemes() + let sourceURI = uri.removingLightningSchemes() + let uri = PubkyAuthRequest.normalizedProtocolURL(sourceURI) let prevalidatedPaymentRequest: BitkitCore.Scanner? if scope == .paymentRequests { guard SamRockSetupRequest.parse(uri) == nil, @@ -649,7 +700,7 @@ extension AppViewModel { } handleNodeUri(url) - case let .pubkyAuth(data: authUrl): + case .pubkyAuth: guard PaykitFeatureFlags.isUIEnabled else { toast( type: .error, @@ -659,7 +710,7 @@ extension AppViewModel { ) return } - handlePubkyAuthApproval(authUrl) + handlePubkyAuthApproval(sourceURI) case let .gift(code, amount): sheetViewModel.showSheet(.gift, data: GiftConfig(code: code, amount: Int(amount))) default: @@ -786,7 +837,7 @@ extension AppViewModel { sheetViewModel.showSheet(.lnurlAuth, data: LnurlAuthConfig(lnurl: lnurl, authData: data)) } - private func handlePubkyAuthApproval(_ authUrl: String) { + private func handlePubkyAuthApproval(_ sourceURL: String) { // State 1: No Pubky identity at all guard (try? Keychain.loadString(key: .paykitSession))?.isEmpty == false else { toast(type: .warning, title: t("pubky_auth__no_identity"), description: t("pubky_auth__no_identity_desc")) @@ -803,11 +854,15 @@ extension AppViewModel { // State 3: Bitkit-generated identity — can approve do { - let request = try PubkyAuthRequest.parse(url: authUrl) - sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(authUrl: authUrl, request: request)) + let request = try PubkyAuthRequest.parse(url: sourceURL) + sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(authUrl: request.rawUrl, request: request)) } catch { Logger.error("Failed to parse pubky auth URL: \(error)", context: "AppViewModel") - toast(type: .error, title: t("pubky_auth__invalid_request")) + toast( + type: .error, + title: t("pubky_auth__invalid_request"), + accessibilityIdentifier: "PubkyAuthInvalidRequestToast" + ) } } diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 2d79b2076..dae67f596 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -131,7 +131,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"), @@ -231,6 +231,11 @@ struct PubkyAuthApprovalSheet: View { descriptionText .padding(.bottom, 32) + if let relayOrigin = config.request.relayOrigin { + relayOriginSection(relayOrigin) + .padding(.bottom, 24) + } + permissionsSection Spacer(minLength: 32) @@ -260,6 +265,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/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index b1b4af240..583172db0 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -13,6 +13,112 @@ 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)&x-bitkit-claim=watch-only-account-v1" + + XCTAssertTrue(PubkyAuthRequest.isProtocolURL(url)) + + let request = try PubkyAuthRequest.parse(url: url) + + XCTAssertTrue(request.rawUrl.hasPrefix("pubkyauth://signin?")) + 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)&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)" + + 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)" + + 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?\(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 testParseUrlPreservesRequestedCapabilities() throws { let capabilities = "/pub/bitkit.to/:rw" let url = "pubkyauth://signin?caps=\(capabilities)&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" @@ -97,6 +203,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..49b97f8f7 --- /dev/null +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -0,0 +1,186 @@ +@testable import Bitkit +import XCTest + +final class PubkyAuthURLSchemeTests: XCTestCase { + 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&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.authUrl.hasPrefix("pubkyauth://signin?")) + + sheets.hideSheet() + let markerlessURL = "bitkit://pubky-auth/setup?caps=/pub/locks.app/:rw" + + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + 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&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&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&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 43be620ac..28a32b1a8 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -46,6 +46,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/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..8ea0c7173 --- /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. Raw `pubkyauth` setup requests remain supported through QR scanning and clipboard paste for compatibility with the Pubky protocol. +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..90ec43130 --- /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&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&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 + +