Compare commits

...

17 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
Nick Klockenga
d4a30a9f82
bump build version to 24 (#23)
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-09 23:05:52 -04:00
Nick Klockenga
86f6a74353
Add MP4 video export of animated descriptor QR codes, replacing the Copy Descriptor button in the QR sheet with an Export Descriptor as MP4 button that generates and shares the video. Add BIP-380 descriptor checksums to all text representations, standardize QR correction level to Low across the app, and add unit tests for checksum correctness and QR encoding invariance. (#22) 2026-04-09 23:05:35 -04:00
Nick Klockenga
a6ea6f47c1
add dark and light birch themes (#21) 2026-04-09 22:31:11 -04:00
Nick Klockenga
528c5690af
minor UTXO detail screen additions (#20) 2026-04-09 22:30:58 -04:00
Nick Klockenga
c48e0a79cc
fixes truncating the receive address on smaller screens (#18) 2026-04-08 20:54:07 -04:00
Nick Klockenga
3c3478b7e8
Feature add reproducible build steps (#19)
* reproducible build first step

* update xcode version for CI runner

* update README.md
2026-04-08 20:53:52 -04:00
Nick Klockenga
50f870faa4
Feature to format bitcoin addresses in 4 character chunks (#17)
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
SwiftFormat Check / Check code formatting with SwiftFormat (push) Has been cancelled
* format bitcoin addresses in 4 character chunks for increased readability

* add formating to verify wallet screen
2026-04-06 22:44:31 -04:00
Nick Klockenga
55404bd848
max height on wallet picker with scrolling (#16) 2026-04-06 22:34:50 -04:00
Nick Klockenga
33a04e91c0
Add address/wallet verification step to create new wallet wizard. (#15) 2026-04-06 22:34:21 -04:00
141 changed files with 4484 additions and 792 deletions

View File

@ -14,11 +14,28 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Pin Xcode version
run: |
XCODE_VERSION=$(cat .xcode-version)
sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer"
xcodebuild -version
- name: Resolve packages
run: xcodebuild -resolvePackageDependencies -project birch.xcodeproj -scheme birch
- name: Validate Package.resolved
run: |
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 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

@ -0,0 +1,63 @@
name: Reproducible Build Check
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
reproducibility:
name: Verify build reproducibility
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Pin Xcode version
run: |
XCODE_VERSION=$(cat .xcode-version)
sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer"
xcodebuild -version
- name: Build 1
run: |
DERIVED_DATA="/tmp/birch-build-1"
rm -rf "$DERIVED_DATA"
xcodebuild archive \
-scheme birch \
-project birch.xcodeproj \
-archivePath "$DERIVED_DATA/birch.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-configuration Release \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGN_IDENTITY="" \
| xcpretty && exit ${PIPESTATUS[0]}
- name: Build 2
run: |
DERIVED_DATA="/tmp/birch-build-2"
rm -rf "$DERIVED_DATA"
xcodebuild archive \
-scheme birch \
-project birch.xcodeproj \
-archivePath "$DERIVED_DATA/birch.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-configuration Release \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGN_IDENTITY="" \
| xcpretty && exit ${PIPESTATUS[0]}
- 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"
./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"
./scripts/compare-builds.sh "$APP1" "$APP2"

10
.gitignore vendored
View File

@ -33,3 +33,13 @@ Pods/
# Xcode temporary build files
build/
DerivedData/
fastlane/report.xml
fastlane/Preview.html
fastlane/test_output/
fastlane/README.md
fastlane/screenshots/
# Bundler (Gemfile.lock is OK to commit; uncomment next line to ignore vendor dir if added later)
vendor/bundle/
.bundle/

1
.xcode-version Normal file
View File

@ -0,0 +1 @@
26.4

61
Config/Base.xcconfig Normal file
View File

@ -0,0 +1,61 @@
// Base.xcconfig — Shared build settings for all configurations
ALWAYS_SEARCH_USER_PATHS = NO
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES
// Static analysis
CLANG_ANALYZER_NONNULL = YES
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE
// Language standards
CLANG_CXX_LANGUAGE_STANDARD = gnu++20
GCC_C_LANGUAGE_STANDARD = gnu17
// Modules and ARC
CLANG_ENABLE_MODULES = YES
CLANG_ENABLE_OBJC_ARC = YES
CLANG_ENABLE_OBJC_WEAK = YES
// Warnings — Clang
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES
CLANG_WARN_BOOL_CONVERSION = YES
CLANG_WARN_COMMA = YES
CLANG_WARN_CONSTANT_CONVERSION = YES
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR
CLANG_WARN_DOCUMENTATION_COMMENTS = YES
CLANG_WARN_EMPTY_BODY = YES
CLANG_WARN_ENUM_CONVERSION = YES
CLANG_WARN_INFINITE_RECURSION = YES
CLANG_WARN_INT_CONVERSION = YES
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES
CLANG_WARN_STRICT_PROTOTYPES = YES
CLANG_WARN_SUSPICIOUS_MOVE = YES
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE
CLANG_WARN_UNREACHABLE_CODE = YES
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES
// Warnings — GCC
GCC_NO_COMMON_BLOCKS = YES
GCC_WARN_64_TO_32_BIT_CONVERSION = YES
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR
GCC_WARN_UNDECLARED_SELECTOR = YES
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE
GCC_WARN_UNUSED_FUNCTION = YES
GCC_WARN_UNUSED_VARIABLE = YES
// Build settings
COPY_PHASE_STRIP = NO
DEVELOPMENT_TEAM = ZW85AH743B
ENABLE_STRICT_OBJC_MSGSEND = YES
ENABLE_USER_SCRIPT_SANDBOXING = YES
IPHONEOS_DEPLOYMENT_TARGET = 26.0
LOCALIZATION_PREFERS_STRING_CATALOGS = YES
MTL_FAST_MATH = YES
SDKROOT = iphoneos
STRING_CATALOG_GENERATE_SYMBOLS = YES

19
Config/Birch.xcconfig Normal file
View File

@ -0,0 +1,19 @@
// 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 = "birch/Preview Content"
GENERATE_INFOPLIST_FILE = NO
INFOPLIST_FILE = birch/Info.plist
IPHONEOS_DEPLOYMENT_TARGET = 18.6
LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks
MARKETING_VERSION = 0.1.2
PRODUCT_BUNDLE_IDENTIFIER = com.klockenga.Hellbender
PRODUCT_NAME = $(TARGET_NAME)
SWIFT_EMIT_LOC_STRINGS = YES
SWIFT_VERSION = 5.0
TARGETED_DEVICE_FAMILY = 1,2

13
Config/Debug.xcconfig Normal file
View File

@ -0,0 +1,13 @@
// Debug.xcconfig — Debug-specific build settings
#include "Base.xcconfig"
DEBUG_INFORMATION_FORMAT = dwarf
ENABLE_PREVIEWS = YES
ENABLE_TESTABILITY = YES
GCC_DYNAMIC_NO_PIC = NO
GCC_OPTIMIZATION_LEVEL = 0
GCC_PREPROCESSOR_DEFINITIONS = DEBUG=1 $(inherited)
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE
ONLY_ACTIVE_ARCH = YES
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG $(inherited)
SWIFT_OPTIMIZATION_LEVEL = -Onone

13
Config/Release.xcconfig Normal file
View File

@ -0,0 +1,13 @@
// Release.xcconfig — Release-specific build settings + reproducibility
#include "Base.xcconfig"
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym
ENABLE_NS_ASSERTIONS = NO
ENABLE_PREVIEWS = NO
MTL_ENABLE_DEBUG_INFO = NO
SWIFT_COMPILATION_MODE = wholemodule
VALIDATE_PRODUCT = YES
// Reproducibility: remap absolute paths in debug info
OTHER_SWIFT_FLAGS = $(inherited) -debug-prefix-map $(SRCROOT)=. -debug-prefix-map $(HOME)=~
OTHER_CFLAGS = $(inherited) -fdebug-prefix-map=$(SRCROOT)=. -fdebug-prefix-map=$(HOME)=~

3
Gemfile Normal file
View File

@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"

338
Gemfile.lock Normal file
View File

@ -0,0 +1,338 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1237.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.219.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.1.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.232.2)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.98.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.3)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.20.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.5)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x86_64-darwin-24
DEPENDENCIES
fastlane
CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1237.0) sha256=9b82f529b69ad83a8e4c5e123038924ed5e8f59bd6064a293ef20efc63364841
aws-sdk-core (3.244.0) sha256=3e458c078b0c5bdee95bc370c3a483374b3224cf730c1f9f0faf849a5d9a18ea
aws-sdk-kms (1.123.0) sha256=d405f37e82f8fa32045ca8980be266c0b45b37aaf2012afe0254321a1e811f20
aws-sdk-s3 (1.219.0) sha256=6a755d7377978525758b3c29185ca6a10128ce2b07555ca37c4549de10c2f1c7
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.1.1) sha256=1c09efab961da45203c8316b0cdaec0ff391dfadb952dd459584b63ebf8054ca
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0
faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
fastlane (2.232.2) sha256=978689f60f0fc3d54699de86ef12be4eda9f5b52217c1798965257c390d2b112
fastlane-sirp (1.0.0) sha256=66478f25bcd039ec02ccf65625373fca29646fa73d655eb533c915f106c5e641
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
google-apis-androidpublisher_v3 (0.98.0) sha256=094fb952419c1131c16c4dfa66e0c96e6a2fa33adbe266f614b84b22cbc8c5cb
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
google-apis-iamcredentials_v1 (0.26.0) sha256=3ff70a10a1d6cddf2554e95b7c5df2c26afdeaeb64100048a355194da19e48a3
google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78
google-apis-storage_v1 (0.61.0) sha256=b330e599b58e6a01533c189525398d6dbdbaf101ffb0c60145940b57e1c982e8
google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf
google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999
google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1
google-cloud-storage (1.59.0) sha256=b8c9a5661d775d65ccb279bb1d6be07fd8152576eb0146c2026bd023c4b186b9
googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
multi_json (1.20.0) sha256=c64106fae5114bd7f388d42d7b52ebb83d7726426d47a35ad5099e35bb923e41
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
nkf (0.2.0) sha256=fbc151bda025451f627fafdfcb3f4f13d0b22ae11f58c6d3a2939c76c5f5f126
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
signet (0.21.0) sha256=d617e9fbf24928280d39dcfefba9a0372d1c38187ffffd0a9283957a10a8cd5b
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
sysrandom (1.0.5) sha256=5ac1ac3c2ec64ef76ac91018059f541b7e8f437fbda1ccddb4f2c56a9ccf1e75
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
xcodeproj (1.27.0) sha256=8cc7a73b4505c227deab044dce118ede787041c702bc47636856a2e566f854d3
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
BUNDLED WITH
4.0.10

159
README.md
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
@ -38,7 +43,7 @@ Hellbender is an iOS Bitcoin multisig coordinator written in Swift. It operates
### Requirements
- Xcode 16.2+
- Xcode 26.2+
- iOS 18.6+ deployment target
- Swift 5.0
@ -60,17 +65,145 @@ 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
For reproducible release builds, see [Reproducible Builds](#reproducible-builds) below.
### CI
GitHub Actions runs `xcodebuild clean build analyze` on every push and pull request to `main`.
GitHub Actions runs `xcodebuild clean build analyze` on every push and pull request to `main`. A separate [reproducibility verification workflow](.github/workflows/reproducible-build-check.yml) builds the project twice, normalizes both outputs, and compares them to catch non-determinism regressions.
### 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.
**What IS reproducible** (after normalization): all code-bearing sections, resources, and application logic.
**What is NOT reproducible**: code signing timestamps, Mach-O LC_UUID values, Xcode build-machine metadata, App Store .ipa files (Apple re-signs and applies FairPlay DRM).
#### Prerequisites
- Exact Xcode version matching `.xcode-version` (currently 26.4)
- macOS with the matching SDK
#### Producing a verifiable build
```bash
./scripts/build-release.sh
```
This creates an unsigned archive at `/tmp/birch-build/birch.xcarchive`.
#### Verifying two builds
```bash
# Normalize both builds
./scripts/normalize-app.sh /path/to/build1.app
./scripts/normalize-app.sh /path/to/build2.app
# Compare
./scripts/compare-builds.sh /path/to/build1.app /path/to/build2.app
```
The comparison exits 0 if the builds are functionally equivalent, 1 if code differences are found.
## 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.
### One-time setup
1. Install [Bundler](https://bundler.io/) if you don't already have it:
```bash
gem install bundler
```
2. Install fastlane via the project `Gemfile`:
```bash
bundle install
```
3. Install [ImageMagick](https://imagemagick.org/) — used to composite iPhone 13 mini screenshots onto their bezel (frameit's bundled 13 mini frame has a pixel-misalignment bug that leaves a visible gap, so we bypass frameit for that device):
```bash
brew install imagemagick
```
4. Patch the installed fastlane gem with iPhone 16/17 device support (from
[fastlane PR #29921](https://github.com/fastlane/fastlane/pull/29921)):
```bash
bundle exec ruby scripts/patch-frameit.rb
```
This is idempotent — safe to re-run. If you upgrade fastlane, the script
aborts with a clear error so you can review the patch for the new version.
On a fresh machine, also download the device frame PNGs before running
the patch (they live at `~/.fastlane/frameit/latest/` and are not in the repo):
```bash
bundle exec fastlane frameit download_frames
```
5. Make sure the required simulators are downloaded. The screenshot lane targets:
- **iPhone 17 Pro Max** (6.9")
- **iPhone 17 Pro** (6.3")
- **iPhone 11 Pro Max** (6.5")
- **iPhone 13 mini** (5.4")
You can trigger a download by booting them once in Xcode (**Window → Devices and Simulators → Simulators → +**) or via the command line:
```bash
xcrun simctl list devices | grep -E "iPhone 17 Pro Max|iPhone 17 Pro|iPhone 11 Pro Max|iPhone 13 mini"
```
### Running
From the repo root:
```bash
bundle exec fastlane screenshots
```
This runs the `screenshots` lane defined in [`fastlane/Fastfile`](fastlane/Fastfile), which:
1. Captures all 23 stops in **dark mode** first (the product's default aesthetic)
2. Captures the same stops in **light mode**
3. Moves iPhone 13 mini bare captures aside (frameit's bundled 13 mini frame has a pixel-misalignment bug)
4. Runs `frameit` to composite device bezels onto iPhone 17 Pro Max, iPhone 17 Pro, and iPhone 11 Pro Max screenshots
5. Moves every `*_framed.png` into a sibling `framed/` subfolder
6. Composites iPhone 13 mini captures onto the bezel directly with ImageMagick (upscaling to 1086×2353 so the screenshot fully covers the frame's screen hole) and writes the result into the same `framed/` directory
Output lands in:
```
fastlane/screenshots/
├── dark/
│ ├── en-US/
│ │ ├── iPhone 17 Pro Max-01-Welcome.png
│ │ ├── iPhone 17 Pro-01-Welcome.png
│ │ └── ...
│ └── framed/
│ ├── iPhone 17 Pro Max-01-Welcome_framed.png
│ ├── iPhone 17 Pro-01-Welcome_framed.png
│ └── ...
└── light/
└── ...
```
### 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.
- 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).
### 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.
- **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/)
@ -78,5 +211,5 @@ GitHub Actions runs `xcodebuild clean build analyze` on every push and pull requ
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,37 +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 /* Birch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Birch.xcconfig; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
CC0000010000000000000006 /* Exceptions for "birch" folder in "birch" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 3C9ACE232F5DED94009B00D0 /* birch */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
3C9ACE262F5DED94009B00D0 /* hellbender */ = {
3C9ACE262F5DED94009B00D0 /* birch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = hellbender;
exceptions = (
CC0000010000000000000006 /* Exceptions for "birch" folder in "birch" target */,
);
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 */
@ -88,9 +105,10 @@
3C9ACE1B2F5DED94009B00D0 = {
isa = PBXGroup;
children = (
3C9ACE262F5DED94009B00D0 /* hellbender */,
3C9ACE372F5DED95009B00D0 /* hellbenderTests */,
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */,
CC0000010000000000000005 /* Config */,
3C9ACE262F5DED94009B00D0 /* birch */,
3C9ACE372F5DED95009B00D0 /* birchTests */,
3C9ACE412F5DED95009B00D0 /* birchUITests */,
3C9ACE252F5DED94009B00D0 /* Products */,
);
sourceTree = "<group>";
@ -98,19 +116,30 @@
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>";
};
CC0000010000000000000005 /* Config */ = {
isa = PBXGroup;
children = (
CC0000010000000000000001 /* Base.xcconfig */,
CC0000010000000000000002 /* Debug.xcconfig */,
CC0000010000000000000004 /* Birch.xcconfig */,
CC0000010000000000000003 /* Release.xcconfig */,
);
path = Config;
sourceTree = "<group>";
};
/* 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 */,
@ -121,9 +150,9 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
3C9ACE262F5DED94009B00D0 /* hellbender */,
3C9ACE262F5DED94009B00D0 /* birch */,
);
name = hellbender;
name = birch;
packageProductDependencies = (
AA00000500000000000000D0 /* URKit */,
AA00000800000000000000D0 /* URUI */,
@ -131,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 */,
@ -149,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 */,
@ -172,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 */
@ -204,7 +233,7 @@
};
};
};
buildConfigurationList = 3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "hellbender" */;
buildConfigurationList = 3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "birch" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
@ -224,9 +253,9 @@
projectDirPath = "";
projectRoot = "";
targets = (
3C9ACE232F5DED94009B00D0 /* hellbender */,
3C9ACE332F5DED95009B00D0 /* hellbenderTests */,
3C9ACE3D2F5DED95009B00D0 /* hellbenderUITests */,
3C9ACE232F5DED94009B00D0 /* birch */,
3C9ACE332F5DED95009B00D0 /* birchTests */,
3C9ACE3D2F5DED95009B00D0 /* birchUITests */,
);
};
/* End PBXProject section */
@ -282,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 */
@ -295,190 +324,35 @@
/* Begin XCBuildConfiguration section */
3C9ACE462F5DED95009B00D0 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000002 /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = ZW85AH743B;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
3C9ACE472F5DED95009B00D0 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000003 /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = ZW85AH743B;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
3C9ACE492F5DED95009B00D0 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_ASSET_PATHS = "\"hellbender/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Hellbender;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSCameraUsageDescription = "Hellbender needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets.";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Hellbender uses Face ID to securely unlock your wallet.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.2;
PRODUCT_BUNDLE_IDENTIFIER = com.klockenga.Hellbender;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
CURRENT_PROJECT_VERSION = 26;
MARKETING_VERSION = 0.2.0;
};
name = Debug;
};
3C9ACE4A2F5DED95009B00D0 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_ASSET_PATHS = "\"hellbender/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Hellbender;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSCameraUsageDescription = "Hellbender needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets.";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Hellbender uses Face ID to securely unlock your wallet.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.2;
PRODUCT_BUNDLE_IDENTIFIER = com.klockenga.Hellbender;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
CURRENT_PROJECT_VERSION = 26;
MARKETING_VERSION = 0.2.0;
};
name = Release;
};
@ -496,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;
};
@ -514,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;
};
@ -530,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;
};
@ -546,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 */,
@ -562,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 */,
@ -571,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 */,
@ -580,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

@ -1,4 +1,5 @@
import Foundation
import SwiftUI
// MARK: - Denomination
@ -57,6 +58,29 @@ extension String {
guard count > leading + trailing + 3 else { return self }
return "\(prefix(leading))...\(suffix(trailing))"
}
/// Build a styled Text view with space-separated 4-character chunks,
/// alternating between primary and secondary text colors.
func chunkedAddressText(font: Font = .hbMono(13)) -> Text {
var chunks: [String] = []
var current = ""
for (i, char) in enumerated() {
if i > 0, i % 4 == 0 {
chunks.append(current)
current = ""
}
current.append(char)
}
if !current.isEmpty { chunks.append(current) }
var result = Text("")
for (i, chunk) in chunks.enumerated() {
if i > 0 { result = result + Text(" ") }
let color: Color = i % 2 == 0 ? .hbTextPrimary : .hbTextSecondary
result = result + Text(chunk).font(font).foregroundColor(color)
}
return result
}
}
extension Date {

92
birch/Info.plist Normal file
View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<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>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<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>
<key>NSFaceIDUsageDescription</key>
<string>Birch uses Face ID to securely unlock your wallet.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
</dict>
</plist>

View File

@ -6,6 +6,7 @@ struct UTXOItem: Identifiable, Equatable {
let amount: UInt64 // sats
let isConfirmed: Bool
let keychain: KeychainKind
let derivationIndex: UInt32
var id: String {
"\(txid):\(vout)"

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
@ -690,7 +690,8 @@ final class BitcoinService {
vout: output.outpoint.vout,
amount: output.txout.value.toSat(),
isConfirmed: confirmed,
keychain: output.keychain == .external ? .external : .internal
keychain: output.keychain == .external ? .external : .internal,
derivationIndex: output.derivationIndex
)
}.sorted { u0, u1 in
let isUnconfirmed0 = !u0.isConfirmed
@ -1332,32 +1333,95 @@ 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)))"
}
/// Build a combined output descriptor with <0;1>/* multipath notation
/// Build a combined output descriptor with <0;1>/* multipath notation and BIP-380 checksum
static func buildCombinedDescriptor(
requiredSignatures: Int,
cosigners: [(xpub: String, fingerprint: String, derivationPath: String)],
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: ",")
return "wsh(sortedmulti(\(requiredSignatures),\(keys)))"
let raw = "wsh(sortedmulti(\(requiredSignatures),\(keys)))"
return raw + "#" + descriptorChecksum(raw)
}
/// Compute the BIP-380 descriptor checksum (8-character string)
/// Reference: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp
static func descriptorChecksum(_ descriptor: String) -> String {
let inputCharset = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
let checksumCharset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
var c: UInt64 = 1
var cls = 0
var clsCount = 0
func polyMod(_ c: inout UInt64, _ val: Int) {
let c0 = Int(c >> 35)
c = ((c & 0x7_FFFF_FFFF) << 5) ^ UInt64(val)
if c0 & 1 != 0 { c ^= 0xF5_DEE5_1989 }
if c0 & 2 != 0 { c ^= 0xA9_FDCA_3312 }
if c0 & 4 != 0 { c ^= 0x1B_AB10_E32D }
if c0 & 8 != 0 { c ^= 0x37_06B1_677A }
if c0 & 16 != 0 { c ^= 0x64_4D62_6FFD }
}
for ch in descriptor {
guard let pos = inputCharset.firstIndex(of: ch) else {
return ""
}
let idx = inputCharset.distance(from: inputCharset.startIndex, to: pos)
polyMod(&c, idx & 31)
cls = cls * 3 + (idx >> 5)
clsCount += 1
if clsCount == 3 {
polyMod(&c, cls)
cls = 0
clsCount = 0
}
}
if clsCount > 0 { polyMod(&c, cls) }
(0 ..< 8).forEach { _ in polyMod(&c, 0) }
c ^= 1
let checksumArray = Array(checksumCharset)
var result = ""
for j in 0 ..< 8 {
result.append(checksumArray[Int((c >> (5 * (7 - j))) & 31)])
}
return result
}
// MARK: - Helpers

