Compare commits

..

1 Commits

Author SHA1 Message Date
Nick Klockenga
c6ded0bef6 max height on wallet picker with scrolling 2026-04-06 21:58:20 -04:00
141 changed files with 735 additions and 4420 deletions

View File

@ -14,28 +14,11 @@ 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: birch
file_to_build: birch.xcodeproj
scheme: hellbender
file_to_build: hellbender.xcodeproj
filetype_parameter: project
run: |
xcodebuild clean build analyze -scheme "$scheme" -"$filetype_parameter" "$file_to_build" CODE_SIGNING_ALLOWED=NO | xcpretty && exit ${PIPESTATUS[0]}

View File

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

View File

@ -1,63 +0,0 @@
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,13 +33,3 @@ 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/

View File

@ -1 +0,0 @@
26.4

View File

@ -1,61 +0,0 @@
// 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

View File

@ -1,19 +0,0 @@
// 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

View File

@ -1,13 +0,0 @@
// 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

View File

@ -1,13 +0,0 @@
// 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)=~

View File

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

View File

@ -1,338 +0,0 @@
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,29 +1,24 @@
<p align="center">
<img src="https://birchwallet.app/assets/AppIcon-og.png" alt="Birch" width="128" height="128" style="border-radius: 24px;" />
<img src="https://hellbenderwallet.com/assets/AppIcon-og.png" alt="Hellbender" width="128" height="128" style="border-radius: 24px;" />
</p>
<h1 align="center">Birch</h1>
<h1 align="center">Hellbender</h1>
<p align="center">
<em>Travel to your private keys and leave your laptop at home.</em>
</p>
<p align="center">
<img src="https://birchwallet.app/assets/screenshots/welcome.png" alt="Welcome" width="150" />
<img src="https://birchwallet.app/assets/screenshots/wallet-setup.png" alt="Setup Choice" width="150" />
<img src="https://birchwallet.app/assets/screenshots/multisig-config.png" alt="New Wallet Multisig Config" width="150" />
<img src="https://birchwallet.app/assets/screenshots/cosigner-import.png" alt="Cosigner Import" width="150" />
<img src="https://birchwallet.app/assets/screenshots/verify-wallet-top.png" alt="Verify Wallet" width="150" />
<img src="https://birchwallet.app/assets/screenshots/verify-wallet-backup.png" alt="Backup PDF/QR" width="150" />
<img src="https://birchwallet.app/assets/screenshots/transactions.png" alt="Transactions" width="150" />
<img src="https://birchwallet.app/assets/screenshots/send.png" alt="Send" width="150" />
<img src="https://birchwallet.app/assets/screenshots/receive.png" alt="Receive" width="150" />
<img src="https://birchwallet.app/assets/screenshots/utxos.png" alt="UTXO" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/welcome.png" alt="Welcome" width="150" />
<img src="https://hellbenderwallet.com/assets/screenshots/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" />
</p>
---
Birch is an iOS Bitcoin multisig coordinator written in Swift. It operates as a **watch-only wallet** — private keys never touch your phone. Coordinate signing across air-gapped hardware wallets using animated QR codes, bringing cold storage security with mobile convenience.
Hellbender is an iOS Bitcoin multisig coordinator written in Swift. It operates as a **watch-only wallet** — private keys never touch your phone. Coordinate signing across air-gapped hardware wallets using animated QR codes, bringing cold storage security with mobile convenience.
## Features
@ -43,7 +38,7 @@ Birch is an iOS Bitcoin multisig coordinator written in Swift. It operates as a
### Requirements
- Xcode 26.2+
- Xcode 16.2+
- iOS 18.6+ deployment target
- Swift 5.0
@ -65,145 +60,17 @@ All dependencies are managed via Swift Package Manager and resolve automatically
git clone https://github.com/newtonick/hellbender-wallet.git
cd hellbender-wallet
```
2. Open `birch.xcodeproj` in Xcode
2. Open `hellbender.xcodeproj` in Xcode
3. SPM dependencies resolve automatically on first open
4. Build and run on a simulator or device
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`. 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).
GitHub Actions runs `xcodebuild clean build analyze` on every push and pull request to `main`.
## Links
- **Website**: [birchwallet.app](https://birchwallet.app)
- **Website**: [hellbenderwallet.com](https://hellbenderwallet.com)
- **TestFlight Beta**: [Join the beta](https://testflight.apple.com/join/PuHVwJDJ)
- **Author**: [newtonick](https://github.com/newtonick/hellbender-wallet/)
@ -211,5 +78,5 @@ fastlane/screenshots/
MIT License — see [LICENSE](LICENSE) for details.
Birch's dependencies use permissive licenses compatible with MIT:
Hellbender's dependencies use permissive licenses compatible with MIT:
bdk-swift (MIT/Apache-2.0), URKit (BSD-2-Clause-Patent), URUI (BSD-2-Clause-Patent), Bbqr (Apache-2.0).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

View File

@ -1,92 +0,0 @@
<?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

@ -1,176 +0,0 @@
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

@ -1,352 +0,0 @@
import SwiftUI
import URKit
struct WalletVerifyView: View {
@Bindable var viewModel: SetupWizardViewModel
let onComplete: () -> Void
@State private var showDescriptorQR = false
@State private var showDescriptorPDF = false
@State private var copiedDescriptor = false
var body: some View {
ScrollView {
VStack(spacing: 24) {
Text("Verify Wallet")
.font(.hbDisplay(28))
.foregroundStyle(Color.hbTextPrimary)
// MARK: - Wallet Summary
VStack(spacing: 16) {
ReviewRow(label: "Name", value: viewModel.walletName.isEmpty ? "My Wallet" : viewModel.walletName)
ReviewRow(label: "Type", value: "\(viewModel.requiredSignatures)-of-\(viewModel.totalCosigners) Multisig")
ReviewRow(label: "Network", value: viewModel.network.displayName)
ReviewRow(label: "Script", value: "P2WSH (Native Segwit)")
}
.hbCard()
.padding(.horizontal, 24)
// MARK: - Cosigners
VStack(alignment: .leading, spacing: 12) {
Text("Cosigners")
.font(.hbHeadline)
.foregroundStyle(Color.hbTextPrimary)
ForEach(0 ..< viewModel.totalCosigners, id: \.self) { index in
VStack(alignment: .leading, spacing: 6) {
Text(viewModel.cosignerLabels[index])
.font(.hbBody(15))
.foregroundStyle(Color.hbTextPrimary)
HStack(spacing: 8) {
Text("FP:")
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
Text(viewModel.cosignerFingerprints[index])
.font(.hbMono(12))
.foregroundStyle(Color.hbBitcoinOrange)
}
Text(viewModel.cosignerXpubs[index])
.font(.hbMono(10))
.foregroundStyle(Color.hbTextSecondary)
.lineLimit(2)
.truncationMode(.middle)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.hbSurfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.hbCard()
.padding(.horizontal, 24)
// MARK: - Back Up Your Descriptor
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Color.hbBitcoinOrange)
.font(.system(size: 20))
Text("Back Up Your Descriptor")
.font(.hbHeadline)
.foregroundStyle(Color.hbTextPrimary)
}
Text("The output descriptor is your **only** recovery path. If you lose Birch (phone dies, app deleted, data corrupted), the descriptor is the only thing needed to rebuild the wallet in any compatible coordinator (Sparrow, Nunchuk, etc.). Without it, you'd need to re-gather all cosigner xpubs and reconstruct the exact same configuration — which may not be possible.")
.font(.hbBody(13))
.foregroundStyle(Color.hbTextSecondary)
VStack(alignment: .leading, spacing: 6) {
BulletRow(text: "Print to PDF")
BulletRow(text: "Import into Sparrow Wallet on another computer")
BulletRow(text: "Save to an encrypted drive")
}
Button(action: { showDescriptorPDF = true }) {
HStack(spacing: 8) {
Image(systemName: "doc.richtext")
Text("PDF/Print Output Descriptor")
.font(.hbBody(15))
}
.foregroundStyle(Color.purple)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.purple.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
Button(action: { showDescriptorQR = true }) {
HStack(spacing: 8) {
Image(systemName: "qrcode.viewfinder")
Text("Show Descriptor QR")
.font(.hbBody(15))
}
.foregroundStyle(Color.hbBitcoinOrange)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.hbBitcoinOrange.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
Button(action: {
UIPasteboard.general.string = viewModel.combinedDescriptor
copiedDescriptor = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
copiedDescriptor = false
}
}) {
HStack(spacing: 8) {
Image(systemName: copiedDescriptor ? "checkmark" : "doc.on.doc")
Text(copiedDescriptor ? "Copied!" : "Copy Descriptor")
.font(.hbBody(15))
}
.foregroundStyle(Color.hbSteelBlue)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.hbSteelBlue.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.hbCard()
.padding(.horizontal, 24)
// MARK: - Verify Receive Address
VStack(alignment: .leading, spacing: 12) {
Label("Verify Receive Address", systemImage: "checkmark.shield")
.font(.hbHeadline)
.foregroundStyle(Color.hbTextPrimary)
Text("Verifying your first receive address confirms that Birch built the correct output descriptor and will generate the same addresses as your cosigner devices. If the addresses don't match, funds sent to this wallet could be unspendable.")
.font(.hbBody(13))
.foregroundStyle(Color.hbTextSecondary)
if let error = viewModel.addressDerivationError {
Text(error)
.font(.hbBody(14))
.foregroundStyle(Color.hbError)
Button(action: { viewModel.deriveFirstAddress() }) {
Text("Retry")
.font(.hbBody(14))
.foregroundStyle(Color.hbBitcoinOrange)
}
} else if !viewModel.firstReceiveAddress.isEmpty {
QRCodeView(content: viewModel.firstReceiveAddress)
.frame(width: 200, height: 200)
.padding(12)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(maxWidth: .infinity)
viewModel.firstReceiveAddress.chunkedAddressText(font: .hbMono(12))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.textSelection(.enabled)
Text("Index 0")
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
.frame(maxWidth: .infinity)
}
Text("Load the output descriptor into your hardware signer (SeedSigner, Krux, etc.) and verify this address matches the first receive address shown on the device.")
.font(.hbBody(13))
.foregroundStyle(Color.hbTextSecondary)
}
.hbCard()
.padding(.horizontal, 24)
// MARK: - Actions
Button(action: onComplete) {
Text("Create Wallet")
.hbPrimaryButton()
}
.padding(.horizontal, 24)
Button(action: { viewModel.goBack() }) {
Text("Back")
.font(.hbBody(16))
.foregroundStyle(Color.hbTextSecondary)
}
.padding(.bottom, 32)
}
.padding(.top, 16)
}
.sheet(isPresented: $showDescriptorQR) {
DescriptorQRSheet(descriptor: viewModel.combinedDescriptor, walletName: viewModel.walletName.isEmpty ? "My Wallet" : viewModel.walletName)
}
.sheet(isPresented: $showDescriptorPDF) {
DescriptorPDFView(
walletName: viewModel.walletName.isEmpty ? "My Wallet" : viewModel.walletName,
descriptor: viewModel.externalDescriptor
)
}
}
}
// MARK: - Supporting Views
private struct ReviewRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
Spacer()
Text(value)
.font(.hbBody(15))
.foregroundStyle(Color.hbTextPrimary)
}
}
}
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, walletName: String) {
self.descriptor = descriptor
self.walletName = walletName
descriptorUR = try? URService.encodeCryptoOutput(descriptor: descriptor)
}
var body: some View {
NavigationStack {
ZStack {
Color.hbBackground.ignoresSafeArea()
VStack(spacing: 16) {
if let ur = descriptorUR {
URDisplaySheet(ur: ur)
.padding(5)
.background(Color.white)
.shadow(color: Color.hbBitcoinOrange.opacity(0.2), radius: 20)
} else {
Text("Failed to encode descriptor")
.font(.hbBody())
.foregroundStyle(Color.hbError)
}
Text("Scan to import this wallet descriptor")
.font(.hbBody(14))
.foregroundStyle(Color.hbTextSecondary)
.multilineTextAlignment(.center)
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)
}
.navigationTitle("Wallet Descriptor")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
.foregroundStyle(Color.hbBitcoinOrange)
}
}
}
}
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 BulletRow: View {
let text: String
var body: some View {
HStack(alignment: .top, spacing: 8) {
Text("\u{2022}")
.font(.hbBody(13))
.foregroundStyle(Color.hbBitcoinOrange)
Text(text)
.font(.hbBody(13))
.foregroundStyle(Color.hbTextSecondary)
}
}
}

View File

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

View File

@ -1,376 +0,0 @@
@testable import birch
import Foundation
import Testing
import URKit
@MainActor
struct DescriptorTests {
// MARK: - Descriptor Construction
@Test func buildTwoOfThreeDescriptor() {
let cosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: "tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ",
fingerprint: "73c5da0a", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubDFcMWLJTavzfRa3Rc5i3bTMGBW7kYBLhLMJpLGSEik5pVhN5SMNKyVXHEB3Wnz6haXBMLF5MUiGMrawKaYFoZhBFNnEv7XEiv3FtGkBLtEHj",
fingerprint: "f3ab64d8", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubDEmRJGMra7j5TnqBb4F8d43geT8sNXkWBzJbAjWz5n3Bm4EJ4CjxqwT2BqNNyVmGdXmMsBafF4vaVhEsEwNeXCxRN1mvPuDJCxPPBkpcjwY",
fingerprint: "c0b5ce41", derivationPath: "m/48'/1'/0'/2'"),
]
let external = BitcoinService.buildDescriptor(
requiredSignatures: 2,
cosigners: cosigners,
network: .testnet4,
isChange: false
)
#expect(external.hasPrefix("wsh(sortedmulti(2,"))
#expect(external.hasSuffix("/0/*))"))
#expect(external.contains("[73c5da0a/48'/1'/0'/2']"))
#expect(external.contains("[f3ab64d8/48'/1'/0'/2']"))
#expect(external.contains("[c0b5ce41/48'/1'/0'/2']"))
}
@Test func buildChangeDescriptor() {
let cosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: "tpubA", fingerprint: "aaaaaaaa", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubB", fingerprint: "bbbbbbbb", derivationPath: "m/48'/1'/0'/2'"),
]
let internal_desc = BitcoinService.buildDescriptor(
requiredSignatures: 2,
cosigners: cosigners,
network: .testnet4,
isChange: true
)
#expect(internal_desc.contains("/1/*"))
#expect(!internal_desc.contains("/0/*"))
}
@Test func descriptorKeysAreSortedLexicographically() throws {
let cosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: "tpubZ", fingerprint: "11111111", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubA", fingerprint: "22222222", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubM", fingerprint: "33333333", derivationPath: "m/48'/1'/0'/2'"),
]
let desc = BitcoinService.buildDescriptor(
requiredSignatures: 2,
cosigners: cosigners,
network: .testnet4,
isChange: false
)
// Keys should be sorted: tpubA, tpubM, tpubZ
let aPos = try #require(desc.range(of: "tpubA")?.lowerBound)
let mPos = try #require(desc.range(of: "tpubM")?.lowerBound)
let zPos = try #require(desc.range(of: "tpubZ")?.lowerBound)
#expect(aPos < mPos)
#expect(mPos < zPos)
}
@Test func descriptorRoundTrip() {
let cosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: "tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ",
fingerprint: "73c5da0a", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubDFcMWLJTavzfRa3Rc5i3bTMGBW7kYBLhLMJpLGSEik5pVhN5SMNKyVXHEB3Wnz6haXBMLF5MUiGMrawKaYFoZhBFNnEv7XEiv3FtGkBLtEHj",
fingerprint: "f3ab64d8", derivationPath: "m/48'/1'/0'/2'"),
]
let desc1 = BitcoinService.buildDescriptor(
requiredSignatures: 2,
cosigners: cosigners,
network: .testnet4,
isChange: false
)
// Parse back via the wizard viewmodel
let vm = SetupWizardViewModel()
vm.importedDescriptorText = desc1
let parsed = vm.parseImportedDescriptor()
#expect(parsed)
#expect(vm.requiredSignatures == 2)
#expect(vm.totalCosigners == 2)
// Rebuild from parsed data
let reparsedCosigners = (0 ..< vm.totalCosigners).map { i in
(xpub: vm.cosignerXpubs[i], fingerprint: vm.cosignerFingerprints[i], derivationPath: vm.cosignerDerivationPaths[i])
}
let desc2 = BitcoinService.buildDescriptor(
requiredSignatures: vm.requiredSignatures,
cosigners: reparsedCosigners,
network: .testnet4,
isChange: false
)
#expect(desc1 == desc2)
}
@Test func descriptorMainnetCoinType() {
let cosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: "xpubA", fingerprint: "aaaaaaaa", derivationPath: "m/48'/0'/0'/2'"),
(xpub: "xpubB", fingerprint: "bbbbbbbb", derivationPath: "m/48'/0'/0'/2'"),
]
let desc = BitcoinService.buildDescriptor(
requiredSignatures: 2,
cosigners: cosigners,
network: .mainnet,
isChange: false
)
#expect(desc.contains("48'/0'/0'/2'"))
}
@Test func descriptorTestnetCoinType() {
let cosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: "tpubA", fingerprint: "aaaaaaaa", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubB", fingerprint: "bbbbbbbb", derivationPath: "m/48'/1'/0'/2'"),
]
let desc = BitcoinService.buildDescriptor(
requiredSignatures: 2,
cosigners: cosigners,
network: .testnet4,
isChange: false
)
#expect(desc.contains("48'/1'/0'/2'"))
}
// MARK: - Descriptor Parsing Validation
@Test func rejectNonWshDescriptor() {
let vm = SetupWizardViewModel()
vm.importedDescriptorText = "sh(sortedmulti(2,[aabb/48'/1'/0'/2']tpubA/0/*,[ccdd/48'/1'/0'/2']tpubB/0/*))"
let result = vm.parseImportedDescriptor()
#expect(!result)
#expect(vm.errorMessage != nil)
}
@Test func rejectEmptyDescriptor() {
let vm = SetupWizardViewModel()
vm.importedDescriptorText = ""
let result = vm.parseImportedDescriptor()
#expect(!result)
}
// MARK: - Descriptor Checksum
private static let realTestnetCosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: "tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ",
fingerprint: "73c5da0a", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubDFcMWLJTavzfRa3Rc5i3bTMGBW7kYBLhLMJpLGSEik5pVhN5SMNKyVXHEB3Wnz6haXBMLF5MUiGMrawKaYFoZhBFNnEv7XEiv3FtGkBLtEHj",
fingerprint: "f3ab64d8", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubDEmRJGMra7j5TnqBb4F8d43geT8sNXkWBzJbAjWz5n3Bm4EJ4CjxqwT2BqNNyVmGdXmMsBafF4vaVhEsEwNeXCxRN1mvPuDJCxPPBkpcjwY",
fingerprint: "c0b5ce41", derivationPath: "m/48'/1'/0'/2'"),
]
@Test func combinedDescriptorHasChecksum() {
let desc = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.realTestnetCosigners,
network: .testnet4
)
// BIP-380 checksum is 8 characters after a '#'
#expect(desc.contains("#"), "Combined descriptor should contain a checksum separator")
let parts = desc.split(separator: "#")
#expect(parts.count == 2, "Should have exactly one '#' separator")
#expect(parts[1].count == 8, "Checksum should be 8 characters, got '\(parts[1])'")
}
@Test func combinedDescriptorChecksumIsDeterministic() {
let desc1 = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.realTestnetCosigners,
network: .testnet4
)
let desc2 = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.realTestnetCosigners,
network: .testnet4
)
#expect(desc1 == desc2, "Same inputs should produce the same checksummed descriptor")
}
@Test func combinedDescriptorChecksumPreservesContent() {
let desc = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.realTestnetCosigners,
network: .testnet4
)
#expect(desc.hasPrefix("wsh(sortedmulti(2,"), "Should still start with wsh(sortedmulti(2,")
#expect(desc.contains("<0;1>/*"), "Should still contain multipath notation")
#expect(desc.contains("[73c5da0a/48'/1'/0'/2']"), "Should contain cosigner fingerprint/path")
}
// MARK: - SLIP132 Vpub/Zpub normalization
/// Cosigners as they might be entered by the user first one is in SLIP132
/// `Vpub` format (BIP-84 wsh testnet), the other two are standard `tpub`.
/// BDK's descriptor parser only accepts `xpub`/`tpub`, so the descriptor
/// builder must normalize the `Vpub` to `tpub` before assembly.
private static let mixedFormatCosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: "Vpub5kv6Y3xqGFyhZQyCz8LzaSwVzAJLJTvHcUewWAhrLRRRjZeYs53qrfspVEBKZw6rvwGy8Z1ef7e7Vzsu3BLF6MkjFXWnLpmftKQT1Eub5Cf",
fingerprint: "d03ce438", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubDE2JvCZ3g8tEX3yegvXFn9cpzUyA2EEg6EwS7sAHcPER9yA6nFKdGPyLzsswYWa3SvEbKFmUiyFe9QQrpVpKwxojCud4ThNEv8R3j411Lcs",
fingerprint: "f9755e5b", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubDFEegnzQJr8LdYmGh1dGy3vqVgWtZ5w6q2cw4fbXhp15A29hvpf4NtAeFNvmmDRFTzeu1CveXs6dK2iPVADn2fSXWAQhHZhtLRGeHLmiBi5",
fingerprint: "acc95047", derivationPath: "m/48'/1'/0'/2'"),
]
/// The expected `tpub` form of the `Vpub` from `mixedFormatCosigners[0]`.
private static let convertedTpub =
"tpubDE4AYPPuhwTk7ENvANSMNU84wRecxjikg4e1WFHE4a6fxsNogCqnA7zzxyDoXp93JeyWNViXEKnkqaysaCrZRnTZDLYXnmbt7zrGxWYc3Mx"
@Test func combinedDescriptorNormalizesVpubToTpub() {
let desc = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4
)
// No SLIP132-tagged keys should remain in the assembled descriptor.
#expect(!desc.contains("Vpub"), "Descriptor should not contain SLIP132 Vpub keys after normalization")
#expect(!desc.contains("Zpub"), "Descriptor should not contain SLIP132 Zpub keys after normalization")
// The converted tpub from the original Vpub must be present, paired with
// the cosigner's original fingerprint.
#expect(desc.contains(Self.convertedTpub), "Vpub should normalize to expected tpub: \(Self.convertedTpub)")
#expect(desc.contains("[d03ce438/48'/1'/0'/2']\(Self.convertedTpub)"), "Converted tpub should retain the original fingerprint/origin")
}
@Test func singleChainDescriptorNormalizesVpubToTpub() {
let external = BitcoinService.buildDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4,
isChange: false
)
#expect(!external.contains("Vpub"), "External descriptor should not contain Vpub")
#expect(external.contains(Self.convertedTpub), "External descriptor should contain the converted tpub")
}
@Test func descriptorBuiltFromMixedFormatsMatchesAllTpubVersion() {
// Building the descriptor from the Vpub-mixed list should produce the same
// result as building it from the equivalent all-tpub list proving the
// SLIP132 input is fully normalized away.
let allTpubCosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: Self.convertedTpub, fingerprint: "d03ce438", derivationPath: "m/48'/1'/0'/2'"),
Self.mixedFormatCosigners[1],
Self.mixedFormatCosigners[2],
]
let fromMixed = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4
)
let fromAllTpub = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: allTpubCosigners,
network: .testnet4
)
#expect(fromMixed == fromAllTpub, "Descriptor built from Vpub+tpub mix should equal all-tpub descriptor")
}
@Test func descriptorSortsByNormalizedXpubForBIP67() {
// The user-supplied example: cosigners entered in [Vpub, tpub, tpub] order
// with fingerprints [d03ce438, f9755e5b, acc95047]. After normalization,
// BIP67 lexicographic sort by tpub puts them in this fingerprint order:
// 1. f9755e5b (tpubDE2JvCZ3g8tEX...)
// 2. d03ce438 (tpubDE4AYPPuhwTk7... converted from Vpub)
// 3. acc95047 (tpubDFEegnzQJr8L...)
let desc = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4
)
let fp1 = desc.range(of: "[f9755e5b/48'/1'/0'/2']")
let fp2 = desc.range(of: "[d03ce438/48'/1'/0'/2']")
let fp3 = desc.range(of: "[acc95047/48'/1'/0'/2']")
#expect(fp1 != nil, "Descriptor should contain f9755e5b key origin")
#expect(fp2 != nil, "Descriptor should contain d03ce438 key origin")
#expect(fp3 != nil, "Descriptor should contain acc95047 key origin")
if let fp1, let fp2, let fp3 {
#expect(fp1.lowerBound < fp2.lowerBound, "f9755e5b (tpubDE2J...) should sort before d03ce438 (tpubDE4A...)")
#expect(fp2.lowerBound < fp3.lowerBound, "d03ce438 (tpubDE4A...) should sort before acc95047 (tpubDFEe...)")
}
}
/// Real descriptor decoded from a known-good crypto-output UR (from URServiceTests)
private func realURDescriptor() -> String? {
let urString = "UR:CRYPTO-OUTPUT/TAADMETAADMSOEADADAOLFTAADDLOSAOWKAXHDCLAOPDFNLNESAXHSJOFTVWFWHPTDUYPYHSROVLSWVDSRVWKBNNECZTHYMOURGSFDVDVAAAHDCXGMDKHPWMZTLRSOBSMWIOBWFWRPTODKNSEYAMTAHKRKQDISJTGWNSTSSFQDKPZSVTAHTAADEHOEADAEAOADAMTAADDYOTADLOCSDYYKADYKAEYKAOYKAOCYDYOTJEGMAXAAAYCYOYJNLKZMASJZGUIHIHIEGUINIOJTIHJPCXEYTAADDLOSAOWKAXHDCLAXIYMYFYWEMKASIOVSFYFDFDVASWONMTSKURSSTDMHVWSKLEAMKOVSGSDSCNSGNDOEAAHDCXBAMHFTFLGSDTBGBGFGGUREENGLFYTSHSCEJNKPHGGLFDFMTEWLENBDBBOXDYEMWTAHTAADEHOEADAEAOADAMTAADDYOTADLOCSDYYKADYKAEYKAOYKAOCYKNBWOSPAAXAAAYCYGRFPNSJOASJZGUIHIHIEGUINIOJTIHJPCXEHDLSWWZMD"
let result = URService.processURString(urString)
guard case let .descriptor(desc) = result else { return nil }
return desc
}
@Test func checksumDoesNotAffectUREncoding() throws {
guard let desc = realURDescriptor() else {
Issue.record("Failed to decode test UR to descriptor")
return
}
let checksum = BitcoinService.descriptorChecksum(desc)
#expect(checksum.count == 8, "Checksum should be 8 characters")
let descWithChecksum = desc + "#" + checksum
// Encode both with and without checksum
let urWithChecksum = try URService.encodeCryptoOutput(descriptor: descWithChecksum)
let urWithoutChecksum = try URService.encodeCryptoOutput(descriptor: desc)
// The CBOR data should be identical checksum is stripped before encoding
#expect(
urWithChecksum.cbor.cborData == urWithoutChecksum.cbor.cborData,
"Checksum should not affect the UR CBOR encoding"
)
}
@Test func checksumDoesNotAffectAnimatedQRFrames() throws {
guard let desc = realURDescriptor() else {
Issue.record("Failed to decode test UR to descriptor")
return
}
let checksum = BitcoinService.descriptorChecksum(desc)
let descWithChecksum = desc + "#" + checksum
let urWithChecksum = try URService.encodeCryptoOutput(descriptor: descWithChecksum)
let urWithoutChecksum = try URService.encodeCryptoOutput(descriptor: desc)
let maxFragmentLen = 160
let encoderWith = UREncoder(urWithChecksum, maxFragmentLen: maxFragmentLen)
let encoderWithout = UREncoder(urWithoutChecksum, maxFragmentLen: maxFragmentLen)
// Same number of parts
#expect(
encoderWith.seqLen == encoderWithout.seqLen,
"Both should produce the same number of UR parts"
)
// Same part content
for i in 0 ..< encoderWith.seqLen {
let partWith = encoderWith.nextPart()
let partWithout = encoderWithout.nextPart()
#expect(
partWith == partWithout,
"UR part \(i) should be identical regardless of checksum"
)
}
}
}

