Compare commits

..

2 Commits

Author SHA1 Message Date
Nick Klockenga
435197827a
Additional fixes related to screenshot UI testing 2026-04-17 21:03:16 -04:00
Nick Klockenga
1a09c8c8fd
add new screenshots and fix a few items 2026-04-16 23:40:07 -04:00
131 changed files with 380 additions and 1021 deletions

View File

@ -22,20 +22,20 @@ jobs:
xcodebuild -version
- name: Resolve packages
run: xcodebuild -resolvePackageDependencies -project birch.xcodeproj -scheme birch
run: xcodebuild -resolvePackageDependencies -project hellbender.xcodeproj -scheme hellbender
- name: Validate Package.resolved
run: |
if ! git diff --quiet birch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved; then
if ! git diff --quiet hellbender.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved; then
echo "::error::Package.resolved has uncommitted changes after resolution. Commit the updated Package.resolved."
git diff birch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
git diff hellbender.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
exit 1
fi
- name: Build
env:
scheme: birch
file_to_build: birch.xcodeproj
scheme: hellbender
file_to_build: hellbender.xcodeproj
filetype_parameter: project
run: |
xcodebuild clean build analyze -scheme "$scheme" -"$filetype_parameter" "$file_to_build" CODE_SIGNING_ALLOWED=NO | xcpretty && exit ${PIPESTATUS[0]}

View File

@ -17,4 +17,4 @@ jobs:
- name: Run Unit Tests
run: |
xcodebuild test -project birch.xcodeproj -scheme birch -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:birchTests -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO | xcpretty && exit ${PIPESTATUS[0]}
xcodebuild test -project hellbender.xcodeproj -scheme hellbender -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:hellbenderTests -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO | xcpretty && exit ${PIPESTATUS[0]}

View File

@ -23,12 +23,12 @@ jobs:
- name: Build 1
run: |
DERIVED_DATA="/tmp/birch-build-1"
DERIVED_DATA="/tmp/hellbender-build-1"
rm -rf "$DERIVED_DATA"
xcodebuild archive \
-scheme birch \
-project birch.xcodeproj \
-archivePath "$DERIVED_DATA/birch.xcarchive" \
-scheme hellbender \
-project hellbender.xcodeproj \
-archivePath "$DERIVED_DATA/hellbender.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-configuration Release \
CODE_SIGNING_ALLOWED=NO \
@ -37,12 +37,12 @@ jobs:
- name: Build 2
run: |
DERIVED_DATA="/tmp/birch-build-2"
DERIVED_DATA="/tmp/hellbender-build-2"
rm -rf "$DERIVED_DATA"
xcodebuild archive \
-scheme birch \
-project birch.xcodeproj \
-archivePath "$DERIVED_DATA/birch.xcarchive" \
-scheme hellbender \
-project hellbender.xcodeproj \
-archivePath "$DERIVED_DATA/hellbender.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-configuration Release \
CODE_SIGNING_ALLOWED=NO \
@ -51,13 +51,13 @@ jobs:
- name: Normalize both builds
run: |
APP1="/tmp/birch-build-1/birch.xcarchive/Products/Applications/birch.app"
APP2="/tmp/birch-build-2/birch.xcarchive/Products/Applications/birch.app"
APP1="/tmp/hellbender-build-1/hellbender.xcarchive/Products/Applications/hellbender.app"
APP2="/tmp/hellbender-build-2/hellbender.xcarchive/Products/Applications/hellbender.app"
./scripts/normalize-app.sh "$APP1"
./scripts/normalize-app.sh "$APP2"
- name: Compare builds
run: |
APP1="/tmp/birch-build-1/birch.xcarchive/Products/Applications/birch.app"
APP2="/tmp/birch-build-2/birch.xcarchive/Products/Applications/birch.app"
APP1="/tmp/hellbender-build-1/hellbender.xcarchive/Products/Applications/hellbender.app"
APP2="/tmp/hellbender-build-2/hellbender.xcarchive/Products/Applications/hellbender.app"
./scripts/compare-builds.sh "$APP1" "$APP2"

View File

@ -1,14 +1,12 @@
// Birch.xcconfig — Target-level settings for the birch app target
// Hellbender.xcconfig — Target-level settings for the hellbender app target
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIcon-Dark
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor
CODE_SIGN_STYLE = Automatic
CURRENT_PROJECT_VERSION = 23
DEVELOPMENT_ASSET_PATHS = "birch/Preview Content"
DEVELOPMENT_ASSET_PATHS = "hellbender/Preview Content"
GENERATE_INFOPLIST_FILE = NO
INFOPLIST_FILE = birch/Info.plist
INFOPLIST_FILE = hellbender/Info.plist
IPHONEOS_DEPLOYMENT_TARGET = 18.6
LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks
MARKETING_VERSION = 0.1.2

View File