View File

@ -126,7 +126,7 @@ enum DescriptorPDFGenerator {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
filter.correctionLevel = "M"
filter.correctionLevel = "L"
guard let outputImage = filter.outputImage else { return nil }

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

@ -0,0 +1,176 @@
import AVFoundation
import CoreImage.CIFilterBuiltins
import UIKit
import URKit
enum QRVideoExporter {
enum ExportError: Error {
case qrGenerationFailed
case writerSetupFailed(String)
case writingFailed(String)
}
static func exportMP4(
ur: UR,
fileName: String = "Descriptor",
maxFragmentLen: Int = 160,
fps: Double = 4.0,
loopCount: Int = 3,
qrSize: Int = 800
) async throws -> URL {
// Step 1: Generate UR part strings
let encoder = UREncoder(ur, maxFragmentLen: maxFragmentLen)
var partStrings: [String] = []
if encoder.isSinglePart {
partStrings.append(encoder.nextPart().uppercased())
} else {
let count = encoder.seqLen
for _ in 0 ..< count {
partStrings.append(encoder.nextPart().uppercased())
}
}
// Step 2: Generate QR images
let context = CIContext()
let qrImages: [UIImage] = try partStrings.map { part in
guard let image = generateQRImage(from: part, context: context, canvasSize: qrSize) else {
throw ExportError.qrGenerationFailed
}
return image
}
// Step 3: Write MP4
let sanitizedName = fileName.replacingOccurrences(of: "/", with: "_")
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("\(sanitizedName).mp4")
try? FileManager.default.removeItem(at: outputURL)
guard let writer = try? AVAssetWriter(outputURL: outputURL, fileType: .mp4) else {
throw ExportError.writerSetupFailed("Failed to create AVAssetWriter")
}
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: qrSize,
AVVideoHeightKey: qrSize,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: 2_000_000,
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
],
]
let input = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
input.expectsMediaDataInRealTime = false
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: input,
sourcePixelBufferAttributes: [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB,
kCVPixelBufferWidthKey as String: qrSize,
kCVPixelBufferHeightKey as String: qrSize,
]
)
writer.add(input)
guard writer.startWriting() else {
throw ExportError.writerSetupFailed(writer.error?.localizedDescription ?? "Unknown error")
}
writer.startSession(atSourceTime: .zero)
let timescale: CMTimeScale = 600
let frameDuration = CMTime(value: CMTimeValue(Double(timescale) / fps), timescale: timescale)
let totalFrames = qrImages.count * loopCount
for frameIndex in 0 ..< totalFrames {
let image = qrImages[frameIndex % qrImages.count]
let presentationTime = CMTimeMultiply(frameDuration, multiplier: Int32(frameIndex))
while !input.isReadyForMoreMediaData {
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
}
guard let pool = adaptor.pixelBufferPool,
let pixelBuffer = pixelBuffer(from: image, width: qrSize, height: qrSize, pool: pool)
else {
throw ExportError.writingFailed("Failed to create pixel buffer for frame \(frameIndex)")
}
adaptor.append(pixelBuffer, withPresentationTime: presentationTime)
}
input.markAsFinished()
await writer.finishWriting()
if writer.status == .failed {
throw ExportError.writingFailed(writer.error?.localizedDescription ?? "Unknown error")
}
return outputURL
}
// MARK: - Private
private static func generateQRImage(from string: String, context: CIContext, canvasSize: Int) -> UIImage? {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
filter.correctionLevel = "L"
guard let ciImage = filter.outputImage else { return nil }
// Scale QR to fit within canvas with padding
let padding: CGFloat = 40
let availableSize = CGFloat(canvasSize) - padding * 2
let scale = availableSize / ciImage.extent.width
let scaledImage = ciImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil }
// Center on white canvas
let renderer = UIGraphicsImageRenderer(size: CGSize(width: canvasSize, height: canvasSize))
return renderer.image { ctx in
UIColor.white.setFill()
ctx.fill(CGRect(x: 0, y: 0, width: canvasSize, height: canvasSize))
let qrImage = UIImage(cgImage: cgImage)
let x = (CGFloat(canvasSize) - scaledImage.extent.width) / 2
let y = (CGFloat(canvasSize) - scaledImage.extent.height) / 2
qrImage.draw(in: CGRect(x: x, y: y, width: scaledImage.extent.width, height: scaledImage.extent.height))
}
}
private static func pixelBuffer(from image: UIImage, width: Int, height: Int, pool: CVPixelBufferPool) -> CVPixelBuffer? {
var pixelBuffer: CVPixelBuffer?
CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixelBuffer)
guard let buffer = pixelBuffer else { return nil }
CVPixelBufferLockBaseAddress(buffer, [])
defer { CVPixelBufferUnlockBaseAddress(buffer, []) }
guard let baseAddress = CVPixelBufferGetBaseAddress(buffer) else { return nil }
let bytesPerRow = CVPixelBufferGetBytesPerRow(buffer)
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let cgContext = CGContext(
data: baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
) else { return nil }
guard let cgImage = image.cgImage else { return nil }
cgContext.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
return buffer
}
}

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