View File

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

View File

@ -1,631 +0,0 @@
//
// ScreenshotTests.swift
// birchUITests
//
// Fastlane `snapshot` walker. Invoked by `bundle exec fastlane screenshots`
// (see fastlane/Fastfile). Walks the app from Welcome through the main tabs,
// calling `snapshot(...)` at each marketing stop.
//
// Kept separate from birchUITests.swift on purpose: the assertion-heavy
// setup test validates that the flow still works, and this test is purely for
// capturing images. The descriptor-import sequence is duplicated (not shared)
// so each test fails in isolation.
//
import XCTest
final class ScreenshotTests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments += ["-UITesting"]
}
override func tearDownWithError() throws {
app = nil
}
@MainActor
func testScreenshotTour() {
setupSnapshot(app)
app.launch()
// MARK: 01 - Welcome
let getStarted = app.buttons["Get Started"]
XCTAssertTrue(getStarted.waitForExistence(timeout: 5), "Welcome screen should show 'Get Started' button")
snapshot("01-Welcome")
getStarted.tap()
// MARK: 02 - Wallet Setup (creation choice)
let walletSetupTitle = app.staticTexts["Wallet Setup"]
XCTAssertTrue(walletSetupTitle.waitForExistence(timeout: 3), "Wallet Setup screen should appear")
sleep(1)
snapshot("02-Wallet-Setup")
// MARK: Walk the descriptor-import flow to reach a loaded wallet.
// (Mirrors birchUITests.swift `testSetupWalletViaDescriptorImport`.)
let importCard = app.staticTexts["Import Descriptor"]
XCTAssertTrue(importCard.waitForExistence(timeout: 3), "Creation choice should show 'Import Descriptor' option")
importCard.tap()
let importTitle = app.staticTexts["Import Descriptor"]
XCTAssertTrue(importTitle.waitForExistence(timeout: 3), "Descriptor import screen should appear")
let testDescriptor = "wsh(sortedmulti(1,[7a13a7b1/48'/1'/0'/2']tpubDETciRzaZyqww2dSAyT2j6tWgzREyiZEY2iZDPKDtqNpSEqqFS31DZUFFTFnayx7wLUVYx3V1R2AWhhWbFrnCukKZ1kmnn83Fn2xSf7hEaH/<0;1>/*,[30a36b52/48'/1'/0'/2']tpubDF6MPv2vWsbCo8c7rk4X32BPa5yuj4niem5Pr6isrd9cSdCkYETcGUmBSFY4ekTR1CRFmjn4eoYGrwPU19FffwEpX7Tda6BBmg91aiHKpmE/<0;1>/*))"
let textEditor = app.textViews.firstMatch
XCTAssertTrue(textEditor.waitForExistence(timeout: 3), "Descriptor text editor should exist")
textEditor.tap()
// Paste the descriptor instead of typing character-by-character.
// typeText() is extremely slow on older simulators (e.g. iPhone 11 Pro
// Max) for 350+ character strings and can cause downstream timeouts.
UIPasteboard.general.string = testDescriptor
textEditor.press(forDuration: 1.2)
let pasteButton = app.menuItems["Paste"]
if pasteButton.waitForExistence(timeout: 3) {
pasteButton.tap()
} else {
// Fallback to typeText if paste menu doesn't appear
textEditor.typeText(testDescriptor)
}
// Dismiss keyboard by tapping a non-field area
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
sleep(1)
// MARK: 03 - Descriptor Import (filled)
snapshot("03-DescriptorImport")
let testnet4Button = app.buttons["Testnet4"]
if testnet4Button.waitForExistence(timeout: 5) {
testnet4Button.tap()
}
let importButton = app.buttons["Import"]
XCTAssertTrue(importButton.waitForExistence(timeout: 5), "Import button should exist")
importButton.tap()
// Descriptor parsing and BDK validation can take several seconds on
// slower simulators (e.g. iPhone 11 Pro Max on x86_64).
let nameTitle = app.staticTexts["Name Your Wallet"]
XCTAssertTrue(nameTitle.waitForExistence(timeout: 30), "Wallet name screen should appear")
let nameField = app.textFields["My Wallet"]
XCTAssertTrue(nameField.waitForExistence(timeout: 3), "Wallet name text field should exist")
nameField.tap()
nameField.typeText("Birch")
// In descriptor-import mode the button reads "Create Wallet" (not "Next")
// and skips the Review screen, going straight to the loaded wallet.
let createButton = app.buttons["Create Wallet"]
XCTAssertTrue(createButton.waitForExistence(timeout: 3), "Create Wallet button should exist")
createButton.tap()
// Wait for the main Transactions tab to render with a balance, then let
// Electrum sync catch up so the balance/tx list aren't stuck at zero.
let balanceExists = app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'sats'")).firstMatch
XCTAssertTrue(balanceExists.waitForExistence(timeout: 15), "Main screen should appear after wallet creation")
sleep(12)
// Enable "Show Fiat Price" in Settings before capturing Transactions
let settingsTabEarly = app.tabBars.buttons["Settings"]
XCTAssertTrue(settingsTabEarly.waitForExistence(timeout: 5), "Settings tab should exist")
settingsTabEarly.tap()
sleep(1)
let fiatToggle = app.switches["showFiatPriceToggle"]
XCTAssertTrue(fiatToggle.waitForExistence(timeout: 5), "Show Fiat Price toggle should exist in Settings")
if fiatToggle.value as? String == "0" {
// Tap the right edge of the row where the switch thumb lives. A plain
// fiatToggle.tap() lands in the center of the accessibility frame,
// which for a Toggle with a two-line VStack label can hit the label
// area without flipping the switch.
fiatToggle.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).tap()
sleep(1)
}
XCTAssertEqual(fiatToggle.value as? String, "1", "Show Fiat Price toggle should be on after tap")
// Return to Transactions tab
let transactionsTabEarly = app.tabBars.buttons["Transactions"]
XCTAssertTrue(transactionsTabEarly.waitForExistence(timeout: 5), "Transactions tab should exist")
transactionsTabEarly.tap()
// Give the fiat rates fetch (kicked off when the toggle flipped) time to
// complete so the balance hero renders the secondary fiat line.
sleep(5)
// MARK: 04 - Transactions (balance hero + tx list)
snapshot("04-Transactions")
// MARK: 05 - Wallet Picker (overlay on transactions screen)
let walletPicker = app.buttons["walletPicker"].firstMatch
if walletPicker.waitForExistence(timeout: 3) {
walletPicker.tap()
let walletsTitle = app.staticTexts["Wallets"]
XCTAssertTrue(walletsTitle.waitForExistence(timeout: 3), "Wallet picker overlay should appear")
sleep(1)
snapshot("05-WalletPicker")
// Dismiss by tapping the wallet picker button again
walletPicker.tap()
sleep(1)
}
// MARK: 06 - Transaction Detail (tap first received transaction)
let firstTxCell = app.cells.firstMatch
if firstTxCell.waitForExistence(timeout: 5) {
firstTxCell.tap()
let receivedLabel = app.staticTexts["Received"]
let sentLabel = app.staticTexts["Sent"]
let detailAppeared = receivedLabel.waitForExistence(timeout: 5) || sentLabel.waitForExistence(timeout: 2)
XCTAssertTrue(detailAppeared, "Transaction detail should show Received or Sent label")
sleep(1)
snapshot("06-TransactionDetail")
// Go back to transaction list
app.navigationBars.buttons.element(boundBy: 0).tap()
sleep(1)
}
// MARK: 07 - Dashboard sheet (via "..." overflow menu)
let walletMenu = app.buttons["walletMenu"].firstMatch
if walletMenu.waitForExistence(timeout: 3) {
walletMenu.tap()
let dashboardMenuItem = app.buttons["Dashboard"]
if dashboardMenuItem.waitForExistence(timeout: 3) {
dashboardMenuItem.tap()
// Give the sheet a beat to animate in.
sleep(1)
snapshot("07-Dashboard")
// Dismiss the sheet by swiping the window down.
app.windows.firstMatch.swipeDown(velocity: .fast)
}
}
// MARK: 08 - Receive
let receiveTab = app.tabBars.buttons["Receive"]
XCTAssertTrue(receiveTab.waitForExistence(timeout: 5), "Receive tab should exist")
receiveTab.tap()
let viewAllAddresses = app.buttons["View All Addresses"]
XCTAssertTrue(viewAllAddresses.waitForExistence(timeout: 10), "View All Addresses link should appear")
snapshot("08-Receive")
// MARK: 09 - Addresses
viewAllAddresses.tap()
let addressesTitle = app.navigationBars["Addresses"]
XCTAssertTrue(addressesTitle.waitForExistence(timeout: 10), "Addresses screen should appear")
snapshot("09-Addresses")
// MARK: 10 - Address Detail (tap first address)
let firstAddressCell = app.cells.firstMatch
if firstAddressCell.waitForExistence(timeout: 5) {
firstAddressCell.tap()
let copyAddressButton = app.buttons["Copy Address"]
XCTAssertTrue(copyAddressButton.waitForExistence(timeout: 5), "Address detail should show Copy Address button")
snapshot("10-AddressDetail")
// Go back to address list
app.navigationBars.buttons.element(boundBy: 0).tap()
sleep(1)
}
// MARK: 11 - Send (lands on recipients step)
let sendTab = app.tabBars.buttons["Send"]
XCTAssertTrue(sendTab.waitForExistence(timeout: 5), "Send tab should exist")
sendTab.tap()
// SendFlowView headline is a static text "Send" wait for it to avoid
// racing the tab animation.
_ = app.staticTexts["Send"].waitForExistence(timeout: 5)
snapshot("11-Send")
// MARK: 12 - UTXOs
let utxosTab = app.tabBars.buttons["UTXOs"]
XCTAssertTrue(utxosTab.waitForExistence(timeout: 5), "UTXOs tab should exist")
utxosTab.tap()
let utxosHeader = app.staticTexts["UTXOs"]
XCTAssertTrue(utxosHeader.waitForExistence(timeout: 5), "UTXOs header should appear")
sleep(1)
snapshot("12-UTXOs")
// MARK: 13 - UTXO Detail (tap first UTXO)
let firstUTXOCell = app.cells.firstMatch
if firstUTXOCell.waitForExistence(timeout: 5) {
firstUTXOCell.tap()
let utxoDetailTitle = app.navigationBars["UTXO Detail"]
XCTAssertTrue(utxoDetailTitle.waitForExistence(timeout: 5), "UTXO Detail screen should appear")
snapshot("13-UTXODetail")
// Go back to UTXO list
app.navigationBars.buttons.element(boundBy: 0).tap()
sleep(1)
}
// MARK: 14 - Settings
let settingsTab = app.tabBars.buttons["Settings"]
XCTAssertTrue(settingsTab.waitForExistence(timeout: 5), "Settings tab should exist")
settingsTab.tap()
sleep(1)
snapshot("14-Settings")
// MARK: Navigate to Transactions and open wallet picker
let transactionsTab = app.tabBars.buttons["Transactions"]
XCTAssertTrue(transactionsTab.waitForExistence(timeout: 5), "Transactions tab should exist")
transactionsTab.tap()
sleep(1)
let walletPickerBtn = app.buttons["walletPicker"].firstMatch
XCTAssertTrue(walletPickerBtn.waitForExistence(timeout: 3), "Wallet picker button should exist")
walletPickerBtn.tap()
let walletsTitleAdd = app.staticTexts["Wallets"]
XCTAssertTrue(walletsTitleAdd.waitForExistence(timeout: 3), "Wallet picker overlay should appear")
sleep(1)
let addWalletBtn = app.buttons["Add"]
XCTAssertTrue(addWalletBtn.waitForExistence(timeout: 3), "Add button should exist in wallet picker")
addWalletBtn.tap()
// Setup Wizard sheet opens Welcome step
let getStartedNew = app.buttons["Get Started"]
XCTAssertTrue(getStartedNew.waitForExistence(timeout: 5), "Welcome screen should show 'Get Started' button")
getStartedNew.tap()
// Creation choice tap "Create New Wallet"
let createNewCard = app.staticTexts["Create New Wallet"]
XCTAssertTrue(createNewCard.waitForExistence(timeout: 3), "Creation choice should show 'Create New Wallet' option")
createNewCard.tap()
// MARK: 15 - Multisig Configuration (Testnet4 default)
let multisigTitle = app.staticTexts["Multisig Configuration"]
XCTAssertTrue(multisigTitle.waitForExistence(timeout: 5), "Multisig Configuration screen should appear")
sleep(1)
snapshot("15-MultisigConfig-Testnet4")
// Switch to Mainnet
let mainnetSegBtn = app.segmentedControls.firstMatch.buttons["Mainnet"]
XCTAssertTrue(mainnetSegBtn.waitForExistence(timeout: 3), "Mainnet segment button should exist")
mainnetSegBtn.tap()
sleep(1)
// MARK: 16 - Multisig Configuration (Mainnet)
snapshot("16-MultisigConfig-Mainnet")
// Switch back to Testnet4
let testnet4SegBtn = app.segmentedControls.firstMatch.buttons["Testnet4"]
XCTAssertTrue(testnet4SegBtn.waitForExistence(timeout: 3), "Testnet4 segment button should exist")
testnet4SegBtn.tap()
sleep(1)
// Advance to cosigner import
let multisigNextBtn = app.buttons["Next"]
XCTAssertTrue(multisigNextBtn.waitForExistence(timeout: 3), "Next button should exist on multisig config screen")
multisigNextBtn.tap()
// MARK: 17 - Empty Cosigner Import Screen
let cosignerImportTitle = app.staticTexts["Import Cosigners"]
XCTAssertTrue(cosignerImportTitle.waitForExistence(timeout: 5), "Import Cosigners screen should appear")
sleep(1)
snapshot("17-CosignerImport-Empty")
// MARK: Fill Cosigner 1
// Type fingerprint into TextField, press Return to dismiss its keyboard.
// Then type xpub directly into the TextEditor (avoids the system clipboard
// permission prompt), and dismiss via swipeDown (scrollDismissesKeyboard).
// Do NOT press Return in the TextEditor it inserts a newline that would
// corrupt the xpub and fail BDK descriptor parsing.
let fpField1 = app.textFields["e.g. 73c5da0a"]
XCTAssertTrue(fpField1.waitForExistence(timeout: 3), "Fingerprint field should exist")
fpField1.tap()
fpField1.typeText("07d25f0c")
app.keyboards.buttons["Return"].tap()
let xpubEditor1 = app.textViews.firstMatch
XCTAssertTrue(xpubEditor1.waitForExistence(timeout: 3), "Xpub text editor should exist")
xpubEditor1.tap()
xpubEditor1.typeText("tpubDE2gU1F6b1GXDg2bFjeq6RUnBmAe2moTNG7x47Cga3VnVnm7EJWLdJE73ZL2MEwKTc2dLNeSudXUjexm2xJ5qboosbnEb1SEiGyJtJcqqZK")
// Dismiss keyboard by tapping a non-field area
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
sleep(1)
// MARK: 18 - Cosigner 1 Filled (no keyboard)
snapshot("18-CosignerImport-Cosigner1")
let nextCosignerBtn1 = app.buttons["Next Cosigner"]
XCTAssertTrue(nextCosignerBtn1.waitForExistence(timeout: 3), "Next Cosigner button should exist")
nextCosignerBtn1.tap()
sleep(1)
// MARK: Fill Cosigner 2
let fpField2 = app.textFields["e.g. 73c5da0a"]
XCTAssertTrue(fpField2.waitForExistence(timeout: 3), "Fingerprint field should exist for cosigner 2")
fpField2.tap()
fpField2.typeText("d73869a4")
app.keyboards.buttons["Return"].tap()
let xpubEditor2 = app.textViews.firstMatch
XCTAssertTrue(xpubEditor2.waitForExistence(timeout: 3), "Xpub text editor should exist for cosigner 2")
xpubEditor2.tap()
xpubEditor2.typeText("tpubDET5GnMK8Zr7UH63ni72etKd7ZYxVq8NvtSneNBfEDJ7YtnSHUmiPCaBYXzCdR6ZBKWvBMXT3urCVp7sLmG6z8VTpdFRJuW4VL7xjHdLFpY")
// Dismiss keyboard by tapping a non-field area. Do not use app.swipeDown()
// here: the setup wizard is inside a sheet, and a full-app swipe-down
// starts the sheet-dismiss gesture, leaving the sheet in a partially-
// dragged state where the subsequent "Next Cosigner" tap fails to advance.
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
sleep(1)
let nextCosignerBtn2 = app.buttons["Next Cosigner"]
XCTAssertTrue(nextCosignerBtn2.waitForExistence(timeout: 3), "Next Cosigner button should exist for cosigner 2")
nextCosignerBtn2.tap()
sleep(1)
// MARK: Fill Cosigner 3
// Verify we actually advanced to cosigner 3 before proceeding, so a future
// regression in the cosigner-2 -> cosigner-3 transition fails here rather
// than producing a mislabeled screenshot.
let cosigner3Header = app.staticTexts["Cosigner 3 of 3"]
XCTAssertTrue(cosigner3Header.waitForExistence(timeout: 3), "Should have advanced to Cosigner 3 of 3")
let fpField3 = app.textFields["e.g. 73c5da0a"]
XCTAssertTrue(fpField3.waitForExistence(timeout: 3), "Fingerprint field should exist for cosigner 3")
fpField3.tap()
fpField3.typeText("e3870581")
app.keyboards.buttons["Return"].tap()
let xpubEditor3 = app.textViews.firstMatch
XCTAssertTrue(xpubEditor3.waitForExistence(timeout: 3), "Xpub text editor should exist for cosigner 3")
xpubEditor3.tap()
xpubEditor3.typeText("tpubDF3GwUrMb5WkigsDUpUWUADH55G3Ez771QujmFqeyrNEPD7onkqTwCsCEjNRbSrbD9VYKDfMHfg7bajem5aEX7CyMp2q5fvQzacy75bUesQ")
// Dismiss keyboard by tapping a non-field area
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
sleep(1)
// MARK: 19 - Cosigner 3 Filled (no keyboard)
snapshot("19-CosignerImport-Cosigner3")
let continueBtn = app.buttons["Continue"]
XCTAssertTrue(continueBtn.waitForExistence(timeout: 3), "Continue button should exist")
continueBtn.tap()
// MARK: 20 - Wallet Name
let nameWalletTitle = app.staticTexts["Name Your Wallet"]
XCTAssertTrue(nameWalletTitle.waitForExistence(timeout: 10), "Wallet name screen should appear")
let newWalletNameField = app.textFields["My Wallet"]
XCTAssertTrue(newWalletNameField.waitForExistence(timeout: 3), "Wallet name text field should exist")
newWalletNameField.tap()
newWalletNameField.typeText("My New Wallet")
app.swipeDown()
sleep(1)
snapshot("20-WalletName")
let walletNameNextBtn = app.buttons["Next"]
XCTAssertTrue(walletNameNextBtn.waitForExistence(timeout: 3), "Next button should exist on wallet name screen")
walletNameNextBtn.tap()
// MARK: 21 - Verify Wallet (top summary + cosigners)
let verifyWalletTitle = app.staticTexts["Verify Wallet"]
XCTAssertTrue(verifyWalletTitle.waitForExistence(timeout: 30), "Verify Wallet screen should appear")
sleep(2)
snapshot("21-VerifyWallet-Top")
// Scroll up a controlled amount to land at the "Back Up Your Descriptor"
// section. swipeUp(velocity: .slow) overshoots by ~60pt, so use a
// fixed-distance coordinate drag instead.
let dragStart = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.72))
let dragEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.42))
dragStart.press(forDuration: 0.05, thenDragTo: dragEnd)
sleep(1)
// MARK: 22 - Verify Wallet (backup section)
snapshot("22-VerifyWallet-Backup")
// Scroll to bring "Verify Receive Address" section to the top
app.swipeUp()
sleep(1)
// MARK: 23 - Verify Wallet (receive address section)
snapshot("23-VerifyWallet-Verify")
// Tap "Create Wallet"
let createWalletFinalBtn = app.buttons["Create Wallet"]
XCTAssertTrue(createWalletFinalBtn.waitForExistence(timeout: 5), "Create Wallet button should exist")
createWalletFinalBtn.tap()
// MARK: 24 - New Wallet syncing
// Capture the transaction screen ~3 seconds into the sync (sheet animates
// away in ~1s, then sync starts total sleep of 4s lands mid-sync).
snapshot("24-NewWalletLoading")
// MARK: - Send Flow Screenshots
// Navigate to Send tab
let sendTabFlow = app.tabBars.buttons["Send"]
XCTAssertTrue(sendTabFlow.waitForExistence(timeout: 5), "Send tab should exist")
sendTabFlow.tap()
_ = app.staticTexts["Send"].waitForExistence(timeout: 5)
sleep(1)
// Dismiss any resume signing card if present
let noBtn = app.buttons["No"]
if noBtn.waitForExistence(timeout: 2) {
noBtn.tap()
sleep(1)
}
// MARK: Fill Recipient 1
// Type address directly into the address field (do not use Paste button)
let addressField = app.textFields.matching(NSPredicate(format: "placeholderValue CONTAINS 'tb1'")).firstMatch
XCTAssertTrue(addressField.waitForExistence(timeout: 5), "Address text field should exist")
addressField.tap()
addressField.typeText("tb1qkmp8r90rcqpzdm6uqy2034j30csd902ynk35pezwg3sag6604xystkkazg")
// Dismiss keyboard
app.swipeDown()
sleep(1)
// Type label
let labelField = app.textFields["Label (optional)"]
XCTAssertTrue(labelField.waitForExistence(timeout: 3), "Label field should exist")
labelField.tap()
labelField.typeText("Test Transaction")
// Dismiss keyboard
app.swipeDown()
sleep(1)
// Type sats amount
let amountField = app.textFields["0"]
XCTAssertTrue(amountField.waitForExistence(timeout: 3), "Amount field should exist")
amountField.tap()
amountField.typeText("71234")
// Dismiss keyboard
app.swipeDown()
sleep(1)
// Expand Fee card by tapping the fee header area
let feeLabel = app.staticTexts["Fee"]
XCTAssertTrue(feeLabel.waitForExistence(timeout: 3), "Fee label should exist")
feeLabel.tap()
sleep(1)
// Select Custom fee
let customLabel = app.staticTexts["Custom"]
XCTAssertTrue(customLabel.waitForExistence(timeout: 3), "Custom fee option should exist")
customLabel.tap()
sleep(1)
// Type custom fee rate
let customFeeField = app.textFields["0.0"]
XCTAssertTrue(customFeeField.waitForExistence(timeout: 3), "Custom fee text field should exist")
customFeeField.tap()
// Clear any existing text and type new value
customFeeField.typeText("2.5")
// Dismiss keyboard
app.swipeDown()
sleep(1)
// Collapse fee card by tapping the fee header again
feeLabel.tap()
sleep(1)
// MARK: 25 - Send Recipients Filled
snapshot("25-SendRecipientsFilled")
// MARK: Tap Review
let reviewButton = app.buttons["Review"]
XCTAssertTrue(reviewButton.waitForExistence(timeout: 5), "Review button should exist")
reviewButton.tap()
// Wait for the Review Transaction screen
let reviewTitle = app.staticTexts["Review Transaction"]
XCTAssertTrue(reviewTitle.waitForExistence(timeout: 15), "Review Transaction screen should appear")
sleep(1)
// MARK: 26 - Review Transaction (top)
snapshot("26-ReviewTransaction-Top")
// Scroll to the bottom of the review screen
app.swipeUp()
sleep(1)
// MARK: 27 - Review Transaction (bottom)
snapshot("27-ReviewTransaction-Bottom")
// Tap "Show QR for Signing"
let showQRBtn = app.buttons["Show QR for Signing"]
XCTAssertTrue(showQRBtn.waitForExistence(timeout: 5), "Show QR for Signing button should exist")
showQRBtn.tap()
// Wait for the PSBT Display / signing QR screen
let scanSignedBtn = app.buttons["Scan Signed PSBT"]
XCTAssertTrue(scanSignedBtn.waitForExistence(timeout: 15), "Scan Signed PSBT button should appear on QR display")
sleep(2)
// MARK: 28 - PSBT QR Display (animated QR showing)
snapshot("28-PSBTQRDisplay")
// Expand Advanced section
let advancedToggle = app.staticTexts["Advanced"]
XCTAssertTrue(advancedToggle.waitForExistence(timeout: 3), "Advanced disclosure group should exist")
advancedToggle.tap()
sleep(1)
// Quarter-scroll to show Advanced settings below the QR
let qtrStart = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.75))
let qtrEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.50))
qtrStart.press(forDuration: 0.05, thenDragTo: qtrEnd)
sleep(1)
// MARK: 29 - PSBT QR Display (Advanced expanded)
snapshot("29-PSBTQRDisplay-Advanced")
// Tap "Scan Signed PSBT" button to go to scan screen
let scanBtn = app.buttons["Scan Signed PSBT"]
XCTAssertTrue(scanBtn.waitForExistence(timeout: 5), "Scan Signed PSBT button should exist")
scanBtn.tap()
// Wait for the Scan Signed PSBT screen
let scanTitle = app.staticTexts["Scan Signed PSBT"]
XCTAssertTrue(scanTitle.waitForExistence(timeout: 5), "Scan Signed PSBT screen should appear")
sleep(1)
// MARK: 30 - Scan Signed PSBT Screen
snapshot("30-ScanSignedPSBT")
// Go back to QR Display
let backToQR = app.buttons["Back to QR Display"]
XCTAssertTrue(backToQR.waitForExistence(timeout: 3), "Back to QR Display button should exist")
backToQR.tap()
sleep(1)
// Tap "Save PSBT"
let savePSBTBtn = app.buttons["Save PSBT"]
XCTAssertTrue(savePSBTBtn.waitForExistence(timeout: 5), "Save PSBT button should exist")
savePSBTBtn.tap()
// Wait for the Save PSBT alert to appear
let saveAlert = app.alerts["Save PSBT"]
XCTAssertTrue(saveAlert.waitForExistence(timeout: 5), "Save PSBT alert should appear")
sleep(1)
// MARK: 31 - Save PSBT Dialog
snapshot("31-SavePSBT")
}
}

