Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
658f76e
feat: xpub persistence
jvsena42 Jun 22, 2026
e4be493
bump bitkit-core and adapt code
jvsena42 Jun 22, 2026
8e1d8ad
feat: adapt code to pesist activities via bitkit-core wallet-scoped s…
jvsena42 Jun 22, 2026
4f8a292
feat: balance + home device row
jvsena42 Jun 22, 2026
02f2dfc
feat: hardware suggestion and sheet
jvsena42 Jun 22, 2026
69d725c
feat: activity merge and sheet receive
jvsena42 Jun 22, 2026
befcb93
refactor: dependency decoupling
jvsena42 Jun 22, 2026
571dfed
test: test parity
jvsena42 Jun 22, 2026
fad790d
doc: changelog
jvsena42 Jun 22, 2026
5510a4d
fix: reimport device ilustrations
jvsena42 Jun 23, 2026
9540676
refactor: extract magic numbers and use better constant naming
jvsena42 Jun 23, 2026
097b08c
refactor: extract HW onChain methods to a vendor agnostic protocol
jvsena42 Jun 23, 2026
7c8fb79
refactor: make chunck code more readable
jvsena42 Jun 23, 2026
763d0a4
refactor: remove redundant comments
jvsena42 Jun 23, 2026
1b03abf
refactor: remove redundant comment
jvsena42 Jun 24, 2026
9a92952
chore: add preview
jvsena42 Jun 24, 2026
a6b48e7
fix: disable RBF, contact and tag features for HW activity detail
jvsena42 Jun 24, 2026
e336467
fix: set wallet scope for tag methods
jvsena42 Jun 24, 2026
d239df2
fix: set wallet id for refresh method in explore
jvsena42 Jun 24, 2026
384e62f
fix: set fallback timestamp for unconfirmed HW transactions
jvsena42 Jun 24, 2026
4f62696
fix: get tx direction from core
jvsena42 Jun 24, 2026
893a53e
fix: clean orphaned activities from deleted devices
jvsena42 Jun 24, 2026
ca78969
fix: watch idenpotency
jvsena42 Jun 24, 2026
79ae836
fix: dont cancel pairing when bracgrouding the device
jvsena42 Jun 24, 2026
914acaf
fix: clear dev dashboard results on wallet disconnect
jvsena42 Jun 24, 2026
c22f682
refactor: consolidate network conversion
jvsena42 Jun 24, 2026
b940159
test: regression tests
jvsena42 Jun 24, 2026
32d4915
refactor: implement core 0.3.2 wallet id derivation and adopt the red…
jvsena42 Jun 25, 2026
5729279
fix: trigger activitiesChangedSubject after persist and delet HW acti…
jvsena42 Jun 25, 2026
6dc2104
fix: guard reconnect under connectedDevice
jvsena42 Jun 25, 2026
a8c451e
fix: protect headlineSats from overflow
jvsena42 Jun 25, 2026
a26a474
refactor: remove unused method
jvsena42 Jun 25, 2026
a3a67c6
fix: stop only dahboard watcher on dashboard dismiss
jvsena42 Jun 25, 2026
8685359
fix: re-check watcher ids after the async watcher start
jvsena42 Jun 25, 2026
d2aa3f8
fix: reconcile hardware watchers when the Electrum server or monitore…
jvsena42 Jun 25, 2026
a039796
fix: set hasHardwareWallet for home widgets and widgets preview
jvsena42 Jun 25, 2026
bae69fd
fix: sort watches for a deterministic merge
jvsena42 Jun 25, 2026
39bbf3e
chore: add TODO for next iterations
jvsena42 Jun 25, 2026
5632064
chore: lint
jvsena42 Jun 25, 2026
6b76cc3
fix: narrow settings watcher trigger
jvsena42 Jun 25, 2026
6e0248f
fix: every watch event was performing a full activity reload and FFi …
jvsena42 Jun 25, 2026
a8fe2c8
refactor: consolidate HwAddressType
jvsena42 Jun 25, 2026
5777dbb
fix: update balance on watcher update, like adding/removing address type
jvsena42 Jun 25, 2026
798ba4c
refactor: consolidate saturation check
jvsena42 Jun 25, 2026
f0d63f8
fix: restart hardware watcher when a device's xpub changes
jvsena42 Jun 25, 2026
4d788bd
fix: only save device after successfully fetch all addressess
jvsena42 Jun 25, 2026
14566f6
fix: match zero balance sugggestion with android
jvsena42 Jun 25, 2026
e83dd03
fix: remove old remove device check with possibly stable data and reu…
jvsena42 Jun 26, 2026
a80db29
fix: set device as know after setting passphrase
jvsena42 Jun 26, 2026
eea9bb1
fix: auto-reconnect gated on isPinVerified || with an .onChange(of: i…
jvsena42 Jun 26, 2026
6ca4ce7
refactor: single source of true electrum url
jvsena42 Jun 26, 2026
136d6df
fix: unniform device name rules
jvsena42 Jun 26, 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
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1201,7 +1201,7 @@
repositoryURL = "https://github.com/synonymdev/bitkit-core";
requirement = {
kind = exactVersion;
version = 0.1.66;
version = 0.3.4;
};
};
96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 37 additions & 1 deletion Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ struct AppScene: View {
@StateObject private var pubkyProfile = PubkyProfileManager()
@StateObject private var contactsManager = ContactsManager()
@State private var keyboardManager = KeyboardManager()
@State private var trezorViewModel = TrezorViewModel()
@State private var trezorManager: TrezorManager
@State private var trezorViewModel: TrezorViewModel
@State private var hwWalletManager: HwWalletManager
@State private var calculatorInputManager = CalculatorInputManager()

@State private var hideSplash = false
Expand Down Expand Up @@ -82,6 +84,12 @@ struct AppScene: View {

_transferTracking = StateObject(wrappedValue: TransferTrackingManager(service: transferService))

let trezorManager = TrezorManager()
let trezorViewModel = TrezorViewModel(connection: trezorManager)
_trezorManager = State(initialValue: trezorManager)
_trezorViewModel = State(initialValue: trezorViewModel)
_hwWalletManager = State(initialValue: HwWalletManager())

CoreService.shared.activity.setPrivatePaykitContactResolvers(
invoice: { paymentHash in
await PrivatePaykitService.shared.contactPublicKey(forPrivateInvoicePaymentHash: paymentHash)
Expand All @@ -106,6 +114,13 @@ 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) }
// 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() }
Comment thread
jvsena42 marked this conversation as resolved.
.onChange(of: isPinVerified) { _, verified in
if verified { Task { await trezorManager.autoReconnect() } }
}
.onReceive(settings.settingsPublisher) { _ in hwWalletManager.reconcileForSettingsChange() }
.onChange(of: migrations.isShowingMigrationLoading) { _, isLoading in
if !isLoading {
SettingsViewModel.shared.updatePinEnabledState()
Expand Down Expand Up @@ -148,7 +163,9 @@ struct AppScene: View {
.environmentObject(pubkyProfile)
.environmentObject(contactsManager)
.environment(keyboardManager)
.environment(trezorManager)
.environment(trezorViewModel)
.environment(hwWalletManager)
.environment(calculatorInputManager)
.onChange(of: pubkyProfile.authState, initial: true) { _, authState in
if authState == .authenticated, let pk = pubkyProfile.publicKey {
Expand Down Expand Up @@ -470,6 +487,12 @@ struct AppScene: View {
await checkAndPerformRNMigration()
try wallet.setWalletExistsState()

// Load any paired hardware devices from storage and feed the watch-only manager so its
// 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()
pushHardwareDevices()

// Setup TimedSheetManager with all timed sheets
TimedSheetManager.shared.setup(
sheetViewModel: sheets,
Expand Down Expand Up @@ -601,6 +624,10 @@ struct AppScene: View {
}

if newPhase == .active {
// Reconnect a known hardware device so its connection indicator turns green again;
if isPinVerified || !settings.pinEnabled {
Task { await trezorManager.autoReconnect() }
}
if wallet.walletExists == true {
Task {
await clearDeliveredNotifications()
Expand Down Expand Up @@ -634,6 +661,15 @@ 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.
private func pushHardwareDevices() {
hwWalletManager.updateDevices(
knownDevices: trezorManager.knownDevices,
connectedDeviceId: trezorManager.connectedDevice?.id
)
}

private func handleNetworkChange(_ isConnected: Bool) {
Logger.info("Network changed: \(isConnected ? "connected" : "disconnected")", context: "AppScene")

Expand Down
22 changes: 22 additions & 0 deletions Bitkit/Assets.xcassets/Illustrations/ledger.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "ledger.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ledger@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions Bitkit/Assets.xcassets/Illustrations/trezor.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "trezor.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "trezor@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "bluetooth-connected.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "btc-circle-blue.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.
105 changes: 105 additions & 0 deletions Bitkit/Components/HardwareWalletsGrid.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import SwiftUI

/// Two-column grid of paired hardware wallets shown on Home, under the Savings/Spending tiles.
/// Mirrors bitkit-android's `HwDevices` (rows chunked in pairs, divided like the on-chain tiles).
struct HardwareWalletsGrid: View {
let wallets: [HwWallet]
let onTap: (HwWallet) -> Void

private var rows: [[HwWallet]] {
chunked(wallets, into: 2)
}

private func chunked(_ wallets: [HwWallet], into size: Int) -> [[HwWallet]] {
stride(from: 0, to: wallets.count, by: size).map { start in
let end = min(start + size, wallets.count)
return Array(wallets[start ..< end])
}
}

var body: some View {
VStack(spacing: 16) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
HStack(spacing: 16) {
HardwareWalletCell(wallet: row[0], onTap: onTap)

CustomDivider(color: .gray4, type: .vertical)

if row.count > 1 {
HardwareWalletCell(wallet: row[1], onTap: onTap)
} else {
Color.clear.frame(maxWidth: .infinity)
}
}
.frame(height: 50)
}
}
}
}

private struct HardwareWalletCell: View {
let wallet: HwWallet
let onTap: (HwWallet) -> Void

var body: some View {
Button {
onTap(wallet)
} label: {
VStack(alignment: .leading) {
CaptionMText(wallet.name)
.lineLimit(1)
.padding(.bottom, 4)

HStack(spacing: 4) {
Image("btc-circle-blue")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
.padding(.trailing, 4)

MoneyText(
sats: Int(clamping: wallet.balanceSats),
size: .subtitle,
enableHide: true,
symbolColor: .textPrimary
)

HwWalletConnectionIcon(isConnected: wallet.isConnected)
.frame(width: 16, height: 16)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.accessibilityIdentifier("ActivityHardware")
}
}

#Preview {
func wallet(_ id: String, _ name: String, connected: Bool, sats: UInt64) -> HwWallet {
HwWallet(id: id, walletId: "trezor:\(id)", name: name, model: name, isConnected: connected, balanceSats: sats)
}

return VStack(spacing: 32) {
// Single device
HardwareWalletsGrid(
wallets: [wallet("1", "Trezor Safe 5", connected: true, sats: 1_234_567)],
onTap: { _ in }
)

CustomDivider()

// Two devices (full 2-column row), one connected, one not
HardwareWalletsGrid(
wallets: [
wallet("1", "Trezor Safe 5", connected: true, sats: 1_234_567),
wallet("2", "Trezor Model T", connected: false, sats: 89000),
],
onTap: { _ in }
)
}
.padding()
.environmentObject(CurrencyViewModel())
.environmentObject(SettingsViewModel.shared)
.preferredColorScheme(.dark)
}
51 changes: 51 additions & 0 deletions Bitkit/Components/Trezor/HwDeviceIllustrations.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import SwiftUI

/// Staggered hardware-device hero used by the hardware intro sheet: a Trezor on the left and a
/// blurred Ledger bleeding off the right. Ports bitkit-android's `HwDeviceIllustrations`.
struct HwDeviceIllustrations: View {
/// All measurements are expressed as fractions of the Figma design frame's width, so the hero
/// scales proportionally to whatever width it's given. Each device is rendered at its natural
/// (non-square) aspect ratio; the Trezor's left bleed is baked into the exported asset, while
/// the Ledger is offset to bleed off the right edge.
private enum Layout {
static let referenceWidth: CGFloat = 375

static let proportionalHeight: CGFloat = 256 / referenceWidth
static let trezorProportionalWidth: CGFloat = 172 / referenceWidth
static let ledgerProportionalWidth: CGFloat = 203 / referenceWidth
static let ledgerProportionalX: CGFloat = 172 / referenceWidth
static let proportionalStagger: CGFloat = 11.6 / referenceWidth
}

var body: some View {
GeometryReader { geo in
let width = geo.size.width
let imageHeight = width * Layout.proportionalHeight
let staggerY = width * Layout.proportionalStagger

ZStack {
Image("trezor")
.resizable()
.scaledToFit()
.frame(width: width * Layout.trezorProportionalWidth, height: imageHeight)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.offset(y: staggerY)

Image("ledger")
.resizable()
.scaledToFit()
.frame(width: width * Layout.ledgerProportionalWidth, height: imageHeight)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.offset(x: width * Layout.ledgerProportionalX, y: -staggerY)
}
}
.accessibilityHidden(true)
}
}

#Preview {
HwDeviceIllustrations()
.frame(height: 300)
.background(Color.black)
.preferredColorScheme(.dark)
}
30 changes: 30 additions & 0 deletions Bitkit/Components/Trezor/HwWalletConnectionIcon.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import SwiftUI

/// Bluetooth connection indicator for a paired hardware wallet. iOS supports Bluetooth only,
/// so there is a single transport glyph — tinted green when connected, gray when disconnected.
/// Mirrors bitkit-android's `HwWalletConnectionIcon` (BLE branch).
struct HwWalletConnectionIcon: View {
let isConnected: Bool

var body: some View {
Image("bluetooth-connected")
.renderingMode(.template)
.resizable()
.scaledToFit()
.foregroundColor(isConnected ? .greenAccent : .gray1)
.accessibilityLabel(
isConnected
? t("hardware__connection_badge_connected_bluetooth")
: t("hardware__connection_badge_disconnected_bluetooth")
)
}
}

#Preview {
HStack(spacing: 24) {
HwWalletConnectionIcon(isConnected: true).frame(width: 16, height: 16)
HwWalletConnectionIcon(isConnected: false).frame(width: 16, height: 16)
}
.padding()
.preferredColorScheme(.dark)
}
Loading
Loading