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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<string>$(TREZOR_ELECTRUM_URL)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>pubkyauth</string>
<string>pubkyring</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
Expand Down
148 changes: 85 additions & 63 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -315,69 +321,13 @@ struct MainNavView: View {
notificationManager.unregister()
}
}
.onOpenURL { url in
Task {
Logger.info("Received deeplink: \(sanitizedDeeplinkDescription(url))")

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

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

let handlingResult = await pubkyProfile.handleAuthCallback(callback)

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

return
}

do {
try await app.handleScannedData(
url.absoluteString,
alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats
)
if shouldOpenPaymentSheet(for: url.absoluteString) {
PaymentNavigationHelper.openPaymentSheet(
app: app,
currency: currency,
settings: settings,
sheetViewModel: sheets
)
}
} catch {
Logger.error(error, context: "Failed to handle deeplink")
app.toast(
type: .error,
title: t("other__qr_error_header"),
description: t("other__qr_error_text")
)
}
}
.task(id: [canHandleDeepLinks, wallet.nodeLifecycleState == .running]) {
guard canHandleDeepLinks else { return }
await handlePendingDeepLink()
}
.onChange(of: app.pendingDeepLinkURL) { _, url in
guard canHandleDeepLinks, url != nil else { return }
Task { await handlePendingDeepLink() }
}
.alert(
t("other__clipboard_redirect_title"),
Expand Down Expand Up @@ -696,6 +646,78 @@ struct MainNavView: View {
!SamRockSetupRequest.isProtocolURL(uri) && !PubkyAuthRequest.isProtocolURL(uri)
}

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

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

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

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

let handlingResult = await pubkyProfile.handleAuthCallback(callback)

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

return
}

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

private func sanitizedDeeplinkDescription(_ url: URL) -> String {
if let description = SamRockSetupRequest.sanitizedDescription(url.absoluteString) {
return description
Expand Down
15 changes: 13 additions & 2 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down
66 changes: 60 additions & 6 deletions Bitkit/Models/PubkyAuthRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ enum PubkyAuthRequestError: Error, Equatable {
case invalidUrl
case missingBitkitClaim
case duplicateBitkitClaim
case duplicateRelay
case duplicateSecret
case unsupportedBitkitClaim(String)
case invalidBitkitClaimCapabilities
}
Expand All @@ -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
Expand All @@ -60,20 +65,40 @@ struct PubkyAuthRequest {
let bitkitClaim: PubkyAuthClaim?

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)
Comment thread
ovitrif marked this conversation as resolved.
let capabilities = details.capabilities ?? ""
let permissions = parseCapabilities(capabilities)
var seenServiceNames = Set<String>()
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,
Expand All @@ -83,7 +108,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
}
Expand All @@ -96,7 +131,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
Expand All @@ -111,6 +146,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: ",")
Expand Down
Loading
Loading