View File

@ -1,313 +0,0 @@
//
// SnapshotHelper.swift
// Example
//
// Created by Felix Krause on 10/8/15.
//
// -----------------------------------------------------
// IMPORTANT: When modifying this file, make sure to
// increment the version number at the very
// bottom of the file to notify users about
// the new SnapshotHelper.swift
// -----------------------------------------------------
import Foundation
import XCTest
@MainActor
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
}
@MainActor
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
if waitForLoadingIndicator {
Snapshot.snapshot(name)
} else {
Snapshot.snapshot(name, timeWaitingForIdle: 0)
}
}
/// - Parameters:
/// - name: The name of the snapshot
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
@MainActor
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
}
enum SnapshotError: Error, CustomDebugStringConvertible {
case cannotFindSimulatorHomeDirectory
case cannotRunOnPhysicalDevice
var debugDescription: String {
switch self {
case .cannotFindSimulatorHomeDirectory:
"Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
case .cannotRunOnPhysicalDevice:
"Can't use Snapshot on a physical device."
}
}
}
@objcMembers
@MainActor
open class Snapshot: NSObject {
static var app: XCUIApplication?
static var waitForAnimations = true
static var cacheDirectory: URL?
static var screenshotsDirectory: URL? {
cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
}
static var deviceLanguage = ""
static var currentLocale = ""
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.app = app
Snapshot.waitForAnimations = waitForAnimations
do {
let cacheDir = try getCacheDirectory()
Snapshot.cacheDirectory = cacheDir
setLanguage(app)
setLocale(app)
setLaunchArguments(app)
} catch {
NSLog(error.localizedDescription)
}
}
class func setLanguage(_ app: XCUIApplication) {
guard let cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("language.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
} catch {
NSLog("Couldn't detect/set language...")
}
}
class func setLocale(_ app: XCUIApplication) {
guard let cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("locale.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
} catch {
NSLog("Couldn't detect/set locale...")
}
if currentLocale.isEmpty, !deviceLanguage.isEmpty {
currentLocale = Locale(identifier: deviceLanguage).identifier
}
if !currentLocale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
}
}
class func setLaunchArguments(_ app: XCUIApplication) {
guard let cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
do {
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
let results = matches.map { result -> String in
(launchArguments as NSString).substring(with: result.range)
}
app.launchArguments += results
} catch {
NSLog("Couldn't detect/set launch_arguments...")
}
}
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
if timeout > 0 {
waitForLoadingIndicatorToDisappear(within: timeout)
}
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
if Snapshot.waitForAnimations {
sleep(1) // Waiting for the animation to be finished (kind of)
}
#if os(OSX)
guard let app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
#else
guard self.app != nil else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let screenshot = XCUIScreen.main.screenshot()
#if os(iOS) && !targetEnvironment(macCatalyst)
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
#else
let image = screenshot.image
#endif
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
do {
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
let range = NSRange(location: 0, length: simulator.count)
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
#if swift(<5.0)
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
#else
try image.pngData()?.write(to: path, options: .atomic)
#endif
} catch {
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
NSLog(error.localizedDescription)
}
#endif
}
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
#if os(watchOS)
return image
#else
if #available(iOS 10.0, *) {
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
return renderer.image { _ in
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
}
} else {
return image
}
#endif
}
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
#if os(tvOS)
return
#endif
guard let app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
}
class func getCacheDirectory() throws -> URL {
let cachePath = "Library/Caches/tools.fastlane"
// on OSX config is stored in /Users/<username>/Library
// and on iOS/tvOS/WatchOS it's in simulator's home dir
#if os(OSX)
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
return homeDir.appendingPathComponent(cachePath)
#elseif arch(i386) || arch(x86_64) || arch(arm64)
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
throw SnapshotError.cannotFindSimulatorHomeDirectory
}
let homeDir = URL(fileURLWithPath: simulatorHostHome)
return homeDir.appendingPathComponent(cachePath)
#else
throw SnapshotError.cannotRunOnPhysicalDevice
#endif
}
}
private extension XCUIElementAttributes {
var isNetworkLoadingIndicator: Bool {
if hasAllowListedIdentifier { return false }
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
}
var hasAllowListedIdentifier: Bool {
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
return allowListedIdentifiers.contains(identifier)
}
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
if elementType == .statusBar { return true }
guard frame.origin == .zero else { return false }
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
}
}
private extension XCUIElementQuery {
var networkLoadingIndicators: XCUIElementQuery {
let isNetworkLoadingIndicator = NSPredicate { evaluatedObject, _ in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isNetworkLoadingIndicator
}
return containing(isNetworkLoadingIndicator)
}
@MainActor
var deviceStatusBars: XCUIElementQuery {
guard let app = Snapshot.app else {
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
}
let deviceWidth = app.windows.firstMatch.frame.width
let isStatusBar = NSPredicate { evaluatedObject, _ in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isStatusBar(deviceWidth)
}
return containing(isStatusBar)
}
}
private extension CGFloat {
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
numberA ... numberB ~= self
}
}
// Please don't remove the lines below
// They are used to detect outdated configuration files
// SnapshotHelperVersion [1.30]