@ -14,6 +14,7 @@ final class SetupWizardViewModel {
case descriptorImport
case walletName
case review
case verify
}
enum CreationMode {
@ -46,6 +47,10 @@ final class SetupWizardViewModel {
var externalDescriptor: String = ""
var internalDescriptor: String = ""
// Verification step
var firstReceiveAddress: String = ""
var addressDerivationError: String?
// Electrum server
var electrumHost: String = ""
var electrumPort: String = ""
@ -91,7 +96,7 @@ final class SetupWizardViewModel {
/// Progress
var stepCount: Int {
creationMode == .createNew ? 5 : 3
creationMode == .createNew ? 6 : 3
}
var currentStepIndex: Int {
@ -103,6 +108,7 @@ final class SetupWizardViewModel {
case .descriptorImport: 2
case .walletName: creationMode == .createNew ? 4 : 3
case .review: stepCount - 1
case .verify: stepCount - 1
}
}
@ -187,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],
@ -205,18 +215,48 @@ 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)))"
internalDescriptor = "wsh(sortedmulti(\(requiredSignatures),\(internalKeys)))"
}
var combinedDescriptor: String {
let cosignerData = (0 ..< totalCosigners).map {
(xpub: cosignerXpubs[$0], fingerprint: cosignerFingerprints[$0], derivationPath: cosignerDerivationPaths[$0])
}
return BitcoinService.buildCombinedDescriptor(
requiredSignatures: requiredSignatures,
cosigners: cosignerData,
network: network
)
}
func deriveFirstAddress() {
let bdkNetwork = BitcoinService.shared.bdkNetwork(from: network)
do {
let extDesc = try Descriptor(descriptor: externalDescriptor, network: bdkNetwork)
let chgDesc = try Descriptor(descriptor: internalDescriptor, network: bdkNetwork)
let persister = try Persister.newInMemory()
let tempWallet = try Wallet(
descriptor: extDesc,
changeDescriptor: chgDesc,
network: bdkNetwork,
persister: persister
)
let info = tempWallet.peekAddress(keychain: .external, index: 0)
firstReceiveAddress = info.address.description
addressDerivationError = nil
} catch {
addressDerivationError = "Failed to derive address: \(error.localizedDescription)"
firstReceiveAddress = ""
}
}
func parseImportedDescriptor() -> Bool {
// If the input is a JSON object (e.g. Specter Desktop export), extract the descriptor field
if let descriptor = URService.extractDescriptorFromJSON(importedDescriptorText) {
@ -392,8 +432,11 @@ final class SetupWizardViewModel {
}
currentStep = .walletName
case .walletName:
currentStep = .review
deriveFirstAddress()
currentStep = .verify
case .review:
break // unused in current flow
case .verify:
break // handled by saveWallet
}
}
@ -411,6 +454,7 @@ final class SetupWizardViewModel {
}
// Import flow: back button is hidden, wallet already created
case .review: currentStep = .walletName
case .verify: currentStep = .walletName
}
}

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

