Compare commits

..

1 Commits

Author SHA1 Message Date
Nick Klockenga
fd037e45ea add dark and light birch themes 2026-04-08 21:15:31 -04:00
136 changed files with 599 additions and 3360 deletions

View File

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

View File

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

View File

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

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

View File

@ -1,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

123
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
@ -65,7 +60,7 @@ All dependencies are managed via Swift Package Manager and resolve automatically
git clone https://github.com/newtonick/hellbender-wallet.git
cd hellbender-wallet
```
2. Open `birch.xcodeproj` in Xcode
2. Open `hellbender.xcodeproj` in Xcode
3. SPM dependencies resolve automatically on first open
4. Build and run on a simulator or device
@ -77,7 +72,7 @@ GitHub Actions runs `xcodebuild clean build analyze` on every push and pull requ
### Reproducible Builds
Birch supports **functionally equivalent** reproducible builds. Given the same source code and Xcode version, two independent builds will produce the same compiled logic after normalization. Certain metadata bytes (Mach-O UUIDs, timestamps, build-machine identifiers) are expected to differ and are zeroed by the normalization step.
Hellbender supports **functionally equivalent** reproducible builds. Given the same source code and Xcode version, two independent builds will produce the same compiled logic after normalization. Certain metadata bytes (Mach-O UUIDs, timestamps, build-machine identifiers) are expected to differ and are zeroed by the normalization step.
**What IS reproducible** (after normalization): all code-bearing sections, resources, and application logic.
@ -94,7 +89,7 @@ Birch supports **functionally equivalent** reproducible builds. Given the same s
./scripts/build-release.sh
```
This creates an unsigned archive at `/tmp/birch-build/birch.xcarchive`.
This creates an unsigned archive at `/tmp/hellbender-build/hellbender.xcarchive`.
#### Verifying two builds
@ -109,101 +104,9 @@ This creates an unsigned archive at `/tmp/birch-build/birch.xcarchive`.
The comparison exits 0 if the builds are functionally equivalent, 1 if code differences are found.
## Generating Screenshots
Birch uses [`fastlane snapshot`](https://docs.fastlane.tools/actions/snapshot/) to generate marketing and App Store screenshots. A single UI test walks the app from Welcome through the main tabs, capturing every major screen on each configured device in both dark and light mode.
### One-time setup
1. Install [Bundler](https://bundler.io/) if you don't already have it:
```bash
gem install bundler
```
2. Install fastlane via the project `Gemfile`:
```bash
bundle install
```
3. Install [ImageMagick](https://imagemagick.org/) — used to composite iPhone 13 mini screenshots onto their bezel (frameit's bundled 13 mini frame has a pixel-misalignment bug that leaves a visible gap, so we bypass frameit for that device):
```bash
brew install imagemagick
```
4. Patch the installed fastlane gem with iPhone 16/17 device support (from
[fastlane PR #29921](https://github.com/fastlane/fastlane/pull/29921)):
```bash
bundle exec ruby scripts/patch-frameit.rb
```
This is idempotent — safe to re-run. If you upgrade fastlane, the script
aborts with a clear error so you can review the patch for the new version.
On a fresh machine, also download the device frame PNGs before running
the patch (they live at `~/.fastlane/frameit/latest/` and are not in the repo):
```bash
bundle exec fastlane frameit download_frames
```
5. Make sure the required simulators are downloaded. The screenshot lane targets:
- **iPhone 17 Pro Max** (6.9")
- **iPhone 17 Pro** (6.3")
- **iPhone 11 Pro Max** (6.5")
- **iPhone 13 mini** (5.4")
You can trigger a download by booting them once in Xcode (**Window → Devices and Simulators → Simulators → +**) or via the command line:
```bash
xcrun simctl list devices | grep -E "iPhone 17 Pro Max|iPhone 17 Pro|iPhone 11 Pro Max|iPhone 13 mini"
```
### Running
From the repo root:
```bash
bundle exec fastlane screenshots
```
This runs the `screenshots` lane defined in [`fastlane/Fastfile`](fastlane/Fastfile), which:
1. Captures all 23 stops in **dark mode** first (the product's default aesthetic)
2. Captures the same stops in **light mode**
3. Moves iPhone 13 mini bare captures aside (frameit's bundled 13 mini frame has a pixel-misalignment bug)
4. Runs `frameit` to composite device bezels onto iPhone 17 Pro Max, iPhone 17 Pro, and iPhone 11 Pro Max screenshots
5. Moves every `*_framed.png` into a sibling `framed/` subfolder
6. Composites iPhone 13 mini captures onto the bezel directly with ImageMagick (upscaling to 1086×2353 so the screenshot fully covers the frame's screen hole) and writes the result into the same `framed/` directory
Output lands in:
```
fastlane/screenshots/
├── dark/
│ ├── en-US/
│ │ ├── iPhone 17 Pro Max-01-Welcome.png
│ │ ├── iPhone 17 Pro-01-Welcome.png
│ │ └── ...
│ └── framed/
│ ├── iPhone 17 Pro Max-01-Welcome_framed.png
│ ├── iPhone 17 Pro-01-Welcome_framed.png
│ └── ...
└── light/
└── ...
```
### How it works
- [`birchUITests/ScreenshotTests.swift`](birchUITests/ScreenshotTests.swift) is a dedicated XCUITest that walks the app. It reuses the existing `-UITesting` launch argument (defined in `birch/birchApp.swift`), which wipes `UserDefaults`/keychain and uses an in-memory SwiftData store so every run starts from a deterministic Welcome screen.
- The test imports a real testnet4 1-of-2 `wsh(sortedmulti(...))` descriptor with live history, waits for Electrum sync, then visits each screen.
- Dark/light mode is driven by the simulator's OS appearance (`xcrun simctl ui ... appearance`). The app's `RootView` follows the OS when the theme is set to `.system`, which it is by default after the `-UITesting` wipe, so no app-side toggle is required.
- The device matrix, scheme, status bar override, and other `snapshot` options live in [`fastlane/Snapfile`](fastlane/Snapfile). Device destinations (simulator OS version), the frameit pass, and the custom 13 mini ImageMagick composite all live in [`fastlane/Fastfile`](fastlane/Fastfile).
### Customizing
- **Add/remove devices:** edit both the `devices([...])` array in `fastlane/Snapfile` and the `DEVICES` hash in `fastlane/Fastfile`.
- **Change which screens are captured:** edit `testScreenshotTour` in `birchUITests/ScreenshotTests.swift` and add or remove `snapshot("NN-Name")` calls.
- **Skip framing:** remove the `frameit(...)` lines and the ImageMagick composite block (steps 47) from `fastlane/Fastfile` if you only need the bare PNGs.
> **Known workaround** (contained in `fastlane/Fastfile`): `frameit` gem 2.232.2's bundled iPhone 13 Mini frame PNG has a ~3-pixel placement-offset bug that leaves a visible edge gap, so 13 mini is composited directly with ImageMagick instead. iPhone 16/17 device support is patched in via `scripts/patch-frameit.rb` (see setup step 4 above).
## Links
- **Website**: [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 +114,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,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,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,54 @@
containerPortal = 3C9ACE1C2F5DED94009B00D0 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3C9ACE232F5DED94009B00D0;
remoteInfo = birch;
remoteInfo = hellbender;
};
3C9ACE3F2F5DED95009B00D0 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 3C9ACE1C2F5DED94009B00D0 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3C9ACE232F5DED94009B00D0;
remoteInfo = birch;
remoteInfo = hellbender;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
3C9ACE242F5DED94009B00D0 /* birch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = birch.app; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE342F5DED95009B00D0 /* birchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = birchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = birchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE242F5DED94009B00D0 /* hellbender.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = hellbender.app; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = hellbenderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = hellbenderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
CC0000010000000000000001 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; };
CC0000010000000000000002 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
CC0000010000000000000003 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
CC0000010000000000000004 /* Birch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Birch.xcconfig; sourceTree = "<group>"; };
CC0000010000000000000004 /* Hellbender.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Hellbender.xcconfig; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
CC0000010000000000000006 /* Exceptions for "birch" folder in "birch" target */ = {
CC0000010000000000000006 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 3C9ACE232F5DED94009B00D0 /* birch */;
target = 3C9ACE232F5DED94009B00D0 /* hellbender */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
3C9ACE262F5DED94009B00D0 /* birch */ = {
3C9ACE262F5DED94009B00D0 /* hellbender */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CC0000010000000000000006 /* Exceptions for "birch" folder in "birch" target */,
CC0000010000000000000006 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
);
path = birch;
path = hellbender;
sourceTree = "<group>";
};
3C9ACE372F5DED95009B00D0 /* birchTests */ = {
3C9ACE372F5DED95009B00D0 /* hellbenderTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = birchTests;
path = hellbenderTests;
sourceTree = "<group>";
};
3C9ACE412F5DED95009B00D0 /* birchUITests */ = {
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = birchUITests;
path = hellbenderUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
@ -106,9 +106,9 @@
isa = PBXGroup;
children = (
CC0000010000000000000005 /* Config */,
3C9ACE262F5DED94009B00D0 /* birch */,
3C9ACE372F5DED95009B00D0 /* birchTests */,
3C9ACE412F5DED95009B00D0 /* birchUITests */,
3C9ACE262F5DED94009B00D0 /* hellbender */,
3C9ACE372F5DED95009B00D0 /* hellbenderTests */,
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */,
3C9ACE252F5DED94009B00D0 /* Products */,
);
sourceTree = "<group>";
@ -116,9 +116,9 @@
3C9ACE252F5DED94009B00D0 /* Products */ = {
isa = PBXGroup;
children = (
3C9ACE242F5DED94009B00D0 /* birch.app */,
3C9ACE342F5DED95009B00D0 /* birchTests.xctest */,
3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */,
3C9ACE242F5DED94009B00D0 /* hellbender.app */,
3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */,
3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
@ -128,7 +128,7 @@
children = (
CC0000010000000000000001 /* Base.xcconfig */,
CC0000010000000000000002 /* Debug.xcconfig */,
CC0000010000000000000004 /* Birch.xcconfig */,
CC0000010000000000000004 /* Hellbender.xcconfig */,
CC0000010000000000000003 /* Release.xcconfig */,
);
path = Config;
@ -137,9 +137,9 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
3C9ACE232F5DED94009B00D0 /* birch */ = {
3C9ACE232F5DED94009B00D0 /* hellbender */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birch" */;
buildConfigurationList = 3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbender" */;
buildPhases = (
3C9ACE202F5DED94009B00D0 /* Sources */,
3C9ACE212F5DED94009B00D0 /* Frameworks */,
@ -150,9 +150,9 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
3C9ACE262F5DED94009B00D0 /* birch */,
3C9ACE262F5DED94009B00D0 /* hellbender */,
);
name = birch;
name = hellbender;
packageProductDependencies = (
AA00000500000000000000D0 /* URKit */,
AA00000800000000000000D0 /* URUI */,
@ -160,13 +160,13 @@
3C1E1C452F7B0D99002FDAE2 /* BitcoinDevKit */,
3C1E1FA42F7B5F63002FDAE2 /* BitcoinDevKit */,
);
productName = birch;
productReference = 3C9ACE242F5DED94009B00D0 /* birch.app */;
productName = hellbender;
productReference = 3C9ACE242F5DED94009B00D0 /* hellbender.app */;
productType = "com.apple.product-type.application";
};
3C9ACE332F5DED95009B00D0 /* birchTests */ = {
3C9ACE332F5DED95009B00D0 /* hellbenderTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchTests" */;
buildConfigurationList = 3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderTests" */;
buildPhases = (
3C9ACE302F5DED95009B00D0 /* Sources */,
3C9ACE312F5DED95009B00D0 /* Frameworks */,
@ -178,18 +178,18 @@
3C9ACE362F5DED95009B00D0 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
3C9ACE372F5DED95009B00D0 /* birchTests */,
3C9ACE372F5DED95009B00D0 /* hellbenderTests */,
);
name = birchTests;
name = hellbenderTests;
packageProductDependencies = (
);
productName = birchTests;
productReference = 3C9ACE342F5DED95009B00D0 /* birchTests.xctest */;
productName = hellbenderTests;
productReference = 3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
3C9ACE3D2F5DED95009B00D0 /* birchUITests */ = {
3C9ACE3D2F5DED95009B00D0 /* hellbenderUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchUITests" */;
buildConfigurationList = 3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderUITests" */;
buildPhases = (
3C9ACE3A2F5DED95009B00D0 /* Sources */,
3C9ACE3B2F5DED95009B00D0 /* Frameworks */,
@ -201,13 +201,13 @@
3C9ACE402F5DED95009B00D0 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
3C9ACE412F5DED95009B00D0 /* birchUITests */,
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */,
);
name = birchUITests;
name = hellbenderUITests;
packageProductDependencies = (
);
productName = birchUITests;
productReference = 3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */;
productName = hellbenderUITests;
productReference = 3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
@ -233,7 +233,7 @@
};
};
};
buildConfigurationList = 3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "birch" */;
buildConfigurationList = 3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "hellbender" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
@ -253,9 +253,9 @@
projectDirPath = "";
projectRoot = "";
targets = (
3C9ACE232F5DED94009B00D0 /* birch */,
3C9ACE332F5DED95009B00D0 /* birchTests */,
3C9ACE3D2F5DED95009B00D0 /* birchUITests */,
3C9ACE232F5DED94009B00D0 /* hellbender */,
3C9ACE332F5DED95009B00D0 /* hellbenderTests */,
3C9ACE3D2F5DED95009B00D0 /* hellbenderUITests */,
);
};
/* End PBXProject section */
@ -311,12 +311,12 @@
/* Begin PBXTargetDependency section */
3C9ACE362F5DED95009B00D0 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3C9ACE232F5DED94009B00D0 /* birch */;
target = 3C9ACE232F5DED94009B00D0 /* hellbender */;
targetProxy = 3C9ACE352F5DED95009B00D0 /* PBXContainerItemProxy */;
};
3C9ACE402F5DED95009B00D0 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3C9ACE232F5DED94009B00D0 /* birch */;
target = 3C9ACE232F5DED94009B00D0 /* hellbender */;
targetProxy = 3C9ACE3F2F5DED95009B00D0 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
@ -326,7 +326,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000002 /* Debug.xcconfig */;
buildSettings = {
DEVELOPMENT_TEAM = ZW85AH743B;
};
name = Debug;
};
@ -334,25 +333,20 @@
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000003 /* Release.xcconfig */;
buildSettings = {
DEVELOPMENT_TEAM = ZW85AH743B;
};
name = Release;
};
3C9ACE492F5DED95009B00D0 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
baseConfigurationReference = CC0000010000000000000004 /* Hellbender.xcconfig */;
buildSettings = {
CURRENT_PROJECT_VERSION = 26;
MARKETING_VERSION = 0.2.0;
};
name = Debug;
};
3C9ACE4A2F5DED95009B00D0 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
baseConfigurationReference = CC0000010000000000000004 /* Hellbender.xcconfig */;
buildSettings = {
CURRENT_PROJECT_VERSION = 26;
MARKETING_VERSION = 0.2.0;
};
name = Release;
};
@ -370,7 +364,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 +382,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 +398,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 +414,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 +430,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 +439,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 +448,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchUITests" */ = {
3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3C9ACE4F2F5DED95009B00D0 /* Debug */,

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

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

View File

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

View File

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

View File

@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import SwiftData
import SwiftUI
import URKit
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "WalletInfo")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "WalletInfo")
struct WalletInfoView: View {
@Environment(\.modelContext) private var modelContext
@ -471,7 +471,7 @@ struct WalletInfoView: View {
EditCosignersView(wallet: wallet)
}
.sheet(isPresented: $showDescriptorQR) {
DescriptorQRSheet(descriptor: combinedDescriptor, walletName: wallet.name)
DescriptorQRSheet(descriptor: combinedDescriptor)
}
.sheet(isPresented: $showDescriptorPDF) {
DescriptorPDFView(walletName: wallet.name, descriptor: combinedDescriptor)
@ -687,9 +687,10 @@ private struct EditCosignersView: View {
Text("Derivation Path")
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
Text(editableCosigners[index].derivationPath.isEmpty ? "" : editableCosigners[index].derivationPath)
TextField("m/48'/1'/0'/2'", text: $editableCosigners[index].derivationPath)
.font(.hbMono())
.frame(maxWidth: .infinity, alignment: .leading)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(12)
.background(Color.hbSurfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 8))
@ -883,15 +884,11 @@ private struct EditableCosigner: Identifiable {
private struct DescriptorQRSheet: View {
let descriptor: String
let walletName: String
let descriptorUR: UR?
@Environment(\.dismiss) private var dismiss
@State private var isExporting = false
@AppStorage(Constants.qrFrameRateKey) private var qrFrameRate: Double = 4.0
init(descriptor: String, walletName: String) {
init(descriptor: String) {
self.descriptor = descriptor
self.walletName = walletName
descriptorUR = try? URService.encodeCryptoOutput(descriptor: descriptor)
}
@ -917,22 +914,13 @@ private struct DescriptorQRSheet: View {
.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)
}
Button(action: {
UIPasteboard.general.string = descriptor
}) {
Label("Copy Descriptor", systemImage: "doc.on.doc")
.font(.hbBody(14))
.foregroundStyle(Color.hbSteelBlue)
}
.disabled(isExporting || descriptorUR == nil)
}
.padding(.top, 8)
}
@ -946,47 +934,6 @@ private struct DescriptorQRSheet: View {
}
}
}
private func exportAsMP4() {
guard let ur = descriptorUR else { return }
isExporting = true
Task {
do {
let url = try await QRVideoExporter.exportMP4(
ur: ur,
fileName: "\(walletName) Output Descriptor",
maxFragmentLen: 160,
fps: qrFrameRate,
loopCount: 3
)
await MainActor.run {
isExporting = false
presentShareSheet(url: url)
}
} catch {
await MainActor.run {
isExporting = false
}
}
}
}
private func presentShareSheet(url: URL) {
let activityVC = UIActivityViewController(
activityItems: [url],
applicationActivities: nil
)
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
var topVC = windowScene.windows.first?.rootViewController else { return }
while let presented = topVC.presentedViewController {
topVC = presented
}
if let popover = activityVC.popoverPresentationController {
popover.sourceView = topVC.view
popover.sourceRect = CGRect(x: topVC.view.bounds.midX, y: 0, width: 0, height: 0)
}
topVC.present(activityVC, animated: true)
}
}
private struct InfoRow: View {

View File

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

View File

@ -3,7 +3,7 @@ import SwiftData
import SwiftUI
import UniformTypeIdentifiers
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "TransactionListView")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "TransactionListView")
struct TransactionListView: View {
@Query private var wallets: [WalletProfile]
@ -231,7 +231,6 @@ struct TransactionListView: View {
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.accessibilityIdentifier("walletMenu")
Spacer()
@ -256,7 +255,6 @@ struct TransactionListView: View {
.strokeBorder(Color.hbBorder, lineWidth: 1)
)
}
.accessibilityIdentifier("walletPicker")
Spacer()

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