View File

@ -1,160 +0,0 @@
default_platform(:ios)
DERIVED_DATA = File.expand_path("../build/DerivedData", __dir__)
PRODUCTS_DIR = "#{DERIVED_DATA}/Build/Products"
SCREENSHOT_SRC = File.expand_path("~/Library/Caches/tools.fastlane/screenshots")
# The xctestrun manifest produced by `build-for-testing`.
# Glob because the filename embeds the SDK version.
def xctestrun_path
Dir.glob("#{PRODUCTS_DIR}/*.xctestrun").first ||
UI.user_error!("No .xctestrun found — run the build step first")
end
# Device matrix — must match what is available in Simulator.
DEVICES = {
"iPhone 17 Pro Max" => "platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4",
"iPhone 17 Pro" => "platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4",
"iPhone 11 Pro Max" => "platform=iOS Simulator,name=iPhone 11 Pro Max,OS=26.4",
"iPhone 13 mini" => "platform=iOS Simulator,name=iPhone 13 mini,OS=26.4",
}
platform :ios do
desc "Capture App Store + marketing screenshots (dark first, then light)"
lane :screenshots do
# ── 0. Clear previous output so stale files don't accumulate ────────
FileUtils.rm_rf(File.expand_path("screenshots/dark", __dir__))
FileUtils.rm_rf(File.expand_path("screenshots/light", __dir__))
# ── 1. Build for testing once ────────────────────────────────────────
# Produces birch.app, birchUITests-Runner.app, and the
# .xctestrun manifest that tells xcodebuild which apps to install.
sh("xcodebuild build-for-testing " \
"-scheme birch " \
"-project ../birch.xcodeproj " \
"-destination 'generic/platform=iOS Simulator' " \
"-derivedDataPath '#{DERIVED_DATA}' " \
"-parallel-testing-enabled NO " \
"| xcpretty")
# ── 2. Dark mode ────────────────────────────────────────────────────
run_screenshot_pass(mode: "dark")
# ── 3. Light mode ───────────────────────────────────────────────────
run_screenshot_pass(mode: "light")
# ── 4. Prep captures for frameit ────────────────────────────────────
# frameit gem 2.232.2 hardcodes its device list. scripts/patch-frameit.rb
# extends it with iPhone 16/17 support (PR #29921), so iPhone 17 Pro and
# Pro Max now go through frameit at their native resolution.
#
# * iPhone 13 mini: frameit's bundled 13 Mini frame PNG has a ~3-pixel
# misalignment between the placement offset and the actual screen
# hole, leaving a visible gap on the right edge. Skip frameit for
# this device — step 7 composites it directly with ImageMagick,
# upscaling slightly so the screenshot fully covers the hole.
thirteen_mini_holding = File.expand_path("screenshots/_13mini_bare", __dir__)
FileUtils.rm_rf(thirteen_mini_holding)
FileUtils.mkdir_p(thirteen_mini_holding)
["dark", "light"].each do |mode|
dir = File.expand_path("screenshots/#{mode}/en-US", __dir__)
# Move 13 mini captures out of frameit's path; remember the mode.
mode_holding = "#{thirteen_mini_holding}/#{mode}"
FileUtils.mkdir_p(mode_holding)
Dir.glob("#{dir}/iPhone 13 mini-*.png").each do |src|
next if src.include?("_framed")
FileUtils.mv(src, "#{mode_holding}/#{File.basename(src)}")
end
end
# ── 5. Frame both passes via frameit (11 Pro Max + 17 Pro Max) ─────
frameit(path: "./fastlane/screenshots/dark", use_platform: "IOS")
frameit(path: "./fastlane/screenshots/light", use_platform: "IOS")
# ── 6. Restore original names and separate framed into subfolder ───
["dark", "light"].each do |mode|
src_dir = File.expand_path("screenshots/#{mode}/en-US", __dir__)
framed_dir = File.expand_path("screenshots/#{mode}/framed", __dir__)
FileUtils.mkdir_p(framed_dir)
# Move all _framed.png files into framed/.
Dir.glob("#{src_dir}/*_framed.png").each do |f|
FileUtils.mv(f, "#{framed_dir}/#{File.basename(f)}")
end
end
# ── 7. Custom-frame iPhone 13 mini via ImageMagick ─────────────────
# Composite each bare capture onto the 13 Mini bezel, upscaling slightly
# (1080×2340 → 1086×2353) so the screenshot fully covers the bezel's
# screen hole and no gap shows through on any edge.
mini_frame = File.expand_path("~/.fastlane/frameit/latest/Apple iPhone 13 Mini Midnight.png")
["dark", "light"].each do |mode|
src_dir = File.expand_path("screenshots/#{mode}/en-US", __dir__)
framed_dir = File.expand_path("screenshots/#{mode}/framed", __dir__)
mode_holding = "#{thirteen_mini_holding}/#{mode}"
Dir.glob("#{mode_holding}/*.png").each do |src|
base = File.basename(src, ".png")
framed = "#{framed_dir}/#{base}_framed.png"
sh("magick '#{mini_frame}' \\( '#{src}' -resize 1086x2353! \\) " \
"-gravity center -composite '#{framed}'")
# Restore bare capture to en-US/ for the normal bare output tree
FileUtils.mv(src, "#{src_dir}/#{base}.png")
end
end
FileUtils.rm_rf(thirteen_mini_holding)
end
# ────────────────────────────────────────────────────────────────────────
private_lane :run_screenshot_pass do |options|
mode = options[:mode] # "dark" or "light"
is_dark = mode == "dark"
DEVICES.each do |name, destination|
UI.header("#{mode} mode — #{name}")
# Boot
sh("xcrun simctl boot '#{name}' 2>/dev/null || true")
sleep(5) # let SpringBoard settle
# Appearance
sh("xcrun simctl ui booted appearance #{mode}")
# Clean status bar: 9:41, full battery, full signal
sh("xcrun simctl status_bar booted override " \
"--time 09:41 --dataNetwork wifi --wifiMode active --wifiBars 3 " \
"--cellularMode active --operatorName '' --cellularBars 4 " \
"--batteryState charged --batteryLevel 100 2>/dev/null || true")
# Clear previous screenshots from the cache so we don't mix passes
FileUtils.rm_rf(SCREENSHOT_SRC)
FileUtils.mkdir_p(SCREENSHOT_SRC)
# Run the test with the explicit xctestrun manifest.
# This installs all DependentProductPaths (including birch.app)
# automatically — works around the Xcode 26 bug where
# `xcodebuild build test` / `test-without-building -scheme` fail to
# install the host app.
sh("xcodebuild test-without-building " \
"-xctestrun '#{xctestrun_path}' " \
"-destination '#{destination}' " \
"-only-testing:birchUITests/ScreenshotTests/testScreenshotTour " \
"-parallel-testing-enabled NO") do |status|
UI.error("Test failed on #{name} (#{mode} mode)") unless status.success?
end
# Collect screenshots into the output directory
output_dir = File.expand_path("screenshots/#{mode}/en-US", __dir__)
FileUtils.mkdir_p(output_dir)
Dir.glob("#{SCREENSHOT_SRC}/*.png").each do |src|
FileUtils.cp(src, output_dir)
end
# Reset status bar and shut down
sh("xcrun simctl status_bar booted clear 2>/dev/null || true")
sh("xcrun simctl shutdown '#{name}' 2>/dev/null || true")
end
end
end