@ -1,29 +1,29 @@
<p align="center">
<img src="https://birchwallet.app/assets/AppIcon-og.png" alt="Birch" width="128" height="128" style="border-radius: 24px;" />
<img src="https://hellbenderwallet.com/assets/AppIcon-og.png" alt="Hellbender" width="128" height="128" style="border-radius: 24px;" />
</p>
<h1 align="center">Birch</h1>
<h1 align="center">Hellbender</h1>
<p align="center">
<em>Travel to your private keys and leave your laptop at home.</em>
</p>
<p align="center">
<img src="https://birchwallet.app/assets/screenshots/welcome.png" alt="Welcome" width="150" />
<img src="https://birchwallet.app/assets/screenshots/wallet-setup.png" alt="Setup Choice" width="150" />
<img src="https://birchwallet.app/assets/screenshots/multisig-config.png" alt="New Wallet Multisig Config" width="150" />
<img src="https://birchwallet.app/assets/screenshots/cosigner-import.png" alt="Cosigner Import" width="150" />
<img src="https://birchwallet.app/assets/screenshots/verify-wallet-top.png" alt="Verify Wallet" width="150" />
<img src="https://birchwallet.app/assets/screenshots/verify-wallet-backup.png" alt="Backup PDF/QR" width="150" />
<img src="https://birchwallet.app/assets/screenshots/transactions.png" alt="Transactions" width="150" />
<img src="https://birchwallet.app/assets/screenshots/send.png" alt="Send" width="150" />
<img src="https://birchwallet.app/assets/screenshots/receive.png" alt="Receive" width="150" />
<img src="https://birchwallet.app/assets/screenshots/utxos.png" alt="UTXO" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/welcome.png" alt="Welcome" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/wallet-setup.png" alt="Setup Choice" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/multisig-config.png" alt="New Wallet Multisig Config" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/cosigner-import.png" alt="Cosigner Import" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/verify-wallet-top.png" alt="Verify Wallet" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/verify-wallet-backup.png" alt="Backup PDF/QR" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/transactions.png" alt="Transactions" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/send.png" alt="Send" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/receive.png" alt="Receive" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/utxos.png" alt="UTXO" width="150" />
</p>
---
Birch is an iOS Bitcoin multisig coordinator written in Swift. It operates as a **watch-only wallet** — private keys never touch your phone. Coordinate signing across air-gapped hardware wallets using animated QR codes, bringing cold storage security with mobile convenience.
Hellbender is an iOS Bitcoin multisig coordinator written in Swift. It operates as a **watch-only wallet** — private keys never touch your phone. Coordinate signing across air-gapped hardware wallets using animated QR codes, bringing cold storage security with mobile convenience.
## Features
@ -65,7 +65,7 @@ All dependencies are managed via Swift Package Manager and resolve automatically
git clone https://github.com/newtonick/hellbender-wallet.git
cd hellbender-wallet
```
2. Open `birch.xcodeproj` in Xcode
2. Open `hellbender.xcodeproj` in Xcode
3. SPM dependencies resolve automatically on first open
4. Build and run on a simulator or device
@ -77,7 +77,7 @@ GitHub Actions runs `xcodebuild clean build analyze` on every push and pull requ
### Reproducible Builds
Birch supports **functionally equivalent** reproducible builds. Given the same source code and Xcode version, two independent builds will produce the same compiled logic after normalization. Certain metadata bytes (Mach-O UUIDs, timestamps, build-machine identifiers) are expected to differ and are zeroed by the normalization step.
Hellbender supports **functionally equivalent** reproducible builds. Given the same source code and Xcode version, two independent builds will produce the same compiled logic after normalization. Certain metadata bytes (Mach-O UUIDs, timestamps, build-machine identifiers) are expected to differ and are zeroed by the normalization step.
**What IS reproducible** (after normalization): all code-bearing sections, resources, and application logic.
@ -94,7 +94,7 @@ Birch supports **functionally equivalent** reproducible builds. Given the same s
./scripts/build-release.sh
```
This creates an unsigned archive at `/tmp/birch-build/birch.xcarchive`.
This creates an unsigned archive at `/tmp/hellbender-build/hellbender.xcarchive`.
#### Verifying two builds
@ -111,7 +111,7 @@ The comparison exits 0 if the builds are functionally equivalent, 1 if code diff
## Generating Screenshots
Birch uses [`fastlane snapshot`](https://docs.fastlane.tools/actions/snapshot/) to generate marketing and App Store screenshots. A single UI test walks the app from Welcome through the main tabs, capturing every major screen on each configured device in both dark and light mode.
Hellbender uses [`fastlane snapshot`](https://docs.fastlane.tools/actions/snapshot/) to generate marketing and App Store screenshots. A single UI test walks the app from Welcome through the main tabs, capturing every major screen on each configured device in both dark and light mode.
### One-time setup
@ -188,7 +188,7 @@ fastlane/screenshots/
### How it works
- [`birchUITests/ScreenshotTests.swift`](birchUITests/ScreenshotTests.swift) is a dedicated XCUITest that walks the app. It reuses the existing `-UITesting` launch argument (defined in `birch/birchApp.swift`), which wipes `UserDefaults`/keychain and uses an in-memory SwiftData store so every run starts from a deterministic Welcome screen.
- [`hellbenderUITests/ScreenshotTests.swift`](hellbenderUITests/ScreenshotTests.swift) is a dedicated XCUITest that walks the app. It reuses the existing `-UITesting` launch argument (defined in `hellbender/hellbenderApp.swift`), which wipes `UserDefaults`/keychain and uses an in-memory SwiftData store so every run starts from a deterministic Welcome screen.
- The test imports a real testnet4 1-of-2 `wsh(sortedmulti(...))` descriptor with live history, waits for Electrum sync, then visits each screen.
- Dark/light mode is driven by the simulator's OS appearance (`xcrun simctl ui ... appearance`). The app's `RootView` follows the OS when the theme is set to `.system`, which it is by default after the `-UITesting` wipe, so no app-side toggle is required.
- The device matrix, scheme, status bar override, and other `snapshot` options live in [`fastlane/Snapfile`](fastlane/Snapfile). Device destinations (simulator OS version), the frameit pass, and the custom 13 mini ImageMagick composite all live in [`fastlane/Fastfile`](fastlane/Fastfile).
@ -196,14 +196,14 @@ fastlane/screenshots/
### Customizing
- **Add/remove devices:** edit both the `devices([...])` array in `fastlane/Snapfile` and the `DEVICES` hash in `fastlane/Fastfile`.
- **Change which screens are captured:** edit `testScreenshotTour` in `birchUITests/ScreenshotTests.swift` and add or remove `snapshot("NN-Name")` calls.
- **Change which screens are captured:** edit `testScreenshotTour` in `hellbenderUITests/ScreenshotTests.swift` and add or remove `snapshot("NN-Name")` calls.
- **Skip framing:** remove the `frameit(...)` lines and the ImageMagick composite block (steps 47) from `fastlane/Fastfile` if you only need the bare PNGs.
> **Known workaround** (contained in `fastlane/Fastfile`): `frameit` gem 2.232.2's bundled iPhone 13 Mini frame PNG has a ~3-pixel placement-offset bug that leaves a visible edge gap, so 13 mini is composited directly with ImageMagick instead. iPhone 16/17 device support is patched in via `scripts/patch-frameit.rb` (see setup step 4 above).
## Links
- **Website**: [birchwallet.app](https://birchwallet.app)
- **Website**: [hellbenderwallet.com](https://hellbenderwallet.com)
- **TestFlight Beta**: [Join the beta](https://testflight.apple.com/join/PuHVwJDJ)
- **Author**: [newtonick](https://github.com/newtonick/hellbender-wallet/)
@ -211,5 +211,5 @@ fastlane/screenshots/
MIT License — see [LICENSE](LICENSE) for details.
Birch's dependencies use permissive licenses compatible with MIT:
Hellbender's dependencies use permissive licenses compatible with MIT:
bdk-swift (MIT/Apache-2.0), URKit (BSD-2-Clause-Patent), URUI (BSD-2-Clause-Patent), Bbqr (Apache-2.0).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -1,14 +0,0 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -1,14 +0,0 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,388 +0,0 @@
@testable import birch
import CryptoKit
import Foundation
import Testing
@Suite("AppLockViewModel")
@MainActor
struct AppLockViewModelTests {
init() {
MockKeychainHelper.reset()
}
private func makeVM() -> AppLockViewModel {
AppLockViewModel(keychain: MockKeychainHelper.self)
}
private func hashPIN(_ pin: String) -> Data {
Data(SHA256.hash(data: Data(pin.utf8)))
}
private func seedPIN(_ pin: String) {
MockKeychainHelper.save(hashPIN(pin), forKey: Constants.keychainPINHashKey)
MockKeychainHelper.save(Data("\(pin.count)".utf8), forKey: Constants.keychainPINLengthKey)
}
private func seedFailedAttempts(_ count: Int) {
MockKeychainHelper.save(Data("\(count)".utf8), forKey: Constants.keychainFailedAttemptsKey)
}
private func seedLockoutExpiry(_ date: Date) {
MockKeychainHelper.save(Data("\(date.timeIntervalSince1970)".utf8), forKey: Constants.keychainLockoutExpiryKey)
}
// MARK: - Initialization
@Test func initWithNoPIN_hasPINIsFalse() {
let vm = makeVM()
#expect(vm.hasPIN == false)
#expect(vm.storedPINLength == 6)
}
@Test func initWithExistingPIN_hasPINIsTrue() {
seedPIN("1234")
let vm = makeVM()
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 4)
}
@Test func initWithPersistedFailedAttempts_restoresCount() {
seedFailedAttempts(5)
let vm = makeVM()
#expect(vm.failedAttempts == 5)
}
@Test func initWithExpiredLockout_clearsLockout() {
seedLockoutExpiry(Date().addingTimeInterval(-100))
let vm = makeVM()
#expect(vm.lockoutExpiry == nil)
#expect(vm.isLockedOut == false)
}
@Test func initWithActiveLockout_restoresLockout() {
seedLockoutExpiry(Date().addingTimeInterval(300))
let vm = makeVM()
#expect(vm.lockoutExpiry != nil)
#expect(vm.isLockedOut == true)
}
// MARK: - PIN Management
@Test func setPIN_storesHashAndLength() {
let vm = makeVM()
vm.setPIN("1234")
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 4)
#expect(MockKeychainHelper.load(forKey: Constants.keychainPINHashKey) == hashPIN("1234"))
#expect(MockKeychainHelper.load(forKey: Constants.keychainPINLengthKey) == Data("4".utf8))
}
@Test func setPIN_resetsFailedAttempts() {
let vm = makeVM()
vm.setPIN("9999")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts == 2)
vm.setPIN("5678")
#expect(vm.failedAttempts == 0)
}
@Test func removePIN_clearsKeychainAndState() {
let vm = makeVM()
vm.setPIN("1234")
#expect(vm.hasPIN == true)
vm.removePIN()
#expect(vm.hasPIN == false)
#expect(vm.storedPINLength == 6)
#expect(MockKeychainHelper.load(forKey: Constants.keychainPINHashKey) == nil)
#expect(MockKeychainHelper.load(forKey: Constants.keychainPINLengthKey) == nil)
#expect(MockKeychainHelper.load(forKey: Constants.keychainFailedAttemptsKey) == nil)
#expect(MockKeychainHelper.load(forKey: Constants.keychainLockoutExpiryKey) == nil)
}
@Test func setPIN_removePIN_setPIN_togglesCorrectly() {
let vm = makeVM()
vm.setPIN("1234")
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 4)
vm.removePIN()
#expect(vm.hasPIN == false)
#expect(vm.storedPINLength == 6)
vm.setPIN("567890")
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 6)
}
// MARK: - PIN Verification
@Test func verifyPIN_correctPIN_returnsTrue() {
let vm = makeVM()
vm.setPIN("5678")
vm.needsPINEntry = true
let result = vm.verifyPIN("5678")
#expect(result == true)
#expect(vm.isLocked == false)
#expect(vm.needsPINEntry == false)
#expect(vm.failedAttempts == 0)
}
@Test func verifyPIN_wrongPIN_returnsFalse() {
let vm = makeVM()
vm.setPIN("5678")
let result = vm.verifyPIN("0000")
#expect(result == false)
#expect(vm.failedAttempts == 1)
}
@Test func verifyPIN_noStoredPIN_returnsFalse() {
let vm = makeVM()
let result = vm.verifyPIN("1234")
#expect(result == false)
}
@Test func verifyPIN_correctPIN_resetsFailedAttempts() {
let vm = makeVM()
vm.setPIN("5678")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts == 3)
let result = vm.verifyPIN("5678")
#expect(result == true)
#expect(vm.failedAttempts == 0)
#expect(vm.lockoutExpiry == nil)
}
@Test func verifyPIN_whileLockedOut_returnsFalse() {
let vm = makeVM()
vm.setPIN("5678")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000") // 4th attempt 60s lockout
#expect(vm.isLockedOut == true)
let result = vm.verifyPIN("5678")
#expect(result == false)
#expect(vm.pinError.contains("Try again"))
}
// MARK: - Lockout Progression
@Test func lockout_noLockoutFor1to3Failures() {
let vm = makeVM()
vm.setPIN("5678")
_ = vm.verifyPIN("0000")
#expect(vm.isLockedOut == false)
_ = vm.verifyPIN("0000")
#expect(vm.isLockedOut == false)
_ = vm.verifyPIN("0000")
#expect(vm.isLockedOut == false)
}
@Test func lockout_60sAfter4Failures() throws {
let vm = makeVM()
vm.setPIN("5678")
for _ in 1 ... 4 {
_ = vm.verifyPIN("0000")
}
#expect(vm.isLockedOut == true)
#expect(vm.failedAttempts == 4)
let expiry = try #require(vm.lockoutExpiry)
let delay = expiry.timeIntervalSinceNow
#expect(delay > 55 && delay <= 61)
}
@Test func lockout_10mAfter5Failures() throws {
seedPIN("5678")
seedFailedAttempts(4)
let vm = makeVM()
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts == 5)
let expiry = try #require(vm.lockoutExpiry)
let delay = expiry.timeIntervalSinceNow
#expect(delay > 595 && delay <= 601)
}
@Test func lockout_90mAfter6Failures() throws {
seedPIN("5678")
seedFailedAttempts(5)
let vm = makeVM()
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts == 6)
let expiry = try #require(vm.lockoutExpiry)
let delay = expiry.timeIntervalSinceNow
#expect(delay > 5395 && delay <= 5401)
}
@Test func lockout_24hAfter7Failures() throws {
seedPIN("5678")
seedFailedAttempts(6)
let vm = makeVM()
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts == 7)
let expiry = try #require(vm.lockoutExpiry)
let delay = expiry.timeIntervalSinceNow
#expect(delay > 86395 && delay <= 86401)
}
@Test func lockout_persistsSurvivesReInit() {
let vm = makeVM()
vm.setPIN("5678")
for _ in 1 ... 4 {
_ = vm.verifyPIN("0000")
}
#expect(vm.isLockedOut == true)
let vm2 = makeVM()
#expect(vm2.failedAttempts == 4)
#expect(vm2.isLockedOut == true)
}
@Test func failedAttempts10_reachesWipeThreshold() {
seedPIN("5678")
seedFailedAttempts(9)
let vm = makeVM()
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts >= 10)
#expect(vm.pinError == "Too many attempts")
}
// MARK: - Background / Foreground
@Test func handleBackground_calledTwice_noOverwrite() {
let vm = makeVM()
vm.isLocked = false
let earlyTime = Date().addingTimeInterval(-120)
vm.handleBackground(at: earlyTime)
vm.handleBackground(at: Date())
vm.handleForeground(timeout: 60)
#expect(vm.isLocked == true)
}
@Test func handleForeground_underTimeout_staysUnlocked() {
let vm = makeVM()
vm.isLocked = false
vm.handleBackground(at: Date())
vm.handleForeground(timeout: 60)
#expect(vm.isLocked == false)
}
@Test func handleForeground_overTimeout_reLocks() {
let vm = makeVM()
vm.isLocked = false
vm.handleBackground(at: Date().addingTimeInterval(-120))
vm.handleForeground(timeout: 60)
#expect(vm.isLocked == true)
}
@Test func handleForeground_rereadsPINState() {
let vm = makeVM()
#expect(vm.hasPIN == false)
seedPIN("1234")
vm.handleForeground(timeout: 60)
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 4)
}
@Test func handleForeground_rereadsPINLength_afterRemoval() {
seedPIN("1234")
let vm = makeVM()
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 4)
MockKeychainHelper.delete(forKey: Constants.keychainPINHashKey)
MockKeychainHelper.delete(forKey: Constants.keychainPINLengthKey)
vm.handleForeground(timeout: 60)
#expect(vm.hasPIN == false)
#expect(vm.storedPINLength == 6)
}
@Test func handleForeground_noPriorBackground_noRelock() {
let vm = makeVM()
vm.isLocked = false
vm.handleForeground(timeout: 60)
#expect(vm.isLocked == false)
}
// MARK: - Cross-Instance Sync
@Test func crossInstance_setPINOnOne_foregroundReadsOnOther() {
let vmA = makeVM()
let vmB = makeVM()
vmA.setPIN("1234")
#expect(vmA.hasPIN == true)
#expect(vmB.hasPIN == false)
vmB.handleForeground(timeout: 60)
#expect(vmB.hasPIN == true)
#expect(vmB.storedPINLength == 4)
}
@Test func crossInstance_removePINOnOne_foregroundReadsOnOther() {
seedPIN("5678")
let vmA = makeVM()
let vmB = makeVM()
#expect(vmA.hasPIN == true)
#expect(vmB.hasPIN == true)
vmA.removePIN()
#expect(vmA.hasPIN == false)
#expect(vmB.hasPIN == true)
vmB.handleForeground(timeout: 60)
#expect(vmB.hasPIN == false)
#expect(vmB.storedPINLength == 6)
}
@Test func crossInstance_setPIN_thenTimeout_showsCorrectPINLength() {
let vmSettings = makeVM()
let vmLock = makeVM()
vmLock.isLocked = false
vmSettings.setPIN("12345678")
#expect(vmLock.storedPINLength == 6)
vmLock.handleBackground(at: Date().addingTimeInterval(-120))
vmLock.handleForeground(timeout: 60)
#expect(vmLock.hasPIN == true)
#expect(vmLock.storedPINLength == 8)
#expect(vmLock.isLocked == true)
}
// MARK: - Lockout Text
@Test func lockoutRemainingText_noLockout_empty() {
let vm = makeVM()
#expect(vm.lockoutRemainingText == "")
}
@Test func lockoutRemainingText_showsSeconds() {
seedLockoutExpiry(Date().addingTimeInterval(30))
let vm = makeVM()
let text = vm.lockoutRemainingText
#expect(text.contains("30s") || text.contains("29s"))
}
@Test func lockoutRemainingText_showsMinutes() {
seedLockoutExpiry(Date().addingTimeInterval(300))
let vm = makeVM()
let text = vm.lockoutRemainingText
#expect(text.contains("5m") || text.contains("4m"))
}
@Test func lockoutRemainingText_showsHoursAndMinutes() {
seedLockoutExpiry(Date().addingTimeInterval(7260))
let vm = makeVM()
let text = vm.lockoutRemainingText
#expect(text.contains("2h"))
}
// MARK: - Face ID Retry State Reset
@Test func faceIDRetry_clearsState() {
let vm = makeVM()
vm.needsPINEntry = true
vm.pinInput = "12"
vm.pinError = "Incorrect PIN"
vm.needsPINEntry = false
vm.pinInput = ""
vm.pinError = ""
#expect(vm.needsPINEntry == false)
#expect(vm.pinInput == "")
#expect(vm.pinError == "")
}
}