@ -14,6 +14,7 @@ struct HBTheme {
var heroBackground: Color
var success: Color
var error: Color
var secondaryAccent: Color
var colorScheme: ColorScheme? = .dark
static let system = HBTheme(
@ -27,6 +28,7 @@ struct HBTheme {
heroBackground: Color(.tertiarySystemBackground),
success: Color(.systemGreen),
error: Color(.systemRed),
secondaryAccent: Color(.systemBlue),
colorScheme: nil
)
@ -41,6 +43,7 @@ struct HBTheme {
heroBackground: Color(red: 0.110, green: 0.110, blue: 0.141),
success: Color(red: 0.176, green: 0.545, blue: 0.341),
error: Color(red: 0.851, green: 0.267, blue: 0.267),
secondaryAccent: Color(red: 0.290, green: 0.565, blue: 0.851),
colorScheme: .dark
)
@ -55,6 +58,37 @@ struct HBTheme {
heroBackground: Color(red: 0.930, green: 0.930, blue: 0.945),
success: Color(red: 0.204, green: 0.780, blue: 0.349),
error: Color(red: 1.000, green: 0.231, blue: 0.188),
secondaryAccent: Color(red: 0.290, green: 0.565, blue: 0.851),
colorScheme: .light
)
static let birchDark = HBTheme(
background: Color(red: 0.102, green: 0.094, blue: 0.078),
surface: Color(red: 0.141, green: 0.125, blue: 0.094),
surfaceElevated: Color(red: 0.180, green: 0.157, blue: 0.125),
border: Color(red: 0.239, green: 0.208, blue: 0.188),
textPrimary: Color(red: 0.929, green: 0.910, blue: 0.875),
textSecondary: Color(red: 0.659, green: 0.620, blue: 0.573),
accent: Color(red: 0.831, green: 0.659, blue: 0.208),
heroBackground: Color(red: 0.141, green: 0.125, blue: 0.094),
success: Color(red: 0.416, green: 0.478, blue: 0.322),
error: Color(red: 0.690, green: 0.235, blue: 0.157),
secondaryAccent: Color(red: 0.416, green: 0.478, blue: 0.322),
colorScheme: .dark
)
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),
colorScheme: .light
)
}
@ -65,12 +99,16 @@ enum AppTheme: String, CaseIterable {
case system
case dark
case light
case birchDark
case birchLight
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"
}
}
@ -79,6 +117,8 @@ enum AppTheme: String, CaseIterable {
case .system: .system
case .dark: .dark
case .light: .light
case .birchDark: .birchDark
case .birchLight: .birchLight
}
}
}
@ -88,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
@ -103,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
}
}
@ -137,7 +177,9 @@ extension Color {
ThemeManager.shared.theme.accent
}
static let hbSteelBlue = Color(red: 0.290, green: 0.565, blue: 0.851) // #4A90D9 not themed
static var hbSteelBlue: Color {
ThemeManager.shared.theme.secondaryAccent
}
/// Semantic
static var hbSuccess: Color {
@ -246,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

@ -26,9 +26,7 @@ struct AddressDetailView: View {
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
Text(address)
.font(.hbMono(13))
.foregroundStyle(Color.hbTextPrimary)
address.chunkedAddressText()
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.textSelection(.enabled)

View File

@ -37,10 +37,9 @@ struct ReceiveView: View {
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
Text(viewModel.currentAddress)
.font(.hbMono(13))
.foregroundStyle(Color.hbTextPrimary)
viewModel.currentAddress.chunkedAddressText()
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 32)
.textSelection(.enabled)

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

@ -338,17 +338,11 @@ private struct PSBTReviewCard: View {
ForEach(Array(viewModel.recipients.enumerated()), id: \.element.id) { index, recipient in
if viewModel.recipients.count > 1 {
ReviewItem(label: "Recipient \(index + 1)") {
Text(recipient.address)
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
.lineLimit(2)
recipient.address.chunkedAddressText(font: .hbMono(12))
}
} else {
ReviewItem(label: "To") {
Text(recipient.address)
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
.lineLimit(2)
recipient.address.chunkedAddressText(font: .hbMono(12))
}
}

View File

@ -24,17 +24,11 @@ struct SendReviewView: View {
ForEach(Array(viewModel.recipients.enumerated()), id: \.element.id) { index, recipient in
if viewModel.recipients.count > 1 {
ReviewItem(label: "Recipient \(index + 1)") {
Text(recipient.address)
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
.lineLimit(2)
recipient.address.chunkedAddressText(font: .hbMono(12))
}
} else {
ReviewItem(label: "To") {
Text(recipient.address)
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
.lineLimit(2)
recipient.address.chunkedAddressText(font: .hbMono(12))
}
}

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
@ -110,9 +110,7 @@ struct UTXODetailView: View {
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
} else {
Text(address)
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
address.chunkedAddressText(font: .hbMono(12))
.textSelection(.enabled)
}
}
@ -156,6 +154,11 @@ struct UTXODetailView: View {
DetailRow(label: "Amount", value: isPrivate ? Constants.privacyText() : utxo.amount.formattedSats)
DetailRow(
label: utxo.keychain == .external ? "Receive Address Index" : "Change Address Index",
value: "\(utxo.derivationIndex)"
)
DetailRow(label: "Output Index", value: "\(utxo.vout)")
DetailRow(label: "Type", value: utxo.keychain == .external ? "Receive" : "Change")
@ -172,7 +175,7 @@ struct UTXODetailView: View {
}
DetailRow(label: "Confirmations",
value: tx.confirmations >= 6 ? "6+" : "\(tx.confirmations)")
value: "\(tx.confirmations)")
}
}
.hbCard()

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
@ -471,7 +471,7 @@ struct WalletInfoView: View {
EditCosignersView(wallet: wallet)
}
.sheet(isPresented: $showDescriptorQR) {
DescriptorQRSheet(descriptor: combinedDescriptor)
DescriptorQRSheet(descriptor: combinedDescriptor, walletName: wallet.name)
}
.sheet(isPresented: $showDescriptorPDF) {
DescriptorPDFView(walletName: wallet.name, descriptor: combinedDescriptor)
@ -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))
@ -884,11 +883,15 @@ private struct EditableCosigner: Identifiable {
private struct DescriptorQRSheet: View {
let descriptor: String
let walletName: String
let descriptorUR: UR?
@Environment(\.dismiss) private var dismiss
@State private var isExporting = false
@AppStorage(Constants.qrFrameRateKey) private var qrFrameRate: Double = 4.0
init(descriptor: String) {
init(descriptor: String, walletName: String) {
self.descriptor = descriptor
self.walletName = walletName
descriptorUR = try? URService.encodeCryptoOutput(descriptor: descriptor)
}
@ -914,13 +917,22 @@ private struct DescriptorQRSheet: View {
.foregroundStyle(Color.hbTextSecondary)
.multilineTextAlignment(.center)
Button(action: {
UIPasteboard.general.string = descriptor
}) {
Label("Copy Descriptor", systemImage: "doc.on.doc")
.font(.hbBody(14))
.foregroundStyle(Color.hbSteelBlue)
Button(action: { exportAsMP4() }) {
if isExporting {
HStack(spacing: 8) {
ProgressView()
.tint(Color.hbSteelBlue)
Text("Generating video...")
.font(.hbBody(14))
.foregroundStyle(Color.hbSteelBlue)
}
} else {
Label("Export Descriptor as MP4", systemImage: "film")
.font(.hbBody(14))
.foregroundStyle(Color.hbSteelBlue)
}
}
.disabled(isExporting || descriptorUR == nil)
}
.padding(.top, 8)
}
@ -934,6 +946,47 @@ private struct DescriptorQRSheet: View {
}
}
}
private func exportAsMP4() {
guard let ur = descriptorUR else { return }
isExporting = true
Task {
do {
let url = try await QRVideoExporter.exportMP4(
ur: ur,
fileName: "\(walletName) Output Descriptor",
maxFragmentLen: 160,
fps: qrFrameRate,
loopCount: 3
)
await MainActor.run {
isExporting = false
presentShareSheet(url: url)
}
} catch {
await MainActor.run {
isExporting = false
}
}
}
}
private func presentShareSheet(url: URL) {
let activityVC = UIActivityViewController(
activityItems: [url],
applicationActivities: nil
)
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
var topVC = windowScene.windows.first?.rootViewController else { return }
while let presented = topVC.presentedViewController {
topVC = presented
}
if let popover = activityVC.popoverPresentationController {
popover.sourceView = topVC.view
popover.sourceRect = CGRect(x: topVC.view.bounds.midX, y: 0, width: 0, height: 0)
}
topVC.present(activityVC, animated: true)
}
}
private struct InfoRow: View {

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]
@ -231,6 +231,7 @@ struct TransactionListView: View {
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.accessibilityIdentifier("walletMenu")
Spacer()
@ -255,6 +256,7 @@ struct TransactionListView: View {
.strokeBorder(Color.hbBorder, lineWidth: 1)
)
}
.accessibilityIdentifier("walletPicker")
Spacer()
@ -332,7 +334,6 @@ struct TransactionListView: View {
transactionContent
}
.background(Color.hbBackground)
.overlay { walletPickerOverlay }
.navigationTitle("")
.onDisappear {
walletPickerEditMode = false
@ -341,6 +342,7 @@ struct TransactionListView: View {
.refreshable {
await viewModel.refresh()
}
.overlay { walletPickerOverlay }
.sheet(isPresented: $showConnectionStatus) {
ConnectionStatusView()
}
@ -548,69 +550,76 @@ struct TransactionListView: View {
Divider()
.background(Color.hbBorder)
ForEach(Array(wallets.enumerated()), id: \.element.id) { index, wallet in
HStack(spacing: 12) {
if walletPickerEditMode {
Image(systemName: "minus.circle.fill")
.font(.system(size: 20))
.foregroundStyle(Color.hbError)
.onTapGesture {
walletToDelete = wallet
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(wallets.enumerated()), id: \.element.id) { index, wallet in
HStack(spacing: 12) {
if walletPickerEditMode {
Image(systemName: "minus.circle.fill")
.font(.system(size: 20))
.foregroundStyle(Color.hbError)
.onTapGesture {
walletToDelete = wallet
}
}
}
WalletIdenticon(id: wallet.id)
.frame(width: 32, height: 32)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.hbBitcoinOrange, lineWidth: wallet.isActive ? 2 : 0)
)
VStack(spacing: 4) {
NetworkBadge(network: wallet.bitcoinNetwork)
Text(wallet.multisigDescription)
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
}
.fixedSize()
Text(wallet.name)
.font(.hbBody())
.foregroundStyle(Color.hbTextPrimary)
Spacer()
WalletIdenticon(id: wallet.id)
.frame(width: 32, height: 32)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.hbBitcoinOrange, lineWidth: wallet.isActive ? 2 : 0)
)
VStack(spacing: 4) {
NetworkBadge(network: wallet.bitcoinNetwork)
Text(wallet.multisigDescription)
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
}
.fixedSize()
Text(wallet.name)
.font(.hbBody())
.foregroundStyle(Color.hbTextPrimary)
Spacer()
if walletPickerEditMode {
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.hbTextSecondary)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 15)
.background(wallet.isActive ? Color.hbBitcoinOrange.opacity(0.08) : Color.clear)
.contentShape(Rectangle())
.onTapGesture {
if walletPickerEditMode {
walletToEdit = wallet
walletPickerEditMode = false
withAnimation(.spring(duration: 0.25, bounce: 0.15)) { showWalletPicker = false }
showWalletInfo = true
} else {
guard !wallet.isActive else {
withAnimation(.spring(duration: 0.25, bounce: 0.15)) { showWalletPicker = false }
return
if walletPickerEditMode {
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.hbTextSecondary)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 15)
.background(wallet.isActive ? Color.hbBitcoinOrange.opacity(0.08) : Color.clear)
.contentShape(Rectangle())
.onTapGesture {
if walletPickerEditMode {
walletToEdit = wallet
walletPickerEditMode = false
withAnimation(.spring(duration: 0.25, bounce: 0.15)) { showWalletPicker = false }
showWalletInfo = true
} else {
guard !wallet.isActive else {
withAnimation(.spring(duration: 0.25, bounce: 0.15)) { showWalletPicker = false }
return
}
viewModel.clearState()
walletManager.setActiveWallet(wallet, allWallets: wallets, modelContext: modelContext)
withAnimation(.spring(duration: 0.25, bounce: 0.15)) { showWalletPicker = false }
}
}
.onLongPressGesture {
walletPickerEditMode = true
}
if index < wallets.count - 1 {
Divider()
.background(Color.hbBorder)
}
viewModel.clearState()
walletManager.setActiveWallet(wallet, allWallets: wallets, modelContext: modelContext)
withAnimation(.spring(duration: 0.25, bounce: 0.15)) { showWalletPicker = false }
}
}
.onLongPressGesture {
walletPickerEditMode = true
}
if index < wallets.count - 1 {
Divider()
.background(Color.hbBorder)
}
}
.scrollBounceBehavior(.basedOnSize)
.scrollIndicators(.visible)
.frame(maxHeight: 310)
}
.background(Color.hbSurfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))

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))
@ -149,7 +148,12 @@ struct CosignerImportView: View {
.padding(.bottom, 32)
}
.padding(.top, 16)
.contentShape(Rectangle())
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
.scrollDismissesKeyboard(.interactively)
.sheet(isPresented: $showScanner) {
URScannerSheet(expectedTypes: [.hdKey], onCancel: { showScanner = false }) { result in
handleScanResult(result)

View File

@ -129,6 +129,10 @@ struct DescriptorImportView: View {
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
.contentShape(Rectangle())
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
.scrollDismissesKeyboard(.interactively)
.sheet(isPresented: $showScanner) {

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