View File

@ -1,51 +0,0 @@
# Devices to capture — full App Store iPhone set.
# iPad deferred until iPad support ships.
devices([
"iPhone 17 Pro Max",
"iPhone 17 Pro",
"iPhone 11 Pro Max",
"iPhone 13 mini",
])
languages(["en-US"])
# Xcode scheme that contains the UI test target.
scheme("birch")
# Only run the screenshot walker, not the assertion-heavy setup test.
test_target_name("birchUITests")
only_testing(["birchUITests/ScreenshotTests/testScreenshotTour"])
# Output directory is overridden per invocation in the Fastfile so we can split
# dark and light into sibling directories. This value is the default.
output_directory("./fastlane/screenshots")
# Wipe previous screenshots before each run so stale files don't linger.
clear_previous_screenshots(true)
# Clean status bar: 9:41, full battery, full wifi.
override_status_bar(true)
# Run the simulator with a visible window. Headless mode can cause
# "FBSApplicationLibrary returned nil" on newer Xcode/iOS versions because
# SpringBoard hasn't finished registering the app before the test launches.
headless(false)
# Avoid Electrum sync contention between simulators running in parallel.
concurrent_simulators(false)
# Fail fast — if one device fails, don't burn time on the rest.
stop_after_first_error(true)
# Launch arg read by birchApp.swift to wipe UserDefaults/keychain and
# use an in-memory SwiftData store. Every run starts at the Welcome screen.
launch_arguments(["-UITesting"])
# Use project-local derived data instead of fastlane's default /tmp path.
# The temp path causes xcodebuild to skip installing birch.app on the
# simulator (only the test runner gets installed), triggering
# "FBSApplicationLibrary returned nil" failures.
derived_data_path("./build/DerivedData")
# Disable parallel testing to prevent xcodebuild from cloning the simulator.
xcargs("-parallel-testing-enabled NO")

