Compare commits

..

8 Commits

Author SHA1 Message Date
Nick Klockenga
84c02cc6dd
version bump (#31)
Some checks failed
Xcode - Build and Analyze / Build and analyse default scheme using xcodebuild command (push) Has been cancelled
Xcode - Unit Tests / Run unit tests using xcodebuild (push) Has been cancelled
Reproducible Build Check / Verify build reproducibility (push) Has been cancelled
SwiftFormat Check / Check code formatting with SwiftFormat (push) Has been cancelled
2026-04-30 23:17:15 -04:00
Nick Klockenga
fac6560531
Security PIN for app lock had bugs, fixed up syncing of state issues across settings and app foreground/launch (#30) 2026-04-30 23:14:54 -04:00
Nick Klockenga
33b7e491c2
handle Vpub/tpub/Zpub/xpub conversion correctly when creating a wallet. Also adding unit test for checking descriptor build (#29) 2026-04-30 23:14:35 -04:00
Nick Klockenga
209750c4e5
Rebrand Wallet to Birch Wallet (#28)
* step one

* progress

* minor theme enhancements

* update screenshot and icon links in README.md

* update site link

* swiftformat fixes
2026-04-30 21:00:59 -04:00
Nick Klockenga
b75f93d950
More screenshots added to the UITest for fastlane (#27)
Some checks failed
Xcode - Build and Analyze / Build and analyse default scheme using xcodebuild command (push) Has been cancelled
Xcode - Unit Tests / Run unit tests using xcodebuild (push) Has been cancelled
Reproducible Build Check / Verify build reproducibility (push) Has been cancelled
SwiftFormat Check / Check code formatting with SwiftFormat (push) Has been cancelled
* add new screenshots and fix a few items

* Additional fixes related to screenshot UI testing
2026-04-28 22:33:32 -04:00
Nick Klockenga
fb7d5376f8
derivation path input field is read only (#26)
Some checks failed
Xcode - Build and Analyze / Build and analyse default scheme using xcodebuild command (push) Has been cancelled
Xcode - Unit Tests / Run unit tests using xcodebuild (push) Has been cancelled
Reproducible Build Check / Verify build reproducibility (push) Has been cancelled
SwiftFormat Check / Check code formatting with SwiftFormat (push) Has been cancelled
2026-04-14 12:38:14 -04:00
Nick Klockenga
806341cdd6
Fastlane screenshot automation setup (#24)
* Setup fastlane for screenshot automation capture

* add more screenshots and fix some bugs. Also update frameit with patch

* update Set Up Wallet to Wallet Setup

* swiftformat
2026-04-14 12:38:03 -04:00
Nick Klockenga
2c539d3a05
update screenshots (#25) 2026-04-13 22:31:29 -04:00
131 changed files with 1281 additions and 428 deletions

View File

@ -22,20 +22,20 @@ jobs:
xcodebuild -version
- name: Resolve packages
run: xcodebuild -resolvePackageDependencies -project hellbender.xcodeproj -scheme hellbender
run: xcodebuild -resolvePackageDependencies -project birch.xcodeproj -scheme birch
- name: Validate Package.resolved
run: |
if ! git diff --quiet hellbender.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved; then
if ! git diff --quiet birch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved; then
echo "::error::Package.resolved has uncommitted changes after resolution. Commit the updated Package.resolved."
git diff hellbender.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
git diff birch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
exit 1
fi
- name: Build
env:
scheme: hellbender
file_to_build: hellbender.xcodeproj
scheme: birch
file_to_build: birch.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 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]}
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]}

View File

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

View File

@ -1,12 +1,14 @@
// Hellbender.xcconfig — Target-level settings for the hellbender app target
// Birch.xcconfig — Target-level settings for the birch 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 = "hellbender/Preview Content"
DEVELOPMENT_ASSET_PATHS = "birch/Preview Content"
GENERATE_INFOPLIST_FILE = NO
INFOPLIST_FILE = hellbender/Info.plist
INFOPLIST_FILE = birch/Info.plist
IPHONEOS_DEPLOYMENT_TARGET = 18.6
LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks
MARKETING_VERSION = 0.1.2

View File

@ -1,24 +1,29 @@
<p align="center">
<img src="https://hellbenderwallet.com/assets/AppIcon-og.png" alt="Hellbender" width="128" height="128" style="border-radius: 24px;" />
<img src="https://birchwallet.app/assets/AppIcon-og.png" alt="Birch" width="128" height="128" style="border-radius: 24px;" />
</p>
<h1 align="center">Hellbender</h1>
<h1 align="center">Birch</h1>
<p align="center">
<em>Travel to your private keys and leave your laptop at home.</em>
</p>
<p align="center">
<img src="https://hellbenderwallet.com/assets/screenshots/welcome.png" alt="Welcome" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/transactions.png" alt="Transactions" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/multisig-config.png" alt="Multisig Config" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/import-descriptor.png" alt="Import Descriptor" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/review-wallet.png" alt="Review Wallet" width="150" />
<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" />
</p>
---
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.
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.
## Features
@ -60,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 `hellbender.xcodeproj` in Xcode
2. Open `birch.xcodeproj` in Xcode
3. SPM dependencies resolve automatically on first open
4. Build and run on a simulator or device
@ -72,7 +77,7 @@ GitHub Actions runs `xcodebuild clean build analyze` on every push and pull requ
### Reproducible Builds
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.
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.
**What IS reproducible** (after normalization): all code-bearing sections, resources, and application logic.
@ -89,7 +94,7 @@ Hellbender supports **functionally equivalent** reproducible builds. Given the s
./scripts/build-release.sh
```
This creates an unsigned archive at `/tmp/hellbender-build/hellbender.xcarchive`.
This creates an unsigned archive at `/tmp/birch-build/birch.xcarchive`.
#### Verifying two builds
@ -106,7 +111,7 @@ The comparison exits 0 if the builds are functionally equivalent, 1 if code diff
## Generating Screenshots
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.
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.
### One-time setup
@ -183,7 +188,7 @@ fastlane/screenshots/
### How it works
- [`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.
- [`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.
- 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).
@ -191,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 `hellbenderUITests/ScreenshotTests.swift` and add or remove `snapshot("NN-Name")` calls.
- **Change which screens are captured:** edit `testScreenshotTour` in `birchUITests/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**: [hellbenderwallet.com](https://hellbenderwallet.com)
- **Website**: [birchwallet.app](https://birchwallet.app)
- **TestFlight Beta**: [Join the beta](https://testflight.apple.com/join/PuHVwJDJ)
- **Author**: [newtonick](https://github.com/newtonick/hellbender-wallet/)
@ -206,5 +211,5 @@ fastlane/screenshots/
MIT License — see [LICENSE](LICENSE) for details.
Hellbender's dependencies use permissive licenses compatible with MIT:
Birch'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).

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

View File

@ -3,7 +3,7 @@ import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "AppLifecycle")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "AppLifecycle")
struct ContentView: View {
@Query private var wallets: [WalletProfile]
@ -100,21 +100,26 @@ private struct PrivacyOverlayView: View {
Color.hbBackground
.ignoresSafeArea()
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)
)
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()
}
}
}
}

View File

@ -5,9 +5,49 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Hellbender</string>
<string>Birch Wallet</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>
@ -23,9 +63,9 @@
<key>LSApplicationCategoryType</key>
<string>public.app-category.finance</string>
<key>NSCameraUsageDescription</key>
<string>Hellbender needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets.</string>
<string>Birch needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets.</string>
<key>NSFaceIDUsageDescription</key>
<string>Hellbender uses Face ID to securely unlock your wallet.</string>
<string>Birch 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 ?? "hellbender", category: "BitcoinService")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "BitcoinService")
@Observable
@MainActor
@ -1333,12 +1333,18 @@ final class BitcoinService {
) -> String {
let chain = isChange ? "1" : "0"
let coinType = network.coinType
let isTestnet = network != .mainnet
let sorted = cosigners.sorted { $0.xpub < $1.xpub }
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 keys = sorted.map { cosigner in
let xpub = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(xpub)/\(chain)/*"
"[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(cosigner.xpub)/\(chain)/*"
}.joined(separator: ",")
return "wsh(sortedmulti(\(requiredSignatures),\(keys)))"
@ -1351,11 +1357,20 @@ final class BitcoinService {
network: BitcoinNetwork
) -> String {
let coinType = network.coinType
let sorted = cosigners.sorted { $0.xpub < $1.xpub }
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 keys = sorted.map { cosigner in
let xpub = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(xpub)/<0;1>/*"
"[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(cosigner.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 ?? "hellbender", category: "FiatPriceService")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", 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 ?? "hellbender", category: "LabelService")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", 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 = "Hellbender"
static let appName = "Birch"
static let defaultNetwork: BitcoinNetwork = .testnet4
// MARK: - BIP48 P2WSH

View File

@ -1,7 +1,15 @@
import Foundation
import Security
enum KeychainHelper {
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 {
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 ?? "hellbender"
let subsystem = Bundle.main.bundleIdentifier ?? "birch"
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 = "Hellbender Logs — Exported \(formatter.string(from: Date()))\n"
let header = "Birch 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 ?? "hellbender", category: "AppLock")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "AppLock")
@Observable
@MainActor
@ -18,6 +18,7 @@ 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
@ -47,9 +48,10 @@ final class AppLockViewModel {
// MARK: - Init
init() {
hasPIN = KeychainHelper.load(forKey: Constants.keychainPINHashKey) != nil
if let data = KeychainHelper.load(forKey: Constants.keychainPINLengthKey),
init(keychain: KeychainStoring.Type = KeychainHelper.self) {
self.keychain = keychain
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)
{
@ -102,7 +104,7 @@ final class AppLockViewModel {
return false
}
guard let storedHash = KeychainHelper.load(forKey: Constants.keychainPINHashKey) else {
guard let storedHash = keychain.load(forKey: Constants.keychainPINHashKey) else {
return false
}
@ -138,8 +140,8 @@ final class AppLockViewModel {
func setPIN(_ pin: String) {
logger.info("PIN set (\(pin.count) digits)")
let hash = hashPIN(pin)
KeychainHelper.save(hash, forKey: Constants.keychainPINHashKey)
KeychainHelper.save(Data("\(pin.count)".utf8), forKey: Constants.keychainPINLengthKey)
keychain.save(hash, forKey: Constants.keychainPINHashKey)
keychain.save(Data("\(pin.count)".utf8), forKey: Constants.keychainPINLengthKey)
failedAttempts = 0
persistFailedAttempts()
lockoutExpiry = nil
@ -150,10 +152,10 @@ final class AppLockViewModel {
func removePIN() {
logger.info("PIN removed")
KeychainHelper.delete(forKey: Constants.keychainPINHashKey)
KeychainHelper.delete(forKey: Constants.keychainPINLengthKey)
KeychainHelper.delete(forKey: Constants.keychainFailedAttemptsKey)
KeychainHelper.delete(forKey: Constants.keychainLockoutExpiryKey)
keychain.delete(forKey: Constants.keychainPINHashKey)
keychain.delete(forKey: Constants.keychainPINLengthKey)
keychain.delete(forKey: Constants.keychainFailedAttemptsKey)
keychain.delete(forKey: Constants.keychainLockoutExpiryKey)
failedAttempts = 0
lockoutExpiry = nil
hasPIN = false
@ -170,6 +172,15 @@ 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 {
@ -208,7 +219,7 @@ final class AppLockViewModel {
}
// Clear Keychain
KeychainHelper.deleteAll()
keychain.deleteAll()
// Reset BitcoinService
BitcoinService.shared.unloadWallet()
@ -245,13 +256,13 @@ final class AppLockViewModel {
}
private func loadPersistedState() {
if let data = KeychainHelper.load(forKey: Constants.keychainFailedAttemptsKey),
if let data = keychain.load(forKey: Constants.keychainFailedAttemptsKey),
let str = String(data: data, encoding: .utf8),
let count = Int(str)
{
failedAttempts = count
}
if let data = KeychainHelper.load(forKey: Constants.keychainLockoutExpiryKey),
if let data = keychain.load(forKey: Constants.keychainLockoutExpiryKey),
let str = String(data: data, encoding: .utf8),
let interval = Double(str)
{
@ -261,14 +272,14 @@ final class AppLockViewModel {
}
private func persistFailedAttempts() {
KeychainHelper.save(Data("\(failedAttempts)".utf8), forKey: Constants.keychainFailedAttemptsKey)
keychain.save(Data("\(failedAttempts)".utf8), forKey: Constants.keychainFailedAttemptsKey)
}
private func persistLockoutExpiry() {
if let expiry = lockoutExpiry {
KeychainHelper.save(Data("\(expiry.timeIntervalSince1970)".utf8), forKey: Constants.keychainLockoutExpiryKey)
keychain.save(Data("\(expiry.timeIntervalSince1970)".utf8), forKey: Constants.keychainLockoutExpiryKey)
} else {
KeychainHelper.delete(forKey: Constants.keychainLockoutExpiryKey)
keychain.delete(forKey: Constants.keychainLockoutExpiryKey)
}
}
}

View File

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

View File

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

View File

@ -193,13 +193,17 @@ final class SetupWizardViewModel {
func buildDescriptors() {
guard allCosignersComplete else { return }
// Build key origin strings and sort by xpub (BIP67 lexicographic sort)
// 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
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: cosignerXpubs[i],
xpub: normalized,
fingerprint: cosignerFingerprints[i],
path: cosignerDerivationPaths[i],
label: cosignerLabels[i],
@ -211,12 +215,10 @@ final class SetupWizardViewModel {
keyEntries.sort { $0.xpub < $1.xpub }
let externalKeys = keyEntries.map {
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/0/*"
"\($0.origin)\($0.xpub)/0/*"
}.joined(separator: ",")
let internalKeys = keyEntries.map {
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/1/*"
"\($0.origin)\($0.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 ?? "hellbender", category: "WalletManager")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", 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 ?? "hellbender", category: "BBQRDisplayView")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "BBQRDisplayView")
struct BBQRDisplayView: View {
let data: Data

View File

@ -78,17 +78,17 @@ struct HBTheme {
)
static let birchLight = HBTheme(
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),
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),
colorScheme: .light
)
}
@ -105,8 +105,8 @@ enum AppTheme: String, CaseIterable {
var displayName: String {
switch self {
case .system: "System"
case .dark: "Dark"
case .light: "Light"
case .dark: "Hellbender Dark"
case .light: "Hellbender 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 = .dark
private(set) var theme: HBTheme = .birchDark
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 ? .dark : .light
theme = colorScheme == .dark ? .birchDark : .birchLight
}
}
@ -288,6 +288,21 @@ 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 ?? "hellbender", category: "URScannerSheet")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "URScannerSheet")
struct URScannerSheet: View {
let onResult: (AppURResult) -> Void

View File

@ -265,7 +265,7 @@ struct ConnectionStatusView: View {
}
private func copyDebugInfo() {
var lines = ["=== Hellbender Debug Info ==="]
var lines = ["=== Birch 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 ?? "hellbender", category: "Navigation")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", 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 ?? "hellbender", category: "LabelService")
Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", 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 ?? "hellbender", category: "Settings")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "Settings")
struct SettingsView: View {
@Environment(\.modelContext) private var modelContext
@ -24,11 +24,6 @@ struct SettingsView: View {
// Security
AppLockSettingsSection()
// Appearance
Section("Appearance") {
AppearanceSettingsRow()
}
// Fee Estimation
Section("Fee Estimation") {
FeeSettingsRow()
@ -39,6 +34,16 @@ struct SettingsView: View {
FiatSettingsRow()
}
// Appearance
Section("Appearance") {
AppearanceSettingsRow()
}
// App Icon
Section("App Icon") {
AppIconSettingsRow()
}
// About
Section("About") {
HStack {
@ -92,6 +97,108 @@ 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 {
@ -159,6 +266,7 @@ private struct FiatSettingsRow: View {
}
}
.tint(Color.hbBitcoinOrange)
.accessibilityIdentifier("showFiatPriceToggle")
if fiatEnabled {
Picker("Price Source", selection: $fiatSourceRaw) {

View File

@ -2,7 +2,7 @@ import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "UTXODetail")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", 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 ?? "hellbender", category: "WalletInfo")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "WalletInfo")
struct WalletInfoView: View {
@Environment(\.modelContext) private var modelContext
@ -687,10 +687,9 @@ private struct EditCosignersView: View {
Text("Derivation Path")
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
TextField("m/48'/1'/0'/2'", text: $editableCosigners[index].derivationPath)
Text(editableCosigners[index].derivationPath.isEmpty ? "" : editableCosigners[index].derivationPath)
.font(.hbMono())
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color.hbSurfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 8))

View File

@ -2,7 +2,7 @@ import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "TransactionDetailView")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", 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 ?? "hellbender", category: "TransactionListView")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "TransactionListView")
struct TransactionListView: View {
@Query private var wallets: [WalletProfile]

View File

@ -66,10 +66,9 @@ struct CosignerImportView: View {
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
TextField("m/48'/1'/0'/2'", text: $viewModel.cosignerDerivationPaths[viewModel.currentCosignerIndex])
Text(viewModel.cosignerDerivationPaths[viewModel.currentCosignerIndex].isEmpty ? "" : viewModel.cosignerDerivationPaths[viewModel.currentCosignerIndex])
.font(.hbMono())
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color.hbSurfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 8))

View File

@ -75,7 +75,7 @@ struct WalletVerifyView: View {
.foregroundStyle(Color.hbTextPrimary)
}
Text("The output descriptor is your **only** recovery path. If you lose Hellbender (phone dies, app deleted, data corrupted), the descriptor is the only thing needed to rebuild the wallet in any compatible coordinator (Sparrow, Nunchuk, etc.). Without it, you'd need to re-gather all cosigner xpubs and reconstruct the exact same configuration — which may not be possible.")
Text("The output descriptor is your **only** recovery path. If you lose Birch (phone dies, app deleted, data corrupted), the descriptor is the only thing needed to rebuild the wallet in any compatible coordinator (Sparrow, Nunchuk, etc.). Without it, you'd need to re-gather all cosigner xpubs and reconstruct the exact same configuration — which may not be possible.")
.font(.hbBody(13))
.foregroundStyle(Color.hbTextSecondary)
@ -140,7 +140,7 @@ struct WalletVerifyView: View {
.font(.hbHeadline)
.foregroundStyle(Color.hbTextPrimary)
Text("Verifying your first receive address confirms that Hellbender built the correct output descriptor and will generate the same addresses as your cosigner devices. If the addresses don't match, funds sent to this wallet could be unspendable.")
Text("Verifying your first receive address confirms that Birch built the correct output descriptor and will generate the same addresses as your cosigner devices. If the addresses don't match, funds sent to this wallet could be unspendable.")
.font(.hbBody(13))
.foregroundStyle(Color.hbTextSecondary)

View File

@ -8,8 +8,7 @@ struct WelcomeStepView: View {
Spacer()
// Icon
Image("WelcomeIcon")
.resizable()
ThemedAppIcon()
.aspectRatio(contentMode: .fit)
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
@ -19,13 +18,9 @@ struct WelcomeStepView: View {
.blur(radius: 12)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
)
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.strokeBorder(Color.hbBorder.opacity(0.5), lineWidth: 1)
)
VStack(spacing: 12) {
Text("Hellbender Wallet")
Text("Birch Wallet")
.font(.hbDisplay(34))
.foregroundStyle(Color.hbTextPrimary)

View File

@ -2,7 +2,7 @@ import SwiftData
import SwiftUI
@main
struct hellbenderApp: App {
struct birchApp: App {
let modelContainer: ModelContainer
init() {

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