Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0e36d50
chore: bump bitkit-core to 0.5.17
coreyphillips Sep 18, 2026
9809107
refactor: give the paired device store vendor neutral names
coreyphillips Sep 18, 2026
e37021c
feat: scope paired hardware devices by vendor
coreyphillips Sep 18, 2026
ef67ea4
feat: classify jade errors next to trezor ones
coreyphillips Sep 18, 2026
0841cda
feat: route hardware wallet sessions by vendor
coreyphillips Sep 18, 2026
79533a2
feat: add the jade bluetooth transport
coreyphillips Sep 18, 2026
7c14012
feat: add the jade session manager
coreyphillips Sep 18, 2026
4704827
feat: wire jade into the app lifecycle
coreyphillips Sep 18, 2026
771f465
feat: pair a jade from connect hardware
coreyphillips Sep 18, 2026
f3928e1
feat: sign with a jade from send and transfer
coreyphillips Sep 18, 2026
57beeea
feat: label the hardware receive tab by vendor
coreyphillips Sep 18, 2026
56abc40
docs: note jade coverage in hardware wallet journeys
coreyphillips Sep 18, 2026
527ace7
chore: add changelog fragment
coreyphillips Sep 18, 2026
bc6edcc
fix: stop cancelled jade connects and release stalled links
coreyphillips Sep 18, 2026
01b3be8
fix: keep trezor reconnects under the session lock and reset them on …
coreyphillips Sep 18, 2026
0ac9aa2
Merge branch 'master' into feat/jade-hardware-wallet
coreyphillips Sep 22, 2026
f24bccf
Merge branch 'master' into feat/jade-hardware-wallet
coreyphillips Sep 22, 2026
b3879ac
fix: let the transfer sign screen be left while the jade waits for it…
coreyphillips Sep 24, 2026
5c3c942
fix: refuse a jade reconnect that finds an unpaired wallet
coreyphillips Sep 24, 2026
ab3d949
Merge remote-tracking branch 'origin/master' into feat/jade-hardware-…
coreyphillips Sep 24, 2026
fed7027
fix: restore the jade bluetooth usage description in the build settings
coreyphillips Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -945,7 +945,7 @@
INFOPLIST_FILE = Bitkit/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Bitkit;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth access is included as part of a library used for wallet operations. Bitkit does not actively use Bluetooth.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bitkit uses Bluetooth to connect to your hardware wallet for signing transactions.";
INFOPLIST_KEY_NSCameraUsageDescription = "Bitkit needs access to the camera to scan QR codes";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
Expand Down Expand Up @@ -994,7 +994,7 @@
INFOPLIST_FILE = Bitkit/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Bitkit;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth access is included as part of a library used for wallet operations. Bitkit does not actively use Bluetooth.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bitkit uses Bluetooth to connect to your hardware wallet for signing transactions.";
INFOPLIST_KEY_NSCameraUsageDescription = "Bitkit needs access to the camera to scan QR codes";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
Expand Down
39 changes: 29 additions & 10 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ struct AppScene: View {
@State private var keyboardManager = KeyboardManager()
@State private var trezorManager: TrezorManager
@State private var trezorViewModel: TrezorViewModel
@State private var jadeManager: JadeManager
@State private var hwWalletManager: HwWalletManager
@State private var calculatorInputManager = CalculatorInputManager()
@State private var paykitPaymentRequestManager = PaykitPaymentRequestManager()
Expand Down Expand Up @@ -257,7 +258,8 @@ struct AppScene: View {
// Created ahead of `transfer` so the hardware-wallet transfer flow can reach the funding
// (compose/sign/broadcast) and device-session (reconnect) capabilities.
let trezorManager = TrezorManager()
let hwWalletManager = HwWalletManager(session: trezorManager)
let jadeManager = JadeManager()
let hwWalletManager = HwWalletManager(trezorSession: trezorManager, jadeSession: jadeManager)

_transfer = StateObject(wrappedValue: TransferViewModel(
transferService: transferService,
Expand All @@ -281,6 +283,8 @@ struct AppScene: View {
let trezorViewModel = TrezorViewModel(connection: trezorManager)
_trezorManager = State(initialValue: trezorManager)
_trezorViewModel = State(initialValue: trezorViewModel)
// Held here because `HwWalletManager` keeps its vendor sessions weakly.
_jadeManager = State(initialValue: jadeManager)
_hwWalletManager = State(initialValue: hwWalletManager)

CoreService.shared.activity.setPrivatePaykitContactResolvers(
Expand Down Expand Up @@ -324,12 +328,13 @@ struct AppScene: View {
.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.
// Bridge the vendor managers' device state into the watch-only manager without coupling them:
// each bumps devicesRevision on any device or connection change.
.onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() }
.onChange(of: jadeManager.devicesRevision) { _, _ in pushHardwareDevices() }
.onChange(of: isPinVerified) { _, verified in
if verified {
Task { await trezorManager.autoReconnect() }
Task { await hwWalletManager.reconnectOnForeground() }
}
}
.onReceive(settings.settingsPublisher) { _ in hwWalletManager.reconcileForSettingsChange() }
Expand Down Expand Up @@ -377,6 +382,7 @@ struct AppScene: View {
.environment(keyboardManager)
.environment(trezorManager)
.environment(trezorViewModel)
.environment(jadeManager)
.environment(hwWalletManager)
.environment(calculatorInputManager)
.environment(paykitPaymentRequestManager)
Expand Down Expand Up @@ -787,6 +793,7 @@ struct AppScene: View {

@Sendable
private func setupTask() async {
AppReset.hardwareWallets = hwWalletManager
do {
// Handle orphaned keychain before anything else
handleOrphanedKeychain()
Expand All @@ -798,6 +805,7 @@ struct AppScene: View {
// watchers start at launch (no-op until a device is paired). loadKnownDevices() also
// bumps devicesRevision, but push explicitly so the initial state is delivered.
trezorManager.loadKnownDevices()
jadeManager.loadKnownDevices()
pushHardwareDevices()

// Setup TimedSheetManager with all timed sheets
Expand Down Expand Up @@ -936,12 +944,16 @@ struct AppScene: View {
// If PIN is enabled, lock the app when the app goes to the background
isPinVerified = false
}
hwWalletManager.onAppBackgrounded()
}

// `.inactive` is left alone: the iOS Bluetooth pairing alert puts the app there mid-connect.
if newPhase == .active {
// Called even behind the PIN screen, so a background release still pending is called off.
hwWalletManager.onAppBecameActive()
// Reconnect a known hardware device so its connection indicator turns green again;
if isPinVerified || !settings.pinEnabled {
Task { await trezorManager.autoReconnect() }
Task { await hwWalletManager.reconnectOnForeground() }
}
if wallet.walletExists == true {
Task {
Expand Down Expand Up @@ -1346,13 +1358,20 @@ struct AppScene: View {
center.removeDeliveredNotifications(withIdentifiers: deliveredNotifications.map(\.request.identifier))
}

/// Feed the current Trezor device snapshot into the watch-only manager. This is the only link
/// between the two managers, kept in the composition root so neither type references the other.
/// Feed both vendors' device snapshots into the watch-only manager. This is the only link between
/// the vendor managers and it, kept in the composition root so none of them references another.
private func pushHardwareDevices() {
let connected: (deviceId: String, walletId: String?)? = if let trezorDevice = trezorManager.connectedDevice {
(trezorDevice.id, trezorManager.connectedWalletId)
} else if let jadeDevice = jadeManager.connected {
(jadeDevice.id, jadeDevice.walletId)
} else {
nil
}
hwWalletManager.updateDevices(
knownDevices: trezorManager.knownDevices,
connectedDeviceId: trezorManager.connectedDevice?.id,
connectedWalletId: trezorManager.connectedWalletId
knownDevices: (trezorManager.knownDevices + jadeManager.knownDevices).sorted { $0.lastConnectedAt > $1.lastConnectedAt },
connectedDeviceId: connected?.deviceId,
connectedWalletId: connected?.walletId
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "jade-placeholder.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion Bitkit/BitkitApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ class AppDelegate: NSObject, UIApplicationDelegate {

func applicationWillTerminate(_ application: UIApplication) {
try? StateLocker.unlock(.lightning)
// A Jade left holding a link when the process ends can drop its Bluetooth bond.
JadeTransport.shared.releaseAllImmediately()
}
}

Expand Down Expand Up @@ -178,7 +180,7 @@ struct BitkitApp: App {
init() {
UIWindow.appearance().overrideUserInterfaceStyle = .dark
if Env.shouldResetTrezorEmulatorState {
TrezorKnownDeviceStorage.removeAll()
HwKnownDeviceStorage.removeAll()
TrezorCredentialStorage.deleteAll()
}
_ = ToastWindowManager.shared
Expand Down
17 changes: 14 additions & 3 deletions Bitkit/Components/SegmentedControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,27 @@ import SwiftUI

struct TabItem<T: Hashable & CustomStringConvertible> {
let tab: T
/// Shown in place of the tab's own description, for a tab whose name depends on what it shows.
let label: String?
let activeColor: Color?
let badge: Int?
let accessibilityIdentifier: String?

init(_ tab: T, activeColor: Color? = nil, badge: Int? = nil, accessibilityIdentifier: String? = nil) {
init(_ tab: T, label: String? = nil, activeColor: Color? = nil, badge: Int? = nil, accessibilityIdentifier: String? = nil) {
self.tab = tab
self.label = label
self.activeColor = activeColor
self.badge = badge
self.accessibilityIdentifier = accessibilityIdentifier
}

var title: String {
label ?? tab.description
}

var resolvedAccessibilityIdentifier: String {
accessibilityIdentifier ?? "Tab-\(title.lowercased())"
}
}

struct SegmentedControl<T: Hashable & CustomStringConvertible>: View {
Expand Down Expand Up @@ -51,7 +62,7 @@ struct SegmentedControl<T: Hashable & CustomStringConvertible>: View {
badge(for: tabItem)
.hidden()
CaptionBText(
tabItem.tab.description,
tabItem.title,
textColor: selectedTab == tabItem.tab ? .white : inactiveColor ?? .secondary
)
badge(for: tabItem)
Expand All @@ -75,7 +86,7 @@ struct SegmentedControl<T: Hashable & CustomStringConvertible>: View {
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
.accessibilityIdentifier(tabItem.accessibilityIdentifier ?? "Tab-\(tabItem.tab.description.lowercased())")
.accessibilityIdentifier(tabItem.resolvedAccessibilityIdentifier)
}
}
.frame(maxWidth: .infinity)
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Components/TabBar/TabBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ struct TabBar: View {
sheets.showSheet(
.receive,
data: ReceiveConfig(
view: .qr(cjitInvoice: nil, tab: .trezor),
view: .qr(cjitInvoice: nil, tab: .hardware),
hardwareWalletId: walletId
)
)
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Components/Trezor/TrezorDeviceRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ struct TrezorDeviceRow: View {

/// Row displaying a previously connected (known) Trezor device
struct KnownDeviceRow: View {
let device: TrezorKnownDevice
let device: HwKnownDevice
let isConnecting: Bool
let onConnect: () -> Void
let onForget: () -> Void
Expand Down
31 changes: 31 additions & 0 deletions Bitkit/Extensions/HwError+Cancellation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/// Vendor-neutral views over the Trezor and Jade error predicates, for code shared by every vendor.
/// A `TrezorError` is never a `JadeError`, so for a Trezor error each one answers as its `isTrezor*`
/// counterpart does.
extension Error {
func isHwUserCancellation() -> Bool {
isTrezorUserCancellation() || isJadeUserCancellation()
}

func isHwDeviceBusy() -> Bool {
isTrezorDeviceBusy() || isJadeDeviceBusy()
}

func isHwFirmwareError() -> Bool {
isTrezorFirmwareError() || isJadeFirmwareError()
}

func isHwSessionFailure() -> Bool {
isTrezorSessionFailure() || isJadeSessionFailure()
}

/// The vendor of the busy or locked device this error reports, so its busy copy can name it.
var hwBusyVendor: HwWalletVendor? {
if isJadeDeviceBusy() {
return .blockstream
}
if isTrezorDeviceBusy() {
return .trezor
}
return nil
}
}
63 changes: 63 additions & 0 deletions Bitkit/Extensions/HwWalletVendor+UI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

/// How each vendor's devices are shown and named across the hardware wallet screens.
extension HwWalletVendor {
/// The upright device illustration; the Jade one is a placeholder until design supplies the asset.
var deviceImageName: String {
switch self {
case .trezor: "trezor-device"
case .blockstream: "jade-placeholder"
}
}

/// The illustration shown while the device signs a transaction.
var signImageName: String {
switch self {
case .trezor: "trezor-card"
case .blockstream: "jade-placeholder"
}
}

var modelName: String {
switch self {
case .trezor: t("hardware__device_model_trezor")
case .blockstream: t("hardware__device_model_jade")
}
}

var foundHeader: String {
switch self {
case .trezor: t("hardware__found_header")
case .blockstream: t("hardware__found_header_jade")
}
}

var pairedHeader: String {
switch self {
case .trezor: t("hardware__paired_header")
case .blockstream: t("hardware__paired_header_jade")
}
}

var sendSignButtonTitle: String {
switch self {
case .trezor: t("hardware__send_open_connect")
case .blockstream: t("hardware__send_open_connect_jade")
}
}

var transferSignButtonTitle: String {
switch self {
case .trezor: t("lightning__transfer_hw__open_connect")
case .blockstream: t("hardware__send_open_connect_jade")
}
}

/// Passphrase (hidden) wallets are a Trezor feature; a Jade holds one wallet per device.
var supportsPassphraseWallets: Bool {
switch self {
case .trezor: true
case .blockstream: false
}
}
}
50 changes: 50 additions & 0 deletions Bitkit/Extensions/JadeError+Cancellation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import BitkitCore

extension Error {
/// The `JadeError` this error is or wraps. `ServiceQueue` boxes core errors into an `AppError`
/// before they reach the caller, so the preserved underlying error is unwrapped as well.
var underlyingJadeError: JadeError? {
if let jadeError = self as? JadeError {
return jadeError
}
if let appError = self as? AppError, let underlyingError = appError.underlyingError {
return underlyingError.underlyingJadeError
}
return nil
}

/// Whether the user declined the request on the Jade. Callers treat it as a silent no-op so the
/// user can retry on the same screen.
func isJadeUserCancellation() -> Bool {
guard case .UserCancelled? = underlyingJadeError else { return false }
return true
}

/// Whether the Jade cannot serve the request until the user acts on it: busy with another prompt,
/// or locked.
func isJadeDeviceBusy() -> Bool {
guard let jadeError = underlyingJadeError else { return false }
switch jadeError {
case .DeviceBusy, .DeviceLocked:
return true
default:
return false
}
}

func isJadeFirmwareError() -> Bool {
guard case .UnsupportedFirmware? = underlyingJadeError else { return false }
return true
}

/// Whether the current Jade channel can no longer be used and must be re-established.
func isJadeSessionFailure() -> Bool {
guard let jadeError = underlyingJadeError else { return false }
switch jadeError {
case .TransportError, .DeviceDisconnected, .ConnectionError, .Timeout, .NotConnected, .NotInitialized, .IoError:
return true
default:
return false
}
}
}
Loading
Loading