View File

@ -20,54 +20,37 @@
containerPortal = 3C9ACE1C2F5DED94009B00D0 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3C9ACE232F5DED94009B00D0;
remoteInfo = birch;
remoteInfo = hellbender;
};
3C9ACE3F2F5DED95009B00D0 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 3C9ACE1C2F5DED94009B00D0 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3C9ACE232F5DED94009B00D0;
remoteInfo = birch;
remoteInfo = hellbender;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
3C9ACE242F5DED94009B00D0 /* birch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = birch.app; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE342F5DED95009B00D0 /* birchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = birchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = birchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
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; };
/* 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 /* birch */ = {
3C9ACE262F5DED94009B00D0 /* hellbender */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CC0000010000000000000006 /* Exceptions for "birch" folder in "birch" target */,
);
path = birch;
path = hellbender;
sourceTree = "<group>";
};
3C9ACE372F5DED95009B00D0 /* birchTests */ = {
3C9ACE372F5DED95009B00D0 /* hellbenderTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = birchTests;
path = hellbenderTests;
sourceTree = "<group>";
};
3C9ACE412F5DED95009B00D0 /* birchUITests */ = {
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = birchUITests;
path = hellbenderUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
@ -105,10 +88,9 @@
3C9ACE1B2F5DED94009B00D0 = {
isa = PBXGroup;
children = (
CC0000010000000000000005 /* Config */,
3C9ACE262F5DED94009B00D0 /* birch */,
3C9ACE372F5DED95009B00D0 /* birchTests */,
3C9ACE412F5DED95009B00D0 /* birchUITests */,
3C9ACE262F5DED94009B00D0 /* hellbender */,
3C9ACE372F5DED95009B00D0 /* hellbenderTests */,
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */,
3C9ACE252F5DED94009B00D0 /* Products */,
);
sourceTree = "<group>";
@ -116,30 +98,19 @@
3C9ACE252F5DED94009B00D0 /* Products */ = {
isa = PBXGroup;
children = (
3C9ACE242F5DED94009B00D0 /* birch.app */,
3C9ACE342F5DED95009B00D0 /* birchTests.xctest */,
3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */,
3C9ACE242F5DED94009B00D0 /* hellbender.app */,
3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */,
3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 /* birch */ = {
3C9ACE232F5DED94009B00D0 /* hellbender */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birch" */;
buildConfigurationList = 3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbender" */;
buildPhases = (
3C9ACE202F5DED94009B00D0 /* Sources */,
3C9ACE212F5DED94009B00D0 /* Frameworks */,
@ -150,9 +121,9 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
3C9ACE262F5DED94009B00D0 /* birch */,
3C9ACE262F5DED94009B00D0 /* hellbender */,
);
name = birch;
name = hellbender;
packageProductDependencies = (
AA00000500000000000000D0 /* URKit */,
AA00000800000000000000D0 /* URUI */,
@ -160,13 +131,13 @@
3C1E1C452F7B0D99002FDAE2 /* BitcoinDevKit */,
3C1E1FA42F7B5F63002FDAE2 /* BitcoinDevKit */,
);
productName = birch;
productReference = 3C9ACE242F5DED94009B00D0 /* birch.app */;
productName = hellbender;
productReference = 3C9ACE242F5DED94009B00D0 /* hellbender.app */;
productType = "com.apple.product-type.application";
};
3C9ACE332F5DED95009B00D0 /* birchTests */ = {
3C9ACE332F5DED95009B00D0 /* hellbenderTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchTests" */;
buildConfigurationList = 3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderTests" */;
buildPhases = (
3C9ACE302F5DED95009B00D0 /* Sources */,
3C9ACE312F5DED95009B00D0 /* Frameworks */,
@ -178,18 +149,18 @@
3C9ACE362F5DED95009B00D0 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
3C9ACE372F5DED95009B00D0 /* birchTests */,
3C9ACE372F5DED95009B00D0 /* hellbenderTests */,
);
name = birchTests;
name = hellbenderTests;
packageProductDependencies = (
);
productName = birchTests;
productReference = 3C9ACE342F5DED95009B00D0 /* birchTests.xctest */;
productName = hellbenderTests;
productReference = 3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
3C9ACE3D2F5DED95009B00D0 /* birchUITests */ = {
3C9ACE3D2F5DED95009B00D0 /* hellbenderUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchUITests" */;
buildConfigurationList = 3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderUITests" */;
buildPhases = (
3C9ACE3A2F5DED95009B00D0 /* Sources */,
3C9ACE3B2F5DED95009B00D0 /* Frameworks */,
@ -201,13 +172,13 @@
3C9ACE402F5DED95009B00D0 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
3C9ACE412F5DED95009B00D0 /* birchUITests */,
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */,
);
name = birchUITests;
name = hellbenderUITests;
packageProductDependencies = (
);
productName = birchUITests;
productReference = 3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */;
productName = hellbenderUITests;
productReference = 3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
@ -233,7 +204,7 @@
};
};
};
buildConfigurationList = 3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "birch" */;
buildConfigurationList = 3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "hellbender" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
@ -253,9 +224,9 @@
projectDirPath = "";
projectRoot = "";
targets = (
3C9ACE232F5DED94009B00D0 /* birch */,
3C9ACE332F5DED95009B00D0 /* birchTests */,
3C9ACE3D2F5DED95009B00D0 /* birchUITests */,
3C9ACE232F5DED94009B00D0 /* hellbender */,
3C9ACE332F5DED95009B00D0 /* hellbenderTests */,
3C9ACE3D2F5DED95009B00D0 /* hellbenderUITests */,
);
};
/* End PBXProject section */
@ -311,12 +282,12 @@
/* Begin PBXTargetDependency section */
3C9ACE362F5DED95009B00D0 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3C9ACE232F5DED94009B00D0 /* birch */;
target = 3C9ACE232F5DED94009B00D0 /* hellbender */;
targetProxy = 3C9ACE352F5DED95009B00D0 /* PBXContainerItemProxy */;
};
3C9ACE402F5DED95009B00D0 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3C9ACE232F5DED94009B00D0 /* birch */;
target = 3C9ACE232F5DED94009B00D0 /* hellbender */;
targetProxy = 3C9ACE3F2F5DED95009B00D0 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
@ -324,35 +295,190 @@
/* 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 = {
CURRENT_PROJECT_VERSION = 26;
MARKETING_VERSION = 0.2.0;
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";
};
name = Debug;
};
3C9ACE4A2F5DED95009B00D0 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
buildSettings = {
CURRENT_PROJECT_VERSION = 26;
MARKETING_VERSION = 0.2.0;
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";
};
name = Release;
};
@ -370,7 +496,7 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/birch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/birch";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hellbender.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hellbender";
};
name = Debug;
};
@ -388,7 +514,7 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/birch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/birch";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hellbender.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hellbender";
};
name = Release;
};
@ -404,7 +530,7 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = birch;
TEST_TARGET_NAME = hellbender;
};
name = Debug;
};
@ -420,14 +546,14 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = birch;
TEST_TARGET_NAME = hellbender;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "birch" */ = {
3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "hellbender" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3C9ACE462F5DED95009B00D0 /* Debug */,
@ -436,7 +562,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birch" */ = {
3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbender" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3C9ACE492F5DED95009B00D0 /* Debug */,
@ -445,7 +571,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchTests" */ = {
3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3C9ACE4C2F5DED95009B00D0 /* Debug */,
@ -454,7 +580,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchUITests" */ = {
3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3C9ACE4F2F5DED95009B00D0 /* Debug */,

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import Foundation
import SwiftUI
// MARK: - Denomination
@ -58,29 +57,6 @@ 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 {

View File

@ -6,7 +6,6 @@ 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 ?? "birch", category: "BitcoinService")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "BitcoinService")
@Observable
@MainActor
@ -690,8 +690,7 @@ final class BitcoinService {
vout: output.outpoint.vout,
amount: output.txout.value.toSat(),
isConfirmed: confirmed,
keychain: output.keychain == .external ? .external : .internal,
derivationIndex: output.derivationIndex
keychain: output.keychain == .external ? .external : .internal
)
}.sorted { u0, u1 in
let isUnconfirmed0 = !u0.isConfirmed
@ -1333,95 +1332,32 @@ final class BitcoinService {
) -> String {
let chain = isChange ? "1" : "0"
let coinType = network.coinType
let isTestnet = network != .mainnet
let normalized = cosigners.map { cosigner -> (xpub: String, fingerprint: String, derivationPath: String) in
let raw = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let xpub = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
return (xpub: xpub, fingerprint: cosigner.fingerprint, derivationPath: cosigner.derivationPath)
}
let sorted = normalized.sorted { $0.xpub < $1.xpub }
let sorted = cosigners.sorted { $0.xpub < $1.xpub }
let keys = sorted.map { cosigner in
"[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(cosigner.xpub)/\(chain)/*"
let xpub = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(xpub)/\(chain)/*"
}.joined(separator: ",")
return "wsh(sortedmulti(\(requiredSignatures),\(keys)))"
}
/// Build a combined output descriptor with <0;1>/* multipath notation and BIP-380 checksum
/// Build a combined output descriptor with <0;1>/* multipath notation
static func buildCombinedDescriptor(
requiredSignatures: Int,
cosigners: [(xpub: String, fingerprint: String, derivationPath: String)],
network: BitcoinNetwork
) -> String {
let coinType = network.coinType
let isTestnet = network != .mainnet
// Normalize each cosigner xpub to standard xpub/tpub format (BDK descriptor
// parser does not accept SLIP132-tagged Vpub/Zpub/Ypub/Upub keys).
let normalized = cosigners.map { cosigner -> (xpub: String, fingerprint: String, derivationPath: String) in
let raw = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let xpub = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
return (xpub: xpub, fingerprint: cosigner.fingerprint, derivationPath: cosigner.derivationPath)
}
let sorted = normalized.sorted { $0.xpub < $1.xpub }
let sorted = cosigners.sorted { $0.xpub < $1.xpub }
let keys = sorted.map { cosigner in
"[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(cosigner.xpub)/<0;1>/*"
let xpub = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(xpub)/<0;1>/*"
}.joined(separator: ",")
let raw = "wsh(sortedmulti(\(requiredSignatures),\(keys)))"
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
return "wsh(sortedmulti(\(requiredSignatures),\(keys)))"
}
// MARK: - Helpers

View File

@ -126,7 +126,7 @@ enum DescriptorPDFGenerator {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
filter.correctionLevel = "L"
filter.correctionLevel = "M"
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 ?? "birch", category: "FiatPriceService")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "FiatPriceService")
enum FiatSource: String, CaseIterable {
case zeus

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,6 @@ final class SetupWizardViewModel {
case descriptorImport
case walletName
case review
case verify
}
enum CreationMode {
@ -47,10 +46,6 @@ 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 = ""
@ -96,7 +91,7 @@ final class SetupWizardViewModel {
/// Progress
var stepCount: Int {
creationMode == .createNew ? 6 : 3
creationMode == .createNew ? 5 : 3
}
var currentStepIndex: Int {
@ -108,7 +103,6 @@ final class SetupWizardViewModel {
case .descriptorImport: 2
case .walletName: creationMode == .createNew ? 4 : 3
case .review: stepCount - 1
case .verify: stepCount - 1
}
}
@ -193,17 +187,13 @@ final class SetupWizardViewModel {
func buildDescriptors() {
guard allCosignersComplete else { return }
// Build key origin strings normalize to standard xpub/tpub format before
// sorting so BIP67 ordering matches what's emitted in the descriptor.
let isTestnet = network != .mainnet
// Build key origin strings and sort by xpub (BIP67 lexicographic sort)
var keyEntries: [(origin: String, xpub: String, fingerprint: String, path: String, label: String, index: Int)] = []
for i in 0 ..< totalCosigners {
let raw = cosignerXpubs[i].trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let normalized = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
keyEntries.append((
origin: "[\(cosignerFingerprints[i])/48'/\(network.coinType)'/0'/2']",
xpub: normalized,
xpub: cosignerXpubs[i],
fingerprint: cosignerFingerprints[i],
path: cosignerDerivationPaths[i],
label: cosignerLabels[i],
@ -215,48 +205,18 @@ final class SetupWizardViewModel {
keyEntries.sort { $0.xpub < $1.xpub }
let externalKeys = keyEntries.map {
"\($0.origin)\($0.xpub)/0/*"
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/0/*"
}.joined(separator: ",")
let internalKeys = keyEntries.map {
"\($0.origin)\($0.xpub)/1/*"
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/1/*"
}.joined(separator: ",")
externalDescriptor = "wsh(sortedmulti(\(requiredSignatures),\(externalKeys)))"
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) {
@ -432,11 +392,8 @@ final class SetupWizardViewModel {
}
currentStep = .walletName
case .walletName:
deriveFirstAddress()
currentStep = .verify
currentStep = .review
case .review:
break // unused in current flow
case .verify:
break // handled by saveWallet
}
}
@ -454,7 +411,6 @@ 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 ?? "birch", category: "WalletManager")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "WalletManager")
@Observable
@MainActor

View File

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

View File

@ -14,7 +14,6 @@ struct HBTheme {
var heroBackground: Color
var success: Color
var error: Color
var secondaryAccent: Color
var colorScheme: ColorScheme? = .dark
static let system = HBTheme(
@ -28,7 +27,6 @@ struct HBTheme {
heroBackground: Color(.tertiarySystemBackground),
success: Color(.systemGreen),
error: Color(.systemRed),
secondaryAccent: Color(.systemBlue),
colorScheme: nil
)
@ -43,7 +41,6 @@ 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
)
@ -58,37 +55,6 @@ 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
)
}
@ -99,16 +65,12 @@ enum AppTheme: String, CaseIterable {
case system
case dark
case light
case birchDark
case birchLight
var displayName: String {
switch self {
case .system: "System"
case .dark: "Hellbender Dark"
case .light: "Hellbender Light"
case .birchDark: "Birch Dark"
case .birchLight: "Birch Light"
case .dark: "Dark"
case .light: "Light"
}
}
@ -117,8 +79,6 @@ enum AppTheme: String, CaseIterable {
case .system: .system
case .dark: .dark
case .light: .light
case .birchDark: .birchDark
case .birchLight: .birchLight
}
}
}
@ -128,7 +88,7 @@ enum AppTheme: String, CaseIterable {
@Observable
final class ThemeManager {
static let shared = ThemeManager()
private(set) var theme: HBTheme = .birchDark
private(set) var theme: HBTheme = .dark
private init() {
let saved = UserDefaults.standard.string(forKey: Constants.themeKey) ?? AppTheme.system.rawValue
@ -143,7 +103,7 @@ final class ThemeManager {
/// Sets the displayed theme to the appropriate custom palette for the given OS color scheme.
/// Only used when the System theme is selected does not save to UserDefaults.
func applySystemColorScheme(_ colorScheme: ColorScheme) {
theme = colorScheme == .dark ? .birchDark : .birchLight
theme = colorScheme == .dark ? .dark : .light
}
}
@ -177,9 +137,7 @@ extension Color {
ThemeManager.shared.theme.accent
}
static var hbSteelBlue: Color {
ThemeManager.shared.theme.secondaryAccent
}
static let hbSteelBlue = Color(red: 0.290, green: 0.565, blue: 0.851) // #4A90D9 not themed
/// Semantic
static var hbSuccess: Color {
@ -288,21 +246,6 @@ extension View {
}
}
// MARK: - Themed App Icon
/// Renders the app-icon artwork that matches the current theme's light/dark appearance.
/// Uses `AppIconPreviewLight` on light color schemes, `AppIconPreviewDark` on dark.
/// The theme is applied via `.preferredColorScheme` at the root, so this works for
/// all AppTheme cases (system, birch light/dark).
struct ThemedAppIcon: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Image(colorScheme == .dark ? "AppIconPreviewDark" : "AppIconPreviewLight")
.resizable()
}
}
// MARK: - Network Badge
struct NetworkBadge: View {

View File

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

View File

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

View File

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

View File

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

View File

@ -37,9 +37,10 @@ struct ReceiveView: View {
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
viewModel.currentAddress.chunkedAddressText()
Text(viewModel.currentAddress)
.font(.hbMono(13))
.foregroundStyle(Color.hbTextPrimary)
.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 ?? "birch", category: "LabelService")
Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "LabelService")
.error("Failed to propagate change label: \(error.localizedDescription)")
}
}

View File

@ -338,11 +338,17 @@ 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)") {
recipient.address.chunkedAddressText(font: .hbMono(12))
Text(recipient.address)
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
.lineLimit(2)
}
} else {
ReviewItem(label: "To") {
recipient.address.chunkedAddressText(font: .hbMono(12))
Text(recipient.address)
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
.lineLimit(2)
}
}

View File

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

View File

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

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