View File

@ -1,28 +0,0 @@
@testable import birch
import Foundation
final class MockKeychainHelper: KeychainStoring {
nonisolated(unsafe) static var store: [String: Data] = [:]
static func reset() {
store.removeAll()
}
@discardableResult
static func save(_ data: Data, forKey key: String) -> Bool {
store[key] = data
return true
}
static func load(forKey key: String) -> Data? {
store[key]
}
static func delete(forKey key: String) {
store.removeValue(forKey: key)
}
static func deleteAll() {
store.removeAll()
}
}

View File

@ -27,11 +27,11 @@ platform :ios do
FileUtils.rm_rf(File.expand_path("screenshots/light", __dir__))
# ── 1. Build for testing once ────────────────────────────────────────
# Produces birch.app, birchUITests-Runner.app, and the
# Produces hellbender.app, hellbenderUITests-Runner.app, and the
# .xctestrun manifest that tells xcodebuild which apps to install.
sh("xcodebuild build-for-testing " \
"-scheme birch " \
"-project ../birch.xcodeproj " \
"-scheme hellbender " \
"-project ../hellbender.xcodeproj " \
"-destination 'generic/platform=iOS Simulator' " \
"-derivedDataPath '#{DERIVED_DATA}' " \
"-parallel-testing-enabled NO " \
@ -133,14 +133,14 @@ platform :ios do
FileUtils.mkdir_p(SCREENSHOT_SRC)
# Run the test with the explicit xctestrun manifest.
# This installs all DependentProductPaths (including birch.app)
# This installs all DependentProductPaths (including hellbender.app)
# automatically — works around the Xcode 26 bug where
# `xcodebuild build test` / `test-without-building -scheme` fail to
# install the host app.
sh("xcodebuild test-without-building " \
"-xctestrun '#{xctestrun_path}' " \
"-destination '#{destination}' " \
"-only-testing:birchUITests/ScreenshotTests/testScreenshotTour " \
"-only-testing:hellbenderUITests/ScreenshotTests/testScreenshotTour " \
"-parallel-testing-enabled NO") do |status|
UI.error("Test failed on #{name} (#{mode} mode)") unless status.success?
end

View File

@ -10,11 +10,11 @@ devices([
languages(["en-US"])
# Xcode scheme that contains the UI test target.
scheme("birch")
scheme("hellbender")
# Only run the screenshot walker, not the assertion-heavy setup test.
test_target_name("birchUITests")
only_testing(["birchUITests/ScreenshotTests/testScreenshotTour"])
test_target_name("hellbenderUITests")
only_testing(["hellbenderUITests/ScreenshotTests/testScreenshotTour"])
# Output directory is overridden per invocation in the Fastfile so we can split
# dark and light into sibling directories. This value is the default.
@ -37,12 +37,12 @@ concurrent_simulators(false)
# Fail fast — if one device fails, don't burn time on the rest.
stop_after_first_error(true)
# Launch arg read by birchApp.swift to wipe UserDefaults/keychain and
# Launch arg read by hellbenderApp.swift to wipe UserDefaults/keychain and
# use an in-memory SwiftData store. Every run starts at the Welcome screen.
launch_arguments(["-UITesting"])
# Use project-local derived data instead of fastlane's default /tmp path.
# The temp path causes xcodebuild to skip installing birch.app on the
# The temp path causes xcodebuild to skip installing hellbender.app on the
# simulator (only the test runner gets installed), triggering
# "FBSApplicationLibrary returned nil" failures.
derived_data_path("./build/DerivedData")

View File

@ -20,54 +20,54 @@
containerPortal = 3C9ACE1C2F5DED94009B00D0 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3C9ACE232F5DED94009B00D0;
remoteInfo = birch;
remoteInfo = hellbender;
};
3C9ACE3F2F5DED95009B00D0 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 3C9ACE1C2F5DED94009B00D0 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3C9ACE232F5DED94009B00D0;
remoteInfo = birch;
remoteInfo = hellbender;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
3C9ACE242F5DED94009B00D0 /* birch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = birch.app; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE342F5DED95009B00D0 /* birchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = birchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = birchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE242F5DED94009B00D0 /* hellbender.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = hellbender.app; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = hellbenderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = hellbenderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
CC0000010000000000000001 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; };
CC0000010000000000000002 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
CC0000010000000000000003 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
CC0000010000000000000004 /* Birch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Birch.xcconfig; sourceTree = "<group>"; };
CC0000010000000000000004 /* Hellbender.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Hellbender.xcconfig; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
CC0000010000000000000006 /* Exceptions for "birch" folder in "birch" target */ = {
CC0000010000000000000006 /* Exceptions for "hellbender" folder in "hellbender" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 3C9ACE232F5DED94009B00D0 /* birch */;
target = 3C9ACE232F5DED94009B00D0 /* hellbender */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
3C9ACE262F5DED94009B00D0 /* birch */ = {
3C9ACE262F5DED94009B00D0 /* hellbender */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CC0000010000000000000006 /* Exceptions for "birch" folder in "birch" target */,
CC0000010000000000000006 /* Exceptions for "hellbender" folder in "hellbender" target */,
);
path = birch;
path = hellbender;
sourceTree = "<group>";
};
3C9ACE372F5DED95009B00D0 /* birchTests */ = {
3C9ACE372F5DED95009B00D0 /* hellbenderTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = birchTests;
path = hellbenderTests;
sourceTree = "<group>";
};
3C9ACE412F5DED95009B00D0 /* birchUITests */ = {
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = birchUITests;
path = hellbenderUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
@ -106,9 +106,9 @@
isa = PBXGroup;
children = (
CC0000010000000000000005 /* Config */,
3C9ACE262F5DED94009B00D0 /* birch */,
3C9ACE372F5DED95009B00D0 /* birchTests */,
3C9ACE412F5DED95009B00D0 /* birchUITests */,
3C9ACE262F5DED94009B00D0 /* hellbender */,
3C9ACE372F5DED95009B00D0 /* hellbenderTests */,
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */,
3C9ACE252F5DED94009B00D0 /* Products */,
);
sourceTree = "<group>";
@ -116,9 +116,9 @@
3C9ACE252F5DED94009B00D0 /* Products */ = {
isa = PBXGroup;
children = (
3C9ACE242F5DED94009B00D0 /* birch.app */,
3C9ACE342F5DED95009B00D0 /* birchTests.xctest */,
3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */,
3C9ACE242F5DED94009B00D0 /* hellbender.app */,
3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */,
3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
@ -128,7 +128,7 @@
children = (
CC0000010000000000000001 /* Base.xcconfig */,
CC0000010000000000000002 /* Debug.xcconfig */,
CC0000010000000000000004 /* Birch.xcconfig */,
CC0000010000000000000004 /* Hellbender.xcconfig */,
CC0000010000000000000003 /* Release.xcconfig */,
);
path = Config;
@ -137,9 +137,9 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
3C9ACE232F5DED94009B00D0 /* birch */ = {
3C9ACE232F5DED94009B00D0 /* hellbender */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birch" */;
buildConfigurationList = 3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbender" */;
buildPhases = (
3C9ACE202F5DED94009B00D0 /* Sources */,
3C9ACE212F5DED94009B00D0 /* Frameworks */,
@ -150,9 +150,9 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
3C9ACE262F5DED94009B00D0 /* birch */,
3C9ACE262F5DED94009B00D0 /* hellbender */,
);
name = birch;
name = hellbender;
packageProductDependencies = (
AA00000500000000000000D0 /* URKit */,
AA00000800000000000000D0 /* URUI */,
@ -160,13 +160,13 @@
3C1E1C452F7B0D99002FDAE2 /* BitcoinDevKit */,
3C1E1FA42F7B5F63002FDAE2 /* BitcoinDevKit */,
);
productName = birch;
productReference = 3C9ACE242F5DED94009B00D0 /* birch.app */;
productName = hellbender;
productReference = 3C9ACE242F5DED94009B00D0 /* hellbender.app */;
productType = "com.apple.product-type.application";
};
3C9ACE332F5DED95009B00D0 /* birchTests */ = {
3C9ACE332F5DED95009B00D0 /* hellbenderTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchTests" */;
buildConfigurationList = 3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderTests" */;
buildPhases = (
3C9ACE302F5DED95009B00D0 /* Sources */,
3C9ACE312F5DED95009B00D0 /* Frameworks */,
@ -178,18 +178,18 @@
3C9ACE362F5DED95009B00D0 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
3C9ACE372F5DED95009B00D0 /* birchTests */,
3C9ACE372F5DED95009B00D0 /* hellbenderTests */,
);
name = birchTests;
name = hellbenderTests;
packageProductDependencies = (
);
productName = birchTests;
productReference = 3C9ACE342F5DED95009B00D0 /* birchTests.xctest */;
productName = hellbenderTests;
productReference = 3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
3C9ACE3D2F5DED95009B00D0 /* birchUITests */ = {
3C9ACE3D2F5DED95009B00D0 /* hellbenderUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchUITests" */;
buildConfigurationList = 3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderUITests" */;
buildPhases = (
3C9ACE3A2F5DED95009B00D0 /* Sources */,
3C9ACE3B2F5DED95009B00D0 /* Frameworks */,
@ -201,13 +201,13 @@
3C9ACE402F5DED95009B00D0 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
3C9ACE412F5DED95009B00D0 /* birchUITests */,
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */,
);
name = birchUITests;
name = hellbenderUITests;
packageProductDependencies = (
);
productName = birchUITests;
productReference = 3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */;
productName = hellbenderUITests;
productReference = 3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
@ -233,7 +233,7 @@
};
};
};
buildConfigurationList = 3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "birch" */;
buildConfigurationList = 3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "hellbender" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
@ -253,9 +253,9 @@
projectDirPath = "";
projectRoot = "";
targets = (
3C9ACE232F5DED94009B00D0 /* birch */,
3C9ACE332F5DED95009B00D0 /* birchTests */,
3C9ACE3D2F5DED95009B00D0 /* birchUITests */,
3C9ACE232F5DED94009B00D0 /* hellbender */,
3C9ACE332F5DED95009B00D0 /* hellbenderTests */,
3C9ACE3D2F5DED95009B00D0 /* hellbenderUITests */,
);
};
/* End PBXProject section */
@ -311,12 +311,12 @@
/* Begin PBXTargetDependency section */
3C9ACE362F5DED95009B00D0 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3C9ACE232F5DED94009B00D0 /* birch */;
target = 3C9ACE232F5DED94009B00D0 /* hellbender */;
targetProxy = 3C9ACE352F5DED95009B00D0 /* PBXContainerItemProxy */;
};
3C9ACE402F5DED95009B00D0 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3C9ACE232F5DED94009B00D0 /* birch */;
target = 3C9ACE232F5DED94009B00D0 /* hellbender */;
targetProxy = 3C9ACE3F2F5DED95009B00D0 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
@ -326,7 +326,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000002 /* Debug.xcconfig */;
buildSettings = {
DEVELOPMENT_TEAM = ZW85AH743B;
};
name = Debug;
};
@ -334,25 +333,22 @@
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000003 /* Release.xcconfig */;
buildSettings = {
DEVELOPMENT_TEAM = ZW85AH743B;
};
name = Release;
};
3C9ACE492F5DED95009B00D0 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
baseConfigurationReference = CC0000010000000000000004 /* Hellbender.xcconfig */;
buildSettings = {
CURRENT_PROJECT_VERSION = 26;
MARKETING_VERSION = 0.2.0;
CURRENT_PROJECT_VERSION = 24;
};
name = Debug;
};
3C9ACE4A2F5DED95009B00D0 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
baseConfigurationReference = CC0000010000000000000004 /* Hellbender.xcconfig */;
buildSettings = {
CURRENT_PROJECT_VERSION = 26;
MARKETING_VERSION = 0.2.0;
CURRENT_PROJECT_VERSION = 24;
};
name = Release;
};
@ -370,7 +366,7 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/birch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/birch";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hellbender.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hellbender";
};
name = Debug;
};
@ -388,7 +384,7 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/birch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/birch";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hellbender.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hellbender";
};
name = Release;
};
@ -404,7 +400,7 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = birch;
TEST_TARGET_NAME = hellbender;
};
name = Debug;
};
@ -420,14 +416,14 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = birch;
TEST_TARGET_NAME = hellbender;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "birch" */ = {
3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "hellbender" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3C9ACE462F5DED95009B00D0 /* Debug */,
@ -436,7 +432,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birch" */ = {
3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbender" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3C9ACE492F5DED95009B00D0 /* Debug */,
@ -445,7 +441,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchTests" */ = {
3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3C9ACE4C2F5DED95009B00D0 /* Debug */,
@ -454,7 +450,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchUITests" */ = {
3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3C9ACE4F2F5DED95009B00D0 /* Debug */,

View File

@ -0,0 +1,87 @@
{
"originHash" : "11f3c5d73e6615e055e5b9f3671e6180f277a34f298c3f7c6935dcc8dd281089",
"pins" : [
{
"identity" : "bbqr-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/bitcoinppl/bbqr-swift",
"state" : {
"revision" : "83b828077ecc4f5d2cf8889da5543a61b4a60a3c",
"version" : "0.3.1"
}
},
{
"identity" : "bcswiftdcbor",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/BCSwiftDCBOR",
"state" : {
"revision" : "21efa67ada2f22a6c277e1961f1059bb376e9b1a",
"version" : "2.0.2"
}
},
{
"identity" : "bcswiftfloat16",
"kind" : "remoteSourceControl",
"location" : "https://github.com/blockchaincommons/BCSwiftFloat16",
"state" : {
"revision" : "a27f3935a7b1db715713eda67369b02feade2ded",
"version" : "2.0.0"
}
},
{
"identity" : "bcswifttags",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/BCSwiftTags",
"state" : {
"revision" : "ced8d92c7cc53375cdf9806c59251fe0161f02ec",
"version" : "0.2.3"
}
},
{
"identity" : "bdk-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/newtonick/bdk-swift",
"state" : {
"revision" : "4660bc83ea6088906edb090652d261e8ed4c09e3",
"version" : "2.3.1-ssl-patch"
}
},
{
"identity" : "swift-numberkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/objecthub/swift-numberkit.git",
"state" : {
"revision" : "33af3f9011e45dcd8ee696492d30dbcd5a8a67f3",
"version" : "2.6.0"
}
},
{
"identity" : "swiftsortedcollections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/wolfmcnally/SwiftSortedCollections",
"state" : {
"revision" : "dd6c8e0eaef987e55a35c056d185144a7c71fc19",
"version" : "0.1.0"
}
},
{
"identity" : "urkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/URKit",
"state" : {
"revision" : "c0a447560768e2552cf85a586dea8cfc26162891",
"version" : "15.1.0"
}
},
{
"identity" : "urui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/URUI",
"state" : {
"revision" : "c1b0ac2d0ba77741f00f439d311e7c85ee26a70a",
"version" : "12.0.0"
}
}
],
"version" : 3
}

View File

@ -16,9 +16,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3C9ACE232F5DED94009B00D0"
BuildableName = "birch.app"
BlueprintName = "birch"
ReferencedContainer = "container:birch.xcodeproj">
BuildableName = "hellbender.app"
BlueprintName = "hellbender"
ReferencedContainer = "container:hellbender.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
@ -36,9 +36,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3C9ACE332F5DED95009B00D0"
BuildableName = "birchTests.xctest"
BlueprintName = "birchTests"
ReferencedContainer = "container:birch.xcodeproj">
BuildableName = "hellbenderTests.xctest"
BlueprintName = "hellbenderTests"
ReferencedContainer = "container:hellbender.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
@ -47,9 +47,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3C9ACE3D2F5DED95009B00D0"
BuildableName = "birchUITests.xctest"
BlueprintName = "birchUITests"
ReferencedContainer = "container:birch.xcodeproj">
BuildableName = "hellbenderUITests.xctest"
BlueprintName = "hellbenderUITests"
ReferencedContainer = "container:hellbender.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
@ -69,9 +69,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3C9ACE232F5DED94009B00D0"
BuildableName = "birch.app"
BlueprintName = "birch"
ReferencedContainer = "container:birch.xcodeproj">
BuildableName = "hellbender.app"
BlueprintName = "hellbender"
ReferencedContainer = "container:hellbender.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
@ -86,9 +86,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3C9ACE232F5DED94009B00D0"
BuildableName = "birch.app"
BlueprintName = "birch"
ReferencedContainer = "container:birch.xcodeproj">
BuildableName = "hellbender.app"
BlueprintName = "hellbender"
ReferencedContainer = "container:hellbender.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -6,10 +6,12 @@
"scale" : "1x"
},
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"scale" : "3x"
}
@ -18,4 +20,4 @@
"author" : "xcode",
"version" : 1
}
}
}

View File

@ -3,7 +3,7 @@ import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "AppLifecycle")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "AppLifecycle")
struct ContentView: View {
@Query private var wallets: [WalletProfile]
@ -100,26 +100,21 @@ private struct PrivacyOverlayView: View {
Color.hbBackground
.ignoresSafeArea()
VStack(spacing: 32) {
Spacer()
ThemedAppIcon()
.aspectRatio(contentMode: .fit)
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.stroke(Color.hbBackground, lineWidth: 24)
.blur(radius: 12)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
)
Text("Birch Wallet")
.font(.hbDisplay(34))
.foregroundStyle(Color.hbTextPrimary)
Spacer()
}
Image("WelcomeIcon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.stroke(Color.hbBackground, lineWidth: 24)
.blur(radius: 12)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
)
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.strokeBorder(Color.hbBorder.opacity(0.5), lineWidth: 1)
)
}
}
}

View File

@ -5,49 +5,9 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Birch Wallet</string>
<string>Hellbender</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIcons</key>
<dict>
<key>CFBundlePrimaryIcon</key>
<dict>
<key>CFBundleIconName</key>
<string>AppIcon</string>
</dict>
<key>CFBundleAlternateIcons</key>
<dict>
<key>AppIcon-Dark</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon-Dark</string>
</array>
<key>UIPrerenderedIcon</key>
<false/>
</dict>
</dict>
</dict>
<key>CFBundleIcons~ipad</key>
<dict>
<key>CFBundlePrimaryIcon</key>
<dict>
<key>CFBundleIconName</key>
<string>AppIcon</string>
</dict>
<key>CFBundleAlternateIcons</key>
<dict>
<key>AppIcon-Dark</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon-Dark</string>
</array>
<key>UIPrerenderedIcon</key>
<false/>
</dict>
</dict>
</dict>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
@ -63,9 +23,9 @@
<key>LSApplicationCategoryType</key>
<string>public.app-category.finance</string>
<key>NSCameraUsageDescription</key>
<string>Birch needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets.</string>
<string>Hellbender needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets.</string>
<key>NSFaceIDUsageDescription</key>
<string>Birch uses Face ID to securely unlock your wallet.</string>
<string>Hellbender uses Face ID to securely unlock your wallet.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@ -21,7 +21,7 @@ enum FeeSource: String, CaseIterable {
}
}
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "BitcoinService")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "BitcoinService")
@Observable
@MainActor
@ -1333,18 +1333,12 @@ final class BitcoinService {
) -> String {
let chain = isChange ? "1" : "0"
let coinType = network.coinType
let isTestnet = network != .mainnet
let normalized = cosigners.map { cosigner -> (xpub: String, fingerprint: String, derivationPath: String) in
let raw = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let xpub = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
return (xpub: xpub, fingerprint: cosigner.fingerprint, derivationPath: cosigner.derivationPath)
}
let sorted = normalized.sorted { $0.xpub < $1.xpub }
let sorted = cosigners.sorted { $0.xpub < $1.xpub }
let keys = sorted.map { cosigner in
"[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(cosigner.xpub)/\(chain)/*"
let xpub = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(xpub)/\(chain)/*"
}.joined(separator: ",")
return "wsh(sortedmulti(\(requiredSignatures),\(keys)))"
@ -1357,20 +1351,11 @@ final class BitcoinService {
network: BitcoinNetwork
) -> String {
let coinType = network.coinType
let isTestnet = network != .mainnet
// Normalize each cosigner xpub to standard xpub/tpub format (BDK descriptor
// parser does not accept SLIP132-tagged Vpub/Zpub/Ypub/Upub keys).
let normalized = cosigners.map { cosigner -> (xpub: String, fingerprint: String, derivationPath: String) in
let raw = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let xpub = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
return (xpub: xpub, fingerprint: cosigner.fingerprint, derivationPath: cosigner.derivationPath)
}
let sorted = normalized.sorted { $0.xpub < $1.xpub }
let sorted = cosigners.sorted { $0.xpub < $1.xpub }
let keys = sorted.map { cosigner in
"[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(cosigner.xpub)/<0;1>/*"
let xpub = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(xpub)/<0;1>/*"
}.joined(separator: ",")
let raw = "wsh(sortedmulti(\(requiredSignatures),\(keys)))"

View File

@ -2,7 +2,7 @@ import Foundation
import Observation
import OSLog
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "FiatPriceService")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "FiatPriceService")
enum FiatSource: String, CaseIterable {
case zeus

View File

@ -2,7 +2,7 @@ import Foundation
import OSLog
import SwiftData
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "LabelService")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "LabelService")
/// Handles label propagation between transactions, UTXOs, and addresses.
enum LabelService {

View File

@ -3,7 +3,7 @@ import Foundation
enum Constants {
// MARK: - App
static let appName = "Birch"
static let appName = "Hellbender"
static let defaultNetwork: BitcoinNetwork = .testnet4
// MARK: - BIP48 P2WSH

View File

@ -1,15 +1,7 @@
import Foundation
import Security
protocol KeychainStoring {
@discardableResult
static func save(_ data: Data, forKey key: String) -> Bool
static func load(forKey key: String) -> Data?
static func delete(forKey key: String)
static func deleteAll()
}
enum KeychainHelper: KeychainStoring {
enum KeychainHelper {
private static let service = Bundle.main.bundleIdentifier ?? "com.hellbender"
@discardableResult

View File

@ -8,7 +8,7 @@ enum LogExporter {
static func collectLogs(hours: Double = 1) throws -> String {
let store = try OSLogStore(scope: .currentProcessIdentifier)
let cutoff = store.position(date: Date().addingTimeInterval(-hours * 3600))
let subsystem = Bundle.main.bundleIdentifier ?? "birch"
let subsystem = Bundle.main.bundleIdentifier ?? "hellbender"
let entries = try store.getEntries(at: cutoff, matching: NSPredicate(format: "subsystem == %@", subsystem))
@ -27,7 +27,7 @@ enum LogExporter {
return "No log entries found in the last \(Int(hours)) hour(s)."
}
let header = "Birch Logs — Exported \(formatter.string(from: Date()))\n"
let header = "Hellbender Logs — Exported \(formatter.string(from: Date()))\n"
+ "Entries: \(lines.count) (last \(Int(hours))h)\n"
+ String(repeating: "", count: 60) + "\n"

View File

@ -4,7 +4,7 @@ import LocalAuthentication
import OSLog
import SwiftData
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "AppLock")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "AppLock")
@Observable
@MainActor
@ -18,7 +18,6 @@ final class AppLockViewModel {
private(set) var failedAttempts: Int = 0
private(set) var lockoutExpiry: Date?
private var backgroundTime: Date?
private let keychain: KeychainStoring.Type
// MARK: - Computed
@ -48,10 +47,9 @@ final class AppLockViewModel {
// MARK: - Init
init(keychain: KeychainStoring.Type = KeychainHelper.self) {
self.keychain = keychain
hasPIN = keychain.load(forKey: Constants.keychainPINHashKey) != nil
if let data = keychain.load(forKey: Constants.keychainPINLengthKey),
init() {
hasPIN = KeychainHelper.load(forKey: Constants.keychainPINHashKey) != nil
if let data = KeychainHelper.load(forKey: Constants.keychainPINLengthKey),
let str = String(data: data, encoding: .utf8),
let len = Int(str)
{
@ -104,7 +102,7 @@ final class AppLockViewModel {
return false
}
guard let storedHash = keychain.load(forKey: Constants.keychainPINHashKey) else {
guard let storedHash = KeychainHelper.load(forKey: Constants.keychainPINHashKey) else {
return false
}
@ -140,8 +138,8 @@ final class AppLockViewModel {
func setPIN(_ pin: String) {
logger.info("PIN set (\(pin.count) digits)")
let hash = hashPIN(pin)
keychain.save(hash, forKey: Constants.keychainPINHashKey)
keychain.save(Data("\(pin.count)".utf8), forKey: Constants.keychainPINLengthKey)
KeychainHelper.save(hash, forKey: Constants.keychainPINHashKey)
KeychainHelper.save(Data("\(pin.count)".utf8), forKey: Constants.keychainPINLengthKey)
failedAttempts = 0
persistFailedAttempts()
lockoutExpiry = nil
@ -152,10 +150,10 @@ final class AppLockViewModel {
func removePIN() {
logger.info("PIN removed")
keychain.delete(forKey: Constants.keychainPINHashKey)
keychain.delete(forKey: Constants.keychainPINLengthKey)
keychain.delete(forKey: Constants.keychainFailedAttemptsKey)
keychain.delete(forKey: Constants.keychainLockoutExpiryKey)
KeychainHelper.delete(forKey: Constants.keychainPINHashKey)
KeychainHelper.delete(forKey: Constants.keychainPINLengthKey)
KeychainHelper.delete(forKey: Constants.keychainFailedAttemptsKey)
KeychainHelper.delete(forKey: Constants.keychainLockoutExpiryKey)
failedAttempts = 0
lockoutExpiry = nil
hasPIN = false
@ -172,15 +170,6 @@ final class AppLockViewModel {
}
func handleForeground(timeout: Int) {
hasPIN = keychain.load(forKey: Constants.keychainPINHashKey) != nil
if let data = keychain.load(forKey: Constants.keychainPINLengthKey),
let str = String(data: data, encoding: .utf8),
let len = Int(str)
{
storedPINLength = len
} else {
storedPINLength = 6
}
if let bgTime = backgroundTime {
let elapsed = Int(Date().timeIntervalSince(bgTime))
if elapsed >= timeout {
@ -219,7 +208,7 @@ final class AppLockViewModel {
}
// Clear Keychain
keychain.deleteAll()
KeychainHelper.deleteAll()
// Reset BitcoinService
BitcoinService.shared.unloadWallet()
@ -256,13 +245,13 @@ final class AppLockViewModel {
}
private func loadPersistedState() {
if let data = keychain.load(forKey: Constants.keychainFailedAttemptsKey),
if let data = KeychainHelper.load(forKey: Constants.keychainFailedAttemptsKey),
let str = String(data: data, encoding: .utf8),
let count = Int(str)
{
failedAttempts = count
}
if let data = keychain.load(forKey: Constants.keychainLockoutExpiryKey),
if let data = KeychainHelper.load(forKey: Constants.keychainLockoutExpiryKey),
let str = String(data: data, encoding: .utf8),
let interval = Double(str)
{
@ -272,14 +261,14 @@ final class AppLockViewModel {
}
private func persistFailedAttempts() {
keychain.save(Data("\(failedAttempts)".utf8), forKey: Constants.keychainFailedAttemptsKey)
KeychainHelper.save(Data("\(failedAttempts)".utf8), forKey: Constants.keychainFailedAttemptsKey)
}
private func persistLockoutExpiry() {
if let expiry = lockoutExpiry {
keychain.save(Data("\(expiry.timeIntervalSince1970)".utf8), forKey: Constants.keychainLockoutExpiryKey)
KeychainHelper.save(Data("\(expiry.timeIntervalSince1970)".utf8), forKey: Constants.keychainLockoutExpiryKey)
} else {
keychain.delete(forKey: Constants.keychainLockoutExpiryKey)
KeychainHelper.delete(forKey: Constants.keychainLockoutExpiryKey)
}
}
}

View File

@ -3,7 +3,7 @@ import Observation
import OSLog
import SwiftData
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "BumpFeeViewModel")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "BumpFeeViewModel")
@Observable
@MainActor

View File

@ -3,7 +3,7 @@ import Observation
import OSLog
import SwiftData
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "SendViewModel")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "SendViewModel")
@Observable
@MainActor

View File

@ -193,17 +193,13 @@ final class SetupWizardViewModel {
func buildDescriptors() {
guard allCosignersComplete else { return }
// Build key origin strings normalize to standard xpub/tpub format before
// sorting so BIP67 ordering matches what's emitted in the descriptor.
let isTestnet = network != .mainnet
// Build key origin strings and sort by xpub (BIP67 lexicographic sort)
var keyEntries: [(origin: String, xpub: String, fingerprint: String, path: String, label: String, index: Int)] = []
for i in 0 ..< totalCosigners {
let raw = cosignerXpubs[i].trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let normalized = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
keyEntries.append((
origin: "[\(cosignerFingerprints[i])/48'/\(network.coinType)'/0'/2']",
xpub: normalized,
xpub: cosignerXpubs[i],
fingerprint: cosignerFingerprints[i],
path: cosignerDerivationPaths[i],
label: cosignerLabels[i],
@ -215,10 +211,12 @@ final class SetupWizardViewModel {
keyEntries.sort { $0.xpub < $1.xpub }
let externalKeys = keyEntries.map {
"\($0.origin)\($0.xpub)/0/*"
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/0/*"
}.joined(separator: ",")
let internalKeys = keyEntries.map {
"\($0.origin)\($0.xpub)/1/*"
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/1/*"
}.joined(separator: ",")
externalDescriptor = "wsh(sortedmulti(\(requiredSignatures),\(externalKeys)))"

View File

@ -3,7 +3,7 @@ import Observation
import OSLog
import SwiftData
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "WalletManager")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "WalletManager")
@Observable
@MainActor

View File

@ -3,7 +3,7 @@ import CoreImage.CIFilterBuiltins
import OSLog
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "BBQRDisplayView")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "BBQRDisplayView")
struct BBQRDisplayView: View {
let data: Data

View File

@ -78,17 +78,17 @@ struct HBTheme {
)
static let birchLight = HBTheme(
background: Color(red: 0.949, green: 0.933, blue: 0.902),
surface: Color(red: 0.890, green: 0.867, blue: 0.827),
surfaceElevated: Color(red: 0.949, green: 0.933, blue: 0.902),
border: Color(red: 0.690, green: 0.663, blue: 0.616),
textPrimary: Color(red: 0.137, green: 0.122, blue: 0.106),
textSecondary: Color(red: 0.310, green: 0.282, blue: 0.251),
accent: Color(red: 0.698, green: 0.525, blue: 0.133),
heroBackground: Color(red: 0.890, green: 0.867, blue: 0.827),
success: Color(red: 0.278, green: 0.373, blue: 0.224),
error: Color(red: 0.600, green: 0.180, blue: 0.120),
secondaryAccent: Color(red: 0.278, green: 0.373, blue: 0.224),
background: Color(red: 0.929, green: 0.910, blue: 0.875),
surface: Color(red: 0.851, green: 0.824, blue: 0.773),
surfaceElevated: Color(red: 0.929, green: 0.910, blue: 0.875),
border: Color(red: 0.769, green: 0.741, blue: 0.690),
textPrimary: Color(red: 0.165, green: 0.145, blue: 0.125),
textSecondary: Color(red: 0.420, green: 0.380, blue: 0.345),
accent: Color(red: 0.769, green: 0.584, blue: 0.165),
heroBackground: Color(red: 0.851, green: 0.824, blue: 0.773),
success: Color(red: 0.353, green: 0.400, blue: 0.259),
error: Color(red: 0.549, green: 0.188, blue: 0.125),
secondaryAccent: Color(red: 0.353, green: 0.400, blue: 0.259),
colorScheme: .light
)
}
@ -105,8 +105,8 @@ enum AppTheme: String, CaseIterable {
var displayName: String {
switch self {
case .system: "System"
case .dark: "Hellbender Dark"
case .light: "Hellbender Light"
case .dark: "Dark"
case .light: "Light"
case .birchDark: "Birch Dark"
case .birchLight: "Birch Light"
}
@ -128,7 +128,7 @@ enum AppTheme: String, CaseIterable {
@Observable
final class ThemeManager {
static let shared = ThemeManager()
private(set) var theme: HBTheme = .birchDark
private(set) var theme: HBTheme = .dark
private init() {
let saved = UserDefaults.standard.string(forKey: Constants.themeKey) ?? AppTheme.system.rawValue
@ -143,7 +143,7 @@ final class ThemeManager {
/// Sets the displayed theme to the appropriate custom palette for the given OS color scheme.
/// Only used when the System theme is selected does not save to UserDefaults.
func applySystemColorScheme(_ colorScheme: ColorScheme) {
theme = colorScheme == .dark ? .birchDark : .birchLight
theme = colorScheme == .dark ? .dark : .light
}
}
@ -288,21 +288,6 @@ extension View {
}
}
// MARK: - Themed App Icon
/// Renders the app-icon artwork that matches the current theme's light/dark appearance.
/// Uses `AppIconPreviewLight` on light color schemes, `AppIconPreviewDark` on dark.
/// The theme is applied via `.preferredColorScheme` at the root, so this works for
/// all AppTheme cases (system, birch light/dark).
struct ThemedAppIcon: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Image(colorScheme == .dark ? "AppIconPreviewDark" : "AppIconPreviewLight")
.resizable()
}
}
// MARK: - Network Badge
struct NetworkBadge: View {

View File

@ -6,7 +6,7 @@ import SwiftUI
import URKit
import URUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "URScannerSheet")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "URScannerSheet")
struct URScannerSheet: View {
let onResult: (AppURResult) -> Void

View File

@ -265,7 +265,7 @@ struct ConnectionStatusView: View {
}
private func copyDebugInfo() {
var lines = ["=== Birch Debug Info ==="]
var lines = ["=== Hellbender Debug Info ==="]
lines.append("Timestamp: \(ISO8601DateFormatter().string(from: Date()))")
// SwiftData wallet info

View File

@ -2,7 +2,7 @@ import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "Navigation")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "Navigation")
struct MainTabView: View {
@State private var selectedTab = 0

View File

@ -243,7 +243,7 @@ struct BroadcastResultView: View {
walletID: walletID
)
} catch {
Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "LabelService")
Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "LabelService")
.error("Failed to propagate change label: \(error.localizedDescription)")
}
}

View File

@ -3,7 +3,7 @@ import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "Settings")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "Settings")
struct SettingsView: View {
@Environment(\.modelContext) private var modelContext
@ -24,6 +24,11 @@ struct SettingsView: View {
// Security
AppLockSettingsSection()
// Appearance
Section("Appearance") {
AppearanceSettingsRow()
}
// Fee Estimation
Section("Fee Estimation") {
FeeSettingsRow()
@ -34,16 +39,6 @@ struct SettingsView: View {
FiatSettingsRow()
}
// Appearance
Section("Appearance") {
AppearanceSettingsRow()
}
// App Icon
Section("App Icon") {
AppIconSettingsRow()
}
// About
Section("About") {
HStack {
@ -97,108 +92,6 @@ private struct AppearanceSettingsRow: View {
}
}
// MARK: - App Icon Settings
private enum AppIconOption: String, CaseIterable, Identifiable {
case light
case dark
var id: String {
rawValue
}
/// Name passed to `UIApplication.setAlternateIconName`; `nil` selects the primary icon.
var alternateIconName: String? {
switch self {
case .light: nil
case .dark: "AppIcon-Dark"
}
}
var displayName: String {
switch self {
case .light: "Light"
case .dark: "Dark"
}
}
var previewAssetName: String {
switch self {
case .light: "AppIconPreviewLight"
case .dark: "AppIconPreviewDark"
}
}
static var current: AppIconOption {
UIApplication.shared.alternateIconName == "AppIcon-Dark" ? .dark : .light
}
}
private struct AppIconSettingsRow: View {
@State private var selected: AppIconOption = .current
var body: some View {
HStack(spacing: 12) {
ForEach(AppIconOption.allCases) { option in
AppIconTile(option: option, isSelected: selected == option) {
select(option)
}
}
}
.listRowBackground(Color.hbSurface)
}
private func select(_ option: AppIconOption) {
guard selected != option else { return }
UIApplication.shared.setAlternateIconName(option.alternateIconName) { error in
Task { @MainActor in
if let error {
logger.error("Failed to set app icon: \(error.localizedDescription, privacy: .public)")
} else {
logger.info("App icon changed to \(option.displayName, privacy: .public)")
selected = option
}
}
}
}
}
private struct AppIconTile: View {
let option: AppIconOption
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(spacing: 8) {
Image(option.previewAssetName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isSelected ? Color.hbBitcoinOrange : Color.hbBorder, lineWidth: isSelected ? 3 : 1)
)
HStack(spacing: 4) {
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 12))
.foregroundStyle(Color.hbBitcoinOrange)
}
Text(option.displayName)
.font(.hbBody(13))
.foregroundStyle(Color.hbTextPrimary)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.buttonStyle(.plain)
}
}
// MARK: - Denomination Settings
private struct DenominationSettingsRow: View {

View File

@ -2,7 +2,7 @@ import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "UTXODetail")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "UTXODetail")
struct UTXODetailView: View {
let utxo: UTXOItem

View File

@ -3,7 +3,7 @@ import SwiftData
import SwiftUI
import URKit
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "WalletInfo")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "WalletInfo")
struct WalletInfoView: View {
@Environment(\.modelContext) private var modelContext

View File

@ -2,7 +2,7 @@ import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "TransactionDetailView")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "TransactionDetailView")
struct TransactionDetailView: View {
let transaction: TransactionItem

View File

@ -3,7 +3,7 @@ import SwiftData
import SwiftUI
import UniformTypeIdentifiers
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "TransactionListView")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "TransactionListView")
struct TransactionListView: View {
@Query private var wallets: [WalletProfile]

Some files were not shown because too many files have changed in this